Python에서의 멀티태스킹: 프로세스 기반 병렬 처리

프로세스와 프로그램의 차이점

프로그램은 단순히 소스 코드 파일(예: main.py)과 같이 저장된 정적 상태를 의미합니다. 반면, 프로세스는 해당 프로그램이 실행되어 운영체제에 의해 메모리 상에 로드되고, 코드와 자원을 활용해 동작하는 동적인 인스턴스입니다. 프로세스는 시스템 자원 할당의 기본 단위이며, 각 프로세스는 독립적인 메모리 공간을 가집니다.

multiprocessing 모듈 소개

Python의 multiprocessing 모듈은 플랫폼 간 호환되는 멀티프로세싱을 지원하며, Process 클래스를 통해 개별 프로세스를 생성할 수 있습니다. 이 방식은 GIL(Global Interpreter Lock)의 제한을 우회하여 CPU 집약적 작업에서 진정한 병렬성을 실현할 수 있게 해줍니다.

기본 사용 예제

다음 코드는 춤추기와 노래 부르기를 각각 별도의 프로세스에서 수행하도록 합니다:

from multiprocessing import Process
import time
import os

def dance():
    print(f"춤 시작, PID: {os.getpid()}")
    for i in range(5):
        print(f"춤 중... {i}")
        time.sleep(0.5)
    print("춤 끝")

def sing():
    print(f"노래 시작, PID: {os.getpid()}")
    for i in range(5):
        print(f"노래 중... {i}")
        time.sleep(0.5)
    print("노래 끝")

if __name__ == "__main__":
    p1 = Process(target=dance)
    p2 = Process(target=sing)
    
    p1.start()
    p2.start()

    p1.join()
    p2.join()

이 코드는 두 작업이 동시에 실행되며, 각각의 출력이 교차해서 나타납니다.

Windows 환경에서의 주의사항

특정 IDE(예: IDLE, Sublime Text 등)에서는 multiprocessing이 정상적으로 작동하지 않을 수 있습니다. 이 문제는 일반적으로 Python 스크립트를 명령줄(CMD 또는 PowerShell)에서 직접 실행함으로써 해결됩니다. Windows는 프로세스 생성 시 spawn 방식을 사용하므로, if __name__ == "__main__": 보호 조건이 반드시 필요합니다.

프로세스에 인자 전달

함수에 매개변수를 전달하려면 argskwargs를 사용합니다:

def perform_activity(name, count, **info):
    print(f"{name}({info['age']}세)가 활동 시작, PID: {os.getpid()}")
    for i in range(count):
        print(f"{name}: {i}단계 수행")
        time.sleep(0.5)

if __name__ == "__main__":
    p1 = Process(target=perform_activity, args=("철수", 4), kwargs={"age": 12})
    p2 = Process(target=perform_activity, args=("영희", 6), kwargs={"age": 14})
    
    p1.start()
    p2.start()
    
    p1.join()
    p2.join()

프로세스 간 통신 (IPC)

서로 다른 프로세스는 메모리를 공유하지 않으므로, 데이터를 주고받기 위해 Queue 객체를 사용해야 합니다. multiprocessing.Queue는 프로세스 간 안전한 데이터 전송을 제공합니다.

from multiprocessing import Process, Queue
import time

def producer(q):
    for item in [10, 20, 30, 40]:
        q.put(item)
        print(f"생산: {item}")
        time.sleep(0.6)

def consumer(q):
    while not q.empty():
        value = q.get()
        print(f"소비: {value}")
        time.sleep(0.6)

if __name__ == "__main__":
    queue = Queue()
    
    p1 = Process(target=producer, args=(queue,))
    p2 = Process(target=consumer, args=(queue,))
    
    p1.start()
    p1.join()  # 생산 완료 후 소비 시작
    
    p2.start()
    p2.join()

프로세스 풀 (Process Pool)

수십 개 이상의 프로세스를 생성해야 하는 경우, 매번 개별적으로 생성하는 것은 비효율적입니다. 이때 Pool을 사용하면 최대 프로세스 수를 제한하면서도 작업을 효율적으로 분배할 수 있습니다.

from multiprocessing import Pool
import os
import time
import random

def task(name):
    start = time.time()
    print(f"[PID: {os.getpid()}] 작업 {name} 시작")
    time.sleep(random.uniform(1, 3))
    
    try:
        result = "완료"
        print(f"[PID: {os.getpid()}] 작업 {name} 성공")
    except Exception as e:
        print(f"[PID: {os.getpid()}] 작업 {name} 실패: {e}")
    
    end = time.time()
    print(f"[PID: {os.getpid()}] 작업 {name} 종료, 소요시간: {end - start:.2f}s")

if __name__ == "__main__":
    with Pool(processes=4) as pool:
        tasks = [f"작업_{i}" for i in range(1, 9)]
        pool.map(task, tasks)

위 예제에서는 최대 4개의 프로세스만을 사용하여 총 8개의 작업을 순차적으로 처리합니다. 하나의 작업이 끝나야 다음 작업이 시작됩니다.

프로세스 풀 내에서의 큐 사용

Pool을 사용할 때는 일반 multiprocessing.Queue() 대신 multiprocessing.Manager().Queue()를 사용해야 합니다. 그렇지 않으면 다음과 같은 런타임 오류가 발생합니다:

RuntimeError: Queue objects should only be shared between processes through inheritance.

이는 Pool이 프로세스를 복제(spool)하는 방식 때문이며, Manager는 프로세스 간 공유 가능한 객체를 생성해 줍니다.

프로세스 vs 스레드 비교

구분 프로세스 스레드
자원 소유 독립적인 메모리 공간과 시스템 자원을 가짐 부모 프로세스의 자원을 공유
생성 오버헤드 크고 느림 작고 빠름
보안성 높음 (격리됨) 낮음 (공유 메모리)
CPU 활용 GIL 영향 없음, 진정한 병렬 처리 가능 GIL로 인해 I/O 병렬만 가능
사용 목적 CPU 집약적 작업 I/O 집약적 작업

일반적으로 CPU 바운드 작업에는 multiprocessing, I/O 바운드 작업에는 threading 또는 asyncio를 사용하는 것이 적절합니다.

태그: multiprocessing Process Pool Queue Manager

6월 22일 01:45에 게시됨