Python의 동시성 (Concurrency)이나 병렬성 (Parallelism)을 구현하려 할 때 가장 많이 쓰이는 도구 중 하나가 concurrent.futures
의 ThreadPoolExecutor
와 ProcessPoolExecutor
둘의 차이를 제대로 이해하지 못하면 성능이 떨어지거나, 심하면 프로그램이 죽는 상황까지 발생
항목 | ThreadPoolExecutor | ProcessPoolExecutor |
---|---|---|
실행 단위 | 스레드(Thread) | 프로세스(Process) |
병렬성 유형 | I/O-bound 작업에 적합 | CPU-bound 작업에 적합 |
메모리 공간 | 프로세스 내부에서 공유 | 각 프로세스는 분리된 메모리 |
직렬화 필요 | 없음 | 필요 (Pickle 사용) |
GIL(Global Interpreter Lock) | 공유됨 (우회 불가) | 분리됨 (GIL 우회 가능) |
속도 | 가볍고 빠름 | 느리고 무거움 (프로세스 생성 비용) |
with ThreadPoolExecutor(max_workers=10) as executor:
results = executor.map(fetch_url, urls)
with ProcessPoolExecutor(max_workers=4) as executor:
results = executor.map(process_image, image_paths)
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
는 병렬 계산에 강하지만,semaphore = asyncio.Semaphore(4)
작업 유형 | 추천 Executor | 이유 |
---|---|---|
웹 크롤링, 파일 입출력 | ThreadPoolExecutor | GIL이 문제되지 않음, 가볍고 빠름 |
이미지 처리, 자연어 전처리 | ProcessPoolExecutor | GIL 우회, 병렬 CPU 사용 가능 |
모델 추론, GPU 처리 | ThreadPoolExecutor 또는 asyncio | GPU 세션 공유 필요 (Process는 위험) |
CPU + I/O 복합 처리 | 섞어서 사용 (asyncio + ProcessPoolExecutor ) | 구조 설계 필요 |
작업의 병목이 CPU 연산인가, I/O 대기인가? 에 따라서 Executor 선택이 달라짐
문서를 많이 올리고 ThreadPoolExecutor로 병렬처리하면, 결과가 섞이거나 순서가 꼬이는 것이 아닐까?
(결과가 어떤 문서에 대한 것인지 식별 안되거나, 입력 순서가 출력 순서가 다르다거나 병렬 처리 중 내부 데이터가 공유되어 꼬이는 상황이 발생하는거는 아닐까)
results = await asyncio.gather(*[
loop.run_in_executor(thread_pool, wrapper, file)
for file in files
])
wrapper(file)
은 독립적인 file만 처리하고gather()
는 입력 순서대로 결과를 반환위의 방식으로 한다면
ThreadPoolExecutor
로 아무리 많이 돌려도 결과는 섞이지 않음
def wrapper(file_id, file):
result = process(file)
return {file_id: result}
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)
save_path = f"output/{file_id}.json"
함수 단위 처리 + 입력 추적만 잘 하면 ThreadPoolExecutor
로 병렬 처리해도, 결과는 섞이지 않음
오히려 처리 속도는 훨씬 빨라지고, 순서도 gather
가 장해주기 때문에 잘 짠 구조라면 ThreadPoolExecutor는 안정적이고 좋은 방법이다.