[Python] ThreadPoolExecutor vs ProcessPoolExecutor

류지수·2025년 7월 3일
0

Python의 동시성 (Concurrency)이나 병렬성 (Parallelism)을 구현하려 할 때 가장 많이 쓰이는 도구 중 하나가 concurrent.futuresThreadPoolExecutorProcessPoolExecutor

둘의 차이를 제대로 이해하지 못하면 성능이 떨어지거나, 심하면 프로그램이 죽는 상황까지 발생

항목ThreadPoolExecutorProcessPoolExecutor
실행 단위스레드(Thread)프로세스(Process)
병렬성 유형I/O-bound 작업에 적합CPU-bound 작업에 적합
메모리 공간프로세스 내부에서 공유각 프로세스는 분리된 메모리
직렬화 필요없음필요 (Pickle 사용)
GIL(Global Interpreter Lock)공유됨 (우회 불가)분리됨 (GIL 우회 가능)
속도가볍고 빠름느리고 무거움 (프로세스 생성 비용)

ThreadPoolExecutor

적절한 상황: I/O 중심 작업

  • 웹 요청 보내기
  • 파일 읽기/쓰기
  • 데이터베이스 쿼리
  • 대기 시간이 긴 작업 (sleep, socket 등)

예시

with ThreadPoolExecutor(max_workers=10) as executor:
    results = executor.map(fetch_url, urls)
  • 장점: 빠르고 가볍다. 메모리 공유 가능, Pickle 안 써도 됨.
  • 주의점: GIL의 영향 때문에 CPU 계산은 병렬화 안 됨.

ProcessPoolExecutor

적절한 상황: CPU 연산 중심 작업

  • 이미지 처리
  • 머신러닝 피처 추출
  • 수치 계산, 통계, 대규모 반복
  • OCR, 텍스트 분석 등 CPU를 많이 쓰는 작업

예시

with ProcessPoolExecutor(max_workers=4) as executor:
    results = executor.map(process_image, image_paths)
  • 장점: 진짜 병렬 처리 (GIL 우회)
  • 주의점: 각 작업마다 객체를 피클해야 하며, 속도가 느릴 수 있음

Pickle 이슈

ProcessPoolExecutor는 작업 함수와 인자를 Pickle로 직렬화하여 프로세스에 전달. 이 때문에 다음과 같은 제약이 있음

피클 가능 여부예시
가능문자열, 숫자, 리스트, dict 등 기본 타입
불가lambda, 내부 함수, 열려 있는 파일 객체, GPU 세션, DB 커넥션 등
def outer():
    def inner(x): return x * x
    # ❌ inner는 피클 불가, ProcessPoolExecutor에서 오류 발생

이러한 이유로 ProcessPoolExecutor에서 아래와 같은 오류가 흔하게 나타남

AttributeError: Can't pickle local object ...

요약

  • ThreadPoolExecutor는 GIL 때문에 계산 성능이 낮지만, I/O는 매우 잘 처리함
  • ProcessPoolExecutor는 병렬 계산에 강하지만,
    • 초기화가 느리고
    • 피클링 이슈가 많으며
    • 자원 소모도 큼
  • 동시 실행 수를 반드시 제한해야함. 아무리 좋은 Executor라도 과도한 동시 실행은 서버를 죽임
semaphore = asyncio.Semaphore(4)
작업 유형추천 Executor이유
웹 크롤링, 파일 입출력ThreadPoolExecutorGIL이 문제되지 않음, 가볍고 빠름
이미지 처리, 자연어 전처리ProcessPoolExecutorGIL 우회, 병렬 CPU 사용 가능
모델 추론, GPU 처리ThreadPoolExecutor 또는 asyncioGPU 세션 공유 필요 (Process는 위험)
CPU + I/O 복합 처리섞어서 사용 (asyncio + ProcessPoolExecutor)구조 설계 필요

결론

작업의 병목이 CPU 연산인가, I/O 대기인가? 에 따라서 Executor 선택이 달라짐


궁금점

문서를 많이 올리고 ThreadPoolExecutor로 병렬처리하면, 결과가 섞이거나 순서가 꼬이는 것이 아닐까?
(결과가 어떤 문서에 대한 것인지 식별 안되거나, 입력 순서가 출력 순서가 다르다거나 병렬 처리 중 내부 데이터가 공유되어 꼬이는 상황이 발생하는거는 아닐까)

1. 함수 단위로 처리하고, 공유 상태가 없다면

results = await asyncio.gather(*[
    loop.run_in_executor(thread_pool, wrapper, file)
    for file in files
])
  • wrapper(file)은 독립적인 file만 처리하고
  • 내부적으로 공유 변수 없이 동작하면
  • 결과는 순서 보장되고, 섞이지 않음
  • gather()는 입력 순서대로 결과를 반환

위의 방식으로 한다면 ThreadPoolExecutor로 아무리 많이 돌려도 결과는 섞이지 않음

섞일 수 있는 위험한 상솽

  1. 전역 변수 사용
  • 여러 스레드가 같은 변수 접근 → race condition
  1. 파일 저장 이름 충돌
  • 예: 모든 같은 이름으로 결과 저장
  1. 순서에 의존하는 로직
  • 예: 처리 결과를 입력 순서로 엮어야 하는데 순서를 따로 관리 안함

안전하게 처리 방법

1. 파일마다 ID 또는 이름 유지

def wrapper(file_id, file):
    result = process(file)
    return {file_id: result}

2. 결과를 리스트가 아니라 딕셔너리로 저장

results = await asyncio.gather(*[
    loop.run_in_executor(pool, wrapper, file_id, file)
    for file_id, file in files
])

final = {}
for item in results:
    final.update(item)

3. 저장 시 파일 이름 충돌 방지

save_path = f"output/{file_id}.json"

결론

함수 단위 처리 + 입력 추적만 잘 하면 ThreadPoolExecutor로 병렬 처리해도, 결과는 섞이지 않음
오히려 처리 속도는 훨씬 빨라지고, 순서도 gather가 장해주기 때문에 잘 짠 구조라면 ThreadPoolExecutor는 안정적이고 좋은 방법이다.

profile
끄적끄적

0개의 댓글