Python 멀티프로세싱: GIL 극복과 병렬 처리

GIL의 한계와 멀티프로세싱의 필요성

CPython의 전역 인터프리터 락(GIL)은 한 시점에 단 하나의 스레드만 바이트코드를 실행하도록 제한합니다. 이는 CPU 집약적 작업에서 멀티스레딩의 성능 향상을 막는 근본적인 병목입니다. I/O 대기 중에는 GIL이 해제되어 다른 스레드가 실행될 수 있으나, 순수 계산 작업에서는 여전히 단일 코어만 활용하게 됩니다.

이 제약을 극복하려면 독립된 파이썬 인터프리터를 다수 실행하는 멀티프로세싱이 필요합니다. 각 프로세스는 별도의 메모리 공간과 GIL을 갖추므로, 다중 코어를 실제로 병렬 활용할 수 있습니다.

Process 클래스로 프로세스 생성하기

multiprocessing 패키지의 Process 클래스는 threading.Thread와 유사한 인터페이스를 제공합니다. 기본 사용법은 다음과 같습니다.

from multiprocessing import Process
import os

def compute_square(number):
    result = number ** 2
    print(f"PID {os.getpid()}: {number}^2 = {result}")

if __name__ == '__main__':
    digits = [3, 5, 7, 9]
    workers = []
    
    for d in digits:
        worker = Process(target=compute_square, args=(d,))
        worker.start()
        workers.append(worker)
    
    for worker in workers:
        worker.join()

주요 매개변수와 메서드

구성 요소설명
target자식 프로세스에서 실행할 호출 가능 객체
args, kwargs대상 함수에 전달할 위치 인자와 키워드 인자
name프로세스 식별용 문자열
daemonTrue 설정 시 부모 종료와 함께 자식도 종료
start()프로세스 실행을 스케줄링
join(timeout=None)프로세스 종료까지 대기, 좀비 프로세스 방지
is_alive()프로세스 실행 중 여부 확인
terminate()프로세스 강제 종료
exitcode종료 상태 코드 (None: 실행 중, 음수: 신호로 종료)

클래스 상속 방식

복잡한 로직은 Process를 상속받아 run() 메서드를 재정의하는 것이 유리합니다.

from multiprocessing import Process
import time
import random

class DataProcessor(Process):
    def __init__(self, dataset_id, queue):
        super().__init__()
        self.dataset_id = dataset_id
        self.queue = queue
    
    def run(self):
        delay = random.uniform(0.5, 2.0)
        time.sleep(delay)
        self.queue.put(f"Dataset {self.dataset_id} processed in {delay:.2f}s")

# 사용 예시
from multiprocessing import Queue

if __name__ == '__main__':
    result_queue = Queue()
    processors = [DataProcessor(i, result_queue) for i in range(4)]
    
    for p in processors:
        p.start()
    
    for p in processors:
        p.join()
    
    while not result_queue.empty():
        print(result_queue.get())

데몬 프로세스와 종료 동작

daemon=True로 설정하면 부모 프로세스 종료 시 자식도 강제 종료됩니다. 백그라운드 작업에 유용하지만, join() 없이 메인 프로세스가 먼저 끝나면 자식의 작업이 완료되지 못할 수 있습니다.

from multiprocessing import Process
import time

def background_task():
    for i in range(10):
        time.sleep(1)
        print(f"Working... step {i}")

if __name__ == '__main__':
    daemon_proc = Process(target=background_task)
    daemon_proc.daemon = True  # start() 호출 전에 설정 필수
    daemon_proc.start()
    
    time.sleep(2.5)  # 메인 프로세스 종료, 데몬 프로세스도 함께 종료
    print("Main exiting")

프로세스 간 메모리 분리

각 프로세스는 독립된 가상 주소 공간을 사용하므로, 전역 변수를 통한 데이터 공유가 불가능합니다.

from multiprocessing import Process

shared_value = 100

def modify_global():
    global shared_value
    shared_value = 0
    print(f"Child process: {shared_value}")  # 0

if __name__ == '__main__':
    p = Process(target=modify_global)
    p.start()
    p.join()
    print(f"Parent process: {shared_value}")  # 여전히 100

실제 데이터 공유가 필요하다면 Queue, Pipe, Manager, 또는 shared_memory를 활용해야 합니다.

프로세스 풀(Pool)로 효율적인 병렬화

대량의 작업을 처리할 때는 직접 Process를 관리하기보다 Pool을 사용하는 것이 바람직합니다.

from multiprocessing import Pool
import os

def intensive_calculation(params):
    idx, base = params
    # CPU 집약적 작업 시뮬레이션
    total = sum(i * base for i in range(10**6))
    return f"Worker {os.getpid()} handled index {idx}, result tail: {total % 10000}"

if __name__ == '__main__':
    inputs = [(i, i + 1) for i in range(8)]
    
    with Pool(processes=4) as pool:
        # 비동기 결과 수집
        async_results = [pool.apply_async(intensive_calculation, (item,)) for item in inputs]
        outputs = [res.get() for res in async_results]
    
    for line in outputs:
        print(line)

Pool의 주요 옵션

  • processes: 워커 프로세스 수 (None 시 os.cpu_count())
  • initializer, initargs: 각 워커 시작 시 실행할 초기화 함수
  • maxtasksperchild: 지정 횟수 작업 후 워커 재생성 (메모리 누수 방지)

비 프로세스와 고아 프로세스

좀비 프로세스(Zombie Process)

자식 프로세스가 종료되었으나 부모가 wait() 또는 waitpid()로 상태를 수거하지 않은 경우, 프로세스 테이블에 잔여하는 항목이 발생합니다. PID 고갈의 원인이 되므로 반드시 join()으로 예방해야 니다.

고아 프로세스(Orphan Process)

부모가 먼저 종료된 자식 프로세스는 init(PID 1)이 입양하여 자동으로 정리합니다. 시스템에 즉각적인 해를 끼치지는 않습니다.

from multiprocessing import Process
import os, time

def orphan_demo():
    time.sleep(2)
    print(f"Orphan's new parent: {os.getppid()}")

if __name__ == '__main__':
    child = Process(target=orphan_demo)
    child.start()
    # 의도적으로 join()을 호출하지 않고 부모 종료
    print(f"Parent {os.getpid()} exiting immediately")

실전 예시: 동시 접속 TCP 서버

프로세스 풀을 활용하여 클라이언트 요청을 병렬 처리하는 에코 서버입니다.

from socket import socket, AF_INET, SOCK_STREAM, SOL_SOCKET, SO_REUSEADDR
from multiprocessing import Pool

def handle_client(conn_sock, addr):
    with conn_sock:
        while True:
            payload = conn_sock.recv(4096)
            if not payload:
                break
            conn_sock.sendall(payload.upper())
    print(f"Closed connection from {addr}")

def run_server(host='127.0.0.1', port=8080, workers=4):
    server = socket(AF_INET, SOCK_STREAM)
    server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
    server.bind((host, port))
    server.listen(128)
    print(f"Server listening on {host}:{port}")
    
    with Pool(processes=workers) as pool:
        try:
            while True:
                client_conn, client_addr = server.accept()
                pool.apply_async(handle_client, (client_conn, client_addr))
        except KeyboardInterrupt:
            print("Shutting down server")
        finally:
            server.close()

if __name__ == '__main__':
    run_server()

Windows 환경에서는 if __name__ == '__main__': 블록이 필수적입니다. 모듈 임포트 시 Process가 재귀적으로 생성되는 것을 방지하기 위함입니다.

태그: python multiprocessing Process Pool GIL

8월 11일 12:56에 게시됨