본문 바로가기

Python/자료구조 (Data Structure)

6-1. 멀티 프로세스와 멀티 스레드 (Multi-process and Multi-thread): 파이썬 자료구조와 알고리즘

멀티 프로세스와 멀티 스레드

운영 체제에서 실행되는 각 프로그램은 각가이 별도의 프로세스 (process) 이다. 각 프로세스에는 하나 이상의 스레드 (thread) 가 있다.

프로세스란, 자기 자신만의 주소 공간을 갖는 독립적인 (Self-contained) 실행 프로그램이다.

스레드란, 프로세스 내의 독립적인 순차흐름 또는 제어이고, 멀티 스레드란 하나의 프로세스에서 여러 개의 스레드가 병행적으로 처리되는 것을 말한다.

한 프로세스에 여러 개의 스레드가 있다면, 마치 여러 작업을 동시에 수행하는 것처럼 보인다. 멀티 프로세스와 멀티 스레드라는 두 가지 방법을 사용하면 프로그램의 작업 부하를 분산시킬 수 있다.

  • 멀티 프로세스

멀티 프로세스는 별도의 메모리 영역을 가지며, 특별한 메커니즘 (예: 시그널, 메시지, 큐, 파이프, 파일 등이 통신하는 프로세스 간 통신, inter-process communication - IPC, 같은 방법으로) 으로만 통신할 수 있다. 프로세서는 각 스레드에 별토의 레지스터 집합을 불러오거나 저장하며, 프로세스 간 데이터 공유와 통신용으로는 비효율 적이다. 바이썬에서는 멀티 프로세스 방식에 subprocess 모듈을 사용한다.

  • 멀티 스레드

단일 프로세스 내의 멀티 스레드는 동일한 메모리에 접근한다. 스레드는 데이터 공유를 통해 간단하게 통신하는데, threading 모듈의 처리를 통해 한 번에 한 스레드만 메모리 영역에 접근할 수 있다. 각 프로세스가 독립적인 스택 (stack), 힙 (heap), 코드 (code), 데이터 (data) 영역을 가지는 반면, 한 프로세스에 속한 스레드는 스택 영역을 제외한 메모리 영역을 공유한다.

파이썬에 스레드 메커니즘이 있긴 하지만 이는 진정한 병렬 (parallel) 실행을 지원하는 것이 아니다. 하지만 프로세스를 병렬로 사용하는 것은 가능하며, 오늘 날 운영체제에서는 이 또한 충분히 효율적이다.

동시성과 병렬성

동시성 (concurrency) 은 논리적으로 여러 작업이 동시에 실행되는 것처럼 보이는 것이다. 예를 들어 I/O (파일 및 네트워크 소켓 입력 및 출력) 연산 등은 프로그램의 흐름에 큰 짐이 될 수 있다. 이럴 때 한 작업의 I/O 연산이 완료되기를 기다리는 동안 다른 작업을 수행하여 유휴 시간을 활용하는 것이 동시성이다.

병렬성 (parallelism) 은 물리적으로 여러 작업이 동시에 처리되는 것이다. 데이터 병렬성과 작업 병렬성으로 나눌 수 있다. 데이터 병렬성은 같은 작업을 병렬 처리 하는 것이다. 하나의 커다란 작업에서 전체 데이터를 쪼갠 후 병렬처리하면 작업을 빠르게 수행할 수 있다. 작업 병렬성은 서로 다른 작업을 병렬처리하는 것이다. 웹 서버에서는 다수의 독립적인 요청을 병렬로 개별적으로 처리할 수 있다.

subprocess 모듈

subprocess 모듈은 '부모-자식 (parent-child)' 프로세스 쌍을 생성하는 데 사용된다. 부모 프로세스는 사용자에 의해 실행되며, 부모 프로세스는 차례로 다른 일을 처리하는 자식 프로세스의 인스턴스를 실행한다. 자식 프로세스를 사용함으로써, 멀티 코어의 이점을 최대한 취하고, 동시성 (concurrency) 문제를 운영 체제가 알아서 처리하도록 한다.

threading 모듈

스레드가 여러 개로 분리되면 스레드 간 데이터 공유의 복잡성이 증가한다. 또한 락 (lock) 과 데드락 (deadlock) 을 회피하는 데 주의를 기울여야 한다. 파이썬 프로그램에는 단 하나의 메인 스레드만 존재하며, 멀티 스레드를 사용하려면 threading 모듈을 사용해야 한다.

내부적으로 락을 관리하려면 queue 모듈을 사용한다. 큐에 의존하면 자원의 접근을 직렬화 (한 번에 하나씩) 할 수 있고, 이는 곧 한 번에 하나의 스레드만 데이터에 접근할 수 있게 한다는 뜻이다 (FIFO, first-in first-out 방식으로). 실행 중인 스레드가 있는 동안에는 프로그램은 종료되지 않는다.

워커 스레드 (worker thread) 가 작업을 완료했는데도 프로그램이 종료되지 않고 계속 실행되는 경우 문제가 될 수 있다. 스레드를 데몬 (daemon) 으로 변환하면 데몬 스레드가 실행되지 않는 즉시 프로그램이 종료된다. queue.join() 메서드는 큐가 빌 때까지 (큐의 모든 항목이 처리될 때까지) 기다린다. queue 모듈의 공식 문서 예제를 조금 수정한 다음 코드를 살펴보자.

import queue
import threading

q = queue.Queue()

def worker(num):
    while True:
        item = q.get()
        if item is None:
            break
        # 작업 처리
        print("스레드 {0}: 처리 완료 {1}".format(num+1, item))
        q.task_done()

# 스레드 정의
num_worker_threads = 5
threads = []

# 스레드에 작업 추가
for i in range(num_worker_threads):
    t = threading.Thread(target=worker, args=(i,))
    t.start()
    threads.append(t)

# 큐에 아이템 20개 추가
for item in range(20):
    q.put(item)

q.join()

for i in range(num_worker_threads):
    q.put(None)
for t in threads:
    t.join()

출력:

스레드 1: 처리 완료 0
스레드 2: 처리 완료 1
스레드 5: 처리 완료 4
스레드 3: 처리 완료 3
스레드 4: 처리 완료 2
스레드 1: 처리 완료 5
스레드 2: 처리 완료 6
스레드 5: 처리 완료 7
스레드 3: 처리 완료 8
스레드 4: 처리 완료 9
스레드 1: 처리 완료 10
스레드 2: 처리 완료 11
스레드 5: 처리 완료 12
스레드 3: 처리 완료 13
스레드 4: 처리 완료 14
스레드 1: 처리 완료 15
스레드 2: 처리 완료 16
스레드 5: 처리 완료 17
스레드 3: 처리 완료 18
스레드 4: 처리 완료 19

뮤텍스와 세마포어

뮤텍스 (mutex) 는 락과 같다. 뮤텍스는 공유 리소스에 한 번에 하나의 스레드만 접근할 수 있도록 하는 상호 배제 (mutual exclusion) 동시성 제어 정책을 강제하기 위해 설계되었다. 가령, 한 스레드가 배열을 수정 중인데, 배열 작업을 절반 이상 수행하면 프로세서가 다른 스레드로 전환한다고 가정하면, 여기서 뮤텍스를 사용해야만 전환이 되며, 그렇지 않는다면 두 스레드가 동시에 배열을 수정하는 일이 벌어진다.

개념적으론 뮤텍스는 1부터 시작하는 정수다. 스레드는 배열을 변경해야할 때 뮤텍스를 '잠근다'. 스레드는 뮤텍스가 양수가 될 때 까지 대기한 다음 숫자를 1 감소시킨다 (이것이 곧 락이다). 배열 수정을 마치면, 뮤텍스가 잠금 해제되어 숫자를 1 감소시킨다 (언락). 배열을 수정하기 전 뮤텍스를 잠근 후, 수정 작업이 끝나고 잠금을 해제하면, 두 스레드가 배열을 동시에 수정하는 일은 일어나지 않을 것이다.

다음 코드를 보자.

from threading import Thread, Lock
import threading

def worker(mutex, data, thread_safe):
    if thread_safe:
        mutex.acquire()
    try:
        print("스레드 {0} {1}\n".format(threading.get_ident(), data))
    finally:
        if thread_safe:
            mutex.release()

threads = []
thread_safe = False
mutex = Lock()

for i in range(20):
    t = Thread(target=worker, args=(mutex, i, thread_safe))
    t.start()
    threads.append(t)
for t in threads:
    t.join()

결과

스레드 16904 0

스레드 20680 1
스레드 21784 2


스레드 21292 4

스레드 24836 6

스레드 832 8

스레드 10092 3
스레드 30884 11

스레드 17724 9
스레드 31208 7
스레드 3504 12

스레드 29372 14


스레드 14332 16
스레드 18708 5
스레드 31868 15

스레드 14448 13


스레드 32608 18




스레드 8848 19

스레드 26832 10

스레드 9600 17

실행할 때 마다 다른 결과가 나온다. 그러면 이제 thread_safe를 True로 바꾸고 다시 실행해보면?

스레드 16660 0

스레드 19524 1

스레드 27156 2

스레드 26516 3

스레드 26756 4

스레드 15000 5

스레드 19284 6

스레드 7948 7

스레드 31108 8

스레드 25232 9

스레드 30644 10

스레드 1008 11

스레드 18820 12

스레드 31592 13

스레드 21296 14

스레드 32164 15

스레드 14312 16

스레드 32128 17

스레드 21952 18

스레드 19832 19

위와 같이 제대로 출력이 잘 된다.

한 편, 세마포어 (semaphore) 는 뮤텍스보다 더 일반적으로 사용되는 개념이다. 세마포어는 1보다 큰 수로 시작할 수 있으며, 세마포어의 값이 곧 한 번에 자원에 접근할 수 있는 스레드의 수이다. 다음 예제를 보자.

import threading
import time

class ThreadPool(object):
    def __init__(self):
        self.active = []
        self.lock = threading.Lock()

    def acquire(self, name):
        with self.lock:
            self.active.append(name)
            print("획득: {0} | 스레드 풀: {1}".format(name, self.active))

    def release(self, name):
        with self.lock:
            self.active.remove(name)
            print("반환: {0} | 스레드 풀: {1}".format(name, self.active))

def worker(semaphore, pool):
    with semaphore:
        name = threading.get_ident()
        pool.acquire(name)
        time.sleep(1)
        pool.release(name)

threads = []
pool = ThreadPool()
semaphore = threading.Semaphore(3)
for i in range(10):
    t = threading.Thread(target=worker, name="스레드" + str(i), args=(semaphore, pool))
    t.start()
    threads.append(t)

for t in threads:
    t.join()

결과

획득: 1092 | 스레드 풀: [1092]
획득: 18636 | 스레드 풀: [1092, 18636]
획득: 14644 | 스레드 풀: [1092, 18636, 14644]
반환: 1092 | 스레드 풀: [18636, 14644]
획득: 25108 | 스레드 풀: [18636, 14644, 25108]
반환: 18636 | 스레드 풀: [14644, 25108]
획득: 24880 | 스레드 풀: [14644, 25108, 24880]
반환: 14644 | 스레드 풀: [25108, 24880]
획득: 1348 | 스레드 풀: [25108, 24880, 1348]
반환: 25108 | 스레드 풀: [24880, 1348]
반환: 24880 | 스레드 풀: [1348]
획득: 17732 | 스레드 풀: [1348, 17732]
반환: 1348 | 스레드 풀: [17732]
획득: 26168 | 스레드 풀: [17732, 26168]
획득: 16136 | 스레드 풀: [17732, 26168, 16136]
반환: 17732 | 스레드 풀: [26168, 16136]
획득: 7124 | 스레드 풀: [26168, 16136, 7124]
반환: 26168 | 스레드 풀: [16136, 7124]
반환: 16136 | 스레드 풀: [7124]
반환: 7124 | 스레드 풀: []

보다시피 세마포어를 3으로 지정해 주었기 때문에 한 번에 세개까지의 스레드를 담당할 수 있다.

데드락과 스핀락

데드락 (deadlock, 교착 상태) 은 두 개 이상의 프로세스나 스레드가 서로 상대방의 작업이 끝나기만을 기다리고 있어 결과적으로 아무 것도 완료되지 못하는 상태를 말한다. 프로그램에서 락을 할당하고, 락을 순서대로 획득한다면 교착 상태를 막을 수 있다 (그러나 이는 일반적인 접근법일 뿐, 정교한 방법은 아니다).

다음 네 가지 조건을 모두 충족해야 데드락이 발생한다. 네 가지 중 하나라도 막는다면 발생하지 않는다.

  1. 상호 배제 (mutual exclusion): 자원은 한 번에 한 프로세스 (혹은 스레드) 만 사용할 수 있다.
  2. 점유와 대기 (hold and wait): 한 프로세스가 자원을 가지고 있는 상태에서, 다른 프로세스가 쓰는 자원의 반납을 기다린다.
  3. 비선점 (no preemption): 다른 프로세스가 이미 점유한 자원을 강제로 뺏어오지 못한다.
  4. 순환 대기 (circular wait): 프로세스 A, B, C가 있다고 가정할 때, A는 B가 점유한 자원을, B는 C가 점유한 자원을, C는 A가 점유한 자원을 대기하는 상태이다.

스핀락 (spinlock) 은 (전체 시스템이 단일 애플리케이션 전용이고, 코어당 하나의 스레드만 사용하는) 고성능 컴퓨팅 상황에 유용한 바쁜 대기 (busy waiting) 의 한 형태다. 스핀락은 임계 구역에 진입이 불가능할 때, 진입이 가능할 때까지 반복문을 돌며 재시도하는 방식으로 구현된 락이다.

스레딩에 대한 구글 파이썬 스타일 가이드

내장 타입의 원자성 (atomicity) 에 의존하지 않는다. 딕셔너리 같은 파이썬 기본 데이터 타입은 원자적 연산을 수행하는 반면, 내장 타입이 원자적이지 않은 경우 (__hash__() 또는 __eq__() 메서드가 구현된 경우) 가 있어서, 내장 타입의 원자성에 의존해선 안된다. 또한 원자적 변수 할당에 의존하지 않아야 한다.

queue 모듈의 Queue 데이터 타입을 스레드 간 데이터를 전달하는 기본 방식으로 사용한다. 그렇지 않다면 threading 모듈의 락을 사용한다. 저수준의 락 대신 threading.Condition을 사용할 수 있도록 조건 변수를 적절하게 사용하는 방법을 숙지한다.

출처

파이썬 자료구조와 알고리즘 - 한빛미디어, 미아 스타인

[병렬프로그래밍]프로세스, 스레드 개념