파이썬 동시성 프로그래밍: 멀티프로세스와 멀티스레드 활용 가이드

1. 멀티프로세스 (Multi-processing)

프로세스는 실행 중인 프로그램의 인스턴스로, 각 프로세스는 독립된 메모리 공간을 가집니다. 파이썬에서는 multiprocessing 모듈을 통해 병렬 처리를 구현할 수 있습니다.

프로세스 생성의 두 가지 방식

첫 번째 방식은 Process 클래스를 직접 인스턴스화하여 대상 함수를 지정하는 방식이며, 두 번째 방식은 Process 클래스를 상속받아 run 메서드를 오버라이딩하는 방식입니다.

from multiprocessing import Process
import time
import os

# 방식 1: 함수 기반 호출
def execute_work(label):
    print(f"작업 시작: {label} (PID: {os.getpid()})")
    time.sleep(2)
    print(f"작업 완료: {label}")

# 방식 2: 클래스 상속 기반
class CustomWorker(Process):
    def __init__(self, identifier):
        super().__init__()
        self.identifier = identifier

    def run(self):
        print(f"워커 프로세스 {self.identifier} 가동 중...")
        time.sleep(2)
        print(f"워커 프로세스 {self.identifier} 종료")

if __name__ == '__main__':
    # 방식 1 실행
    p1 = Process(target=execute_work, args=('SubProcess-1',))
    p1.start()

    # 방식 2 실행
    p2 = CustomWorker('Worker-01')
    p2.start()

    p1.join()
    p2.join()
    print("메인 프로세스 종료")

프로세스 간 메모리 독립성

프로세스는 메모리 공간을 공유하지 않습니다. 자식 프로세스에서 전역 변수를 수정하더라도 부모 프로세스의 변수에는 영향을 주지 않습니다.

from multiprocessing import Process

base_value = 1000

def modify_data():
    global base_value
    base_value = 0
    print(f"자식 프로세스 내부 값: {base_value}")

if __name__ == '__main__':
    proc = Process(target=modify_data)
    proc.start()
    proc.join()
    print(f"메인 프로세스 유지 값: {base_value}") # 결과는 여전히 1000

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

  • 좀비 프로세스: 자식 프로세스가 종료되었지만 부모 프로세스가 자원의 상태를 회수(join 등)하지 않아 시스템 테이블에 남아있는 상태입니다.
  • 고아 프로세스: 부모 프로세스가 먼저 종료되어 자식 프로세스가 남겨진 경우입니다. 이 경우 운영체제(init 프로세스)가 자식을 입양하여 자원을 회수합니다.

데몬 프로세스 (Daemon Process)

부모 프로세스가 종료될 때 함께 강제 종료되는 프로세스입니다. p.daemon = True 설정을 통해 활성화하며, 반드시 start() 호출 전에 설정해야 합니다.

2. 프로세스 동기화와 통신 (IPC)

여러 프로세스가 동시에 동일한 자원에 접근할 때 데이터 무결성을 지키기 위해 Mutex Lock을 사용하거나, 데이터를 주고받기 위해 Queue를 사용합니다.

상호 배제 (Lock) 활용

from multiprocessing import Process, Lock
import json
import time

def update_inventory(lock, user):
    lock.acquire()
    try:
        # 데이터 읽기 및 수정 시뮬레이션
        with open('stock.json', 'r') as f:
            db = json.load(f)
        
        if db['count'] > 0:
            db['count'] -= 1
            time.sleep(0.1) # 네트워크 지연 가정
            with open('stock.json', 'w') as f:
                json.dump(db, f)
            print(f"{user} 구매 성공")
        else:
            print(f"{user} 재고 부족")
    finally:
        lock.release()

if __name__ == '__main__':
    mutex = Lock()
    for i in range(5):
        Process(target=update_inventory, args=(mutex, f"User-{i}")).start()

3. 멀티스레드 (Multi-threading)

스레드는 프로세스 내부의 실행 단위입니다. 같은 프로세스 내의 스레드들은 메모리 공간을 공유하므로 통신이 빠르지만, 동기화 문제에 유의해야 합니다.

from threading import Thread, Lock
import time

shared_resource = 0
thread_lock = Lock()

def increase_counter():
    global shared_resource
    with thread_lock:
        current = shared_resource
        time.sleep(0.01)
        shared_resource = current + 1

if __name__ == '__main__':
    threads = []
    for _ in range(50):
        t = Thread(target=increase_counter)
        t.start()
        threads.append(t)
    
    for t in threads:
        t.join()
    print(f"최종 카운트: {shared_resource}")

4. GIL (Global Interpreter Lock)과 성능

CPython 인터프리터는 한 번에 하나의 스레드만 바이트코드를 실행하도록 보장하는 GIL을 사용합니다. 이로 인해 멀티 코어 환경에서도 멀티스레딩이 CPU 연산 작업에서 큰 성능 향상을 내지 못할 수 있습니다.

  • CPU 집약적 작업: 멀티프로세스 사용 권장 (GIL 우회, 병렬 처리 가능)
  • I/O 집약적 작업: 멀티스레드 사용 권장 (대기 시간 동안 다른 스레드 실행 가능)

5. 스레드 풀과 프로세스 풀

시스템 자원을 효율적으로 관리하기 위해 미리 일정한 수의 스레드/프로세스를 만들어 두고 재사용하는 풀(Pool) 방식을 사용합니다.

from concurrent.futures import ThreadPoolExecutor
import requests

def fetch_url(url):
    response = requests.get(url)
    return f"{url}: {len(response.content)} bytes"

urls = ["https://www.google.com", "https://www.python.org", "https://www.github.com"]

if __name__ == '__main__':
    with ThreadPoolExecutor(max_workers=3) as executor:
        results = list(executor.map(fetch_url, urls))
    
    for res in results:
        print(res)

6. 코루틴 (Coroutine)

코루틴은 사용자 레벨에서 스케줄링되는 경량 스레드입니다. 단일 스레드 내에서 I/O 발생 시 실행 권한을 전환하여 동시성을 극대화합니다.

from gevent import monkey; monkey.patch_all()
import gevent
import time

def async_task(name, duration):
    print(f"{name} 시작")
    gevent.sleep(duration)
    print(f"{name} 완료")

if __name__ == '__main__':
    start = time.time()
    gevent.joinall([
        gevent.spawn(async_task, 'Task-A', 2),
        gevent.spawn(async_task, 'Task-B', 1),
        gevent.spawn(async_task, 'Task-C', 3)
    ])
    print(f"전체 소요 시간: {time.time() - start:.2f}초")

태그: python multiprocessing Multithreading GIL coroutine

7월 22일 10:06에 게시됨