- Published on
Python multiprocessing 멈춤? daemonic·pickle 에러 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 코드에서 multiprocessing을 붙였는데 프로그램이 갑자기 멈추거나, daemonic processes are not allowed to have children, Can't pickle local object ... 같은 에러가 터지면 보통 “병렬 처리 자체가 불안정하다”라고 오해하기 쉽습니다. 하지만 실제로는 몇 가지 고질적인 패턴(프로세스 시작 방식, 직렬화 가능 여부, 큐/파이프의 백프레셔, join 순서, 데몬 프로세스 제약)을 정확히 밟고 있는 경우가 대부분입니다.
이 글에서는 멈춤(hang)과 daemonic/pickle 에러를 재현 가능한 최소 예제로 설명하고, 원인별로 안전한 구조로 고치는 방법을 정리합니다.
네트워크 타임아웃이나 외부 시스템 지연도 “멀티프로세싱이 멈춘 것처럼” 보이게 만들 수 있습니다. API 호출이 섞인 워커라면 타임아웃/재시도 설계를 함께 점검하세요. 관련: OpenAI Responses API 408 타임아웃 재현과 해결 실전 가이드
1) 멈춤(hang)의 전형: Queue가 꽉 차서 생산자/소비자 교착
멀티프로세싱에서 가장 흔한 멈춤은 “코드가 느린 게 아니라 서로 기다리느라 멈춘 상태”입니다. 특히 multiprocessing.Queue는 내부적으로 파이프와 버퍼를 쓰기 때문에, 생산자가 너무 빨리 put()을 하면 큐가 꽉 차고 블로킹되며, 소비자가 get()을 못 하고 있으면 전체가 정지합니다.
재현 코드: 소비자가 늦으면 생산자가 멈춘다
import multiprocessing as mp
import time
def producer(q: mp.Queue) -> None:
for i in range(10_000):
q.put(i) # 소비가 느리면 여기서 블로킹
q.put(None)
def consumer(q: mp.Queue) -> None:
while True:
item = q.get()
if item is None:
return
time.sleep(0.01) # 일부러 느리게
if __name__ == "__main__":
q = mp.Queue(maxsize=100) # 작게 잡으면 쉽게 재현
p1 = mp.Process(target=producer, args=(q,))
p2 = mp.Process(target=consumer, args=(q,))
p1.start(); p2.start()
p1.join(); p2.join()
해결 전략
Queue(maxsize=...)를 너무 작게 잡지 않기- 생산자/소비자 속도 균형 맞추기(배치 처리, 소비자 수 늘리기)
- **종료 시그널(센티널)**을 소비자 수만큼 보내기
join()순서와close()/cancel_join_thread()를 올바르게 사용하기
센티널을 소비자 수만큼 보내는 패턴
import multiprocessing as mp
def worker(q: mp.Queue) -> None:
for item in iter(q.get, None):
# do work
pass
if __name__ == "__main__":
n_workers = 4
q = mp.Queue()
procs = [mp.Process(target=worker, args=(q,)) for _ in range(n_workers)]
for p in procs:
p.start()
for i in range(1000):
q.put(i)
for _ in range(n_workers):
q.put(None) # 워커 수만큼
for p in procs:
p.join()
2) daemonic processes are not allowed to have children의 의미와 해결
이 에러는 “데몬 프로세스가 자식 프로세스를 만들 수 없다”는 제약을 정확히 말합니다.
multiprocessing.Process(daemon=True)로 만든 프로세스- 또는
multiprocessing.Pool의 워커(환경/버전에 따라 데몬으로 동작)
이런 데몬 성격의 프로세스 내부에서 다시 Process나 Pool을 만들면 터집니다.
재현 코드: 풀 워커 안에서 또 풀을 만들기
import multiprocessing as mp
def inner_task(x: int) -> int:
return x * x
def outer_task(x: int) -> int:
# (문제) 워커 프로세스 안에서 또 프로세스를 만들려 함
with mp.Pool(2) as p:
return sum(p.map(inner_task, [x, x + 1]))
if __name__ == "__main__":
with mp.Pool(2) as p:
print(p.map(outer_task, [1, 2, 3]))
해결 1: “중첩 프로세스”를 피하고 병렬 레벨을 한 번만 쓰기
대부분의 경우 가장 좋은 해법은 병렬화 지점을 하나로 고정하는 겁니다.
- 바깥에서만
Pool을 쓰고, 안쪽은 순차 처리 - 또는 바깥은 스레드/비동기로, CPU 바운드만 프로세스로
해결 2: 바깥은 프로세스, 안쪽은 스레드로
I/O 바운드(HTTP 호출, DB)라면 내부 병렬은 ThreadPoolExecutor가 안전합니다.
import multiprocessing as mp
from concurrent.futures import ThreadPoolExecutor
def inner_io(x: int) -> int:
return x + 1
def outer_cpu(x: int) -> int:
with ThreadPoolExecutor(max_workers=8) as ex:
return sum(ex.map(inner_io, [x] * 100))
if __name__ == "__main__":
with mp.Pool(4) as p:
print(p.map(outer_cpu, [1, 2, 3, 4]))
해결 3: 정말 중첩이 필요하면 “프로세스 시작 방식”과 설계를 재검토
리눅스 기본인 fork는 부모의 상태를 복제하기 때문에, 중첩 병렬에서 락/소켓/스레드 상태가 꼬여 더 큰 문제를 만들 수 있습니다. 안전을 위해 spawn을 쓰는 편이 낫지만, spawn은 모든 것이 pickle 가능해야 하고 초기화 비용이 증가합니다.
import multiprocessing as mp
if __name__ == "__main__":
mp.set_start_method("spawn", force=True)
# 이후 프로세스 생성
중첩 병렬은 성능보다 안정성 비용이 커지는 경우가 많아, 가능하면 “한 단계만 병렬”로 구조를 바꾸는 것을 권장합니다.
3) Can't pickle local object ...가 나는 이유와 고치는 법
multiprocessing은 워커 프로세스에 함수를 전달할 때(특히 spawn/윈도우) pickle로 직렬화합니다. 따라서 다음은 직렬화가 안 됩니다.
- 함수 내부에 정의된 로컬 함수/클로저
lambda- 일부 바인딩된 객체(열린 파일 핸들, 소켓, 락, DB 커넥션)
__main__에서만 존재하는 비정상 참조
재현 코드: 로컬 함수는 pickle 불가
import multiprocessing as mp
def main() -> None:
def f(x: int) -> int: # 로컬 함수
return x * 2
with mp.Pool(2) as p:
print(p.map(f, [1, 2, 3]))
if __name__ == "__main__":
main()
해결 1: 워커 함수는 반드시 “모듈 최상단”에 정의
import multiprocessing as mp
def f(x: int) -> int:
return x * 2
def main() -> None:
with mp.Pool(2) as p:
print(p.map(f, [1, 2, 3]))
if __name__ == "__main__":
main()
해결 2: 인스턴스 메서드 대신 @staticmethod 또는 최상단 함수 사용
클래스 인스턴스가 복잡한 상태(락/커넥션)를 들고 있으면 pickle이 실패하거나, 되더라도 자식 프로세스에서 깨진 상태가 됩니다. 워커는 순수 함수에 가깝게 만들고, 상태는 프로세스 시작 시 초기화하세요.
initializer로 프로세스별 리소스 초기화
import multiprocessing as mp
from typing import Optional
_client: Optional[object] = None
def init_worker() -> None:
global _client
_client = object() # 예: DB 연결, HTTP 세션 등을 여기서 생성
def task(x: int) -> int:
# _client 사용
return x * x
if __name__ == "__main__":
with mp.Pool(processes=4, initializer=init_worker) as p:
print(p.map(task, range(10)))
해결 3: 전달 인자는 “pickle 가능한 값”만
- 기본형(int/str/float/bool)
- dict/list/tuple(내부도 pickle 가능해야)
- dataclass(필드가 pickle 가능해야)
파일 핸들, 커넥션 객체를 인자로 넘기지 말고, 워커 내부에서 열거나 initializer로 생성하세요.
4) 윈도우/맥에서 특히 중요한 if __name__ == "__main__" 가드
spawn 기반 플랫폼(윈도우, macOS의 일부 설정)에서는 자식 프로세스가 모듈을 재임포트합니다. 이때 가드가 없으면 프로세스가 무한히 생성되거나, 예상치 못한 초기화가 반복되며 멈춤/폭주가 발생합니다.
import multiprocessing as mp
def work(x: int) -> int:
return x + 1
if __name__ == "__main__":
with mp.Pool(2) as p:
print(p.map(work, [1, 2, 3]))
운영 환경이 리눅스라 하더라도, 로컬 개발/CI가 윈도우라면 이 가드는 사실상 필수입니다.
5) fork로 인한 “조용한 멈춤”: 스레드/락/로그 핸들 상속 문제
리눅스에서 기본인 fork는 빠르지만, 부모 프로세스의 상태를 그대로 복제합니다. 다음이 섞여 있으면 워커가 조용히 멈추는 원인이 됩니다.
- 이미 생성된 스레드가 있는 상태에서
fork - 로깅 핸들러가 락을 잡은 상태에서
fork - 이벤트 루프/네트워크 세션/DB 커넥션을 물고
fork
증상
- 워커가 시작하긴 하는데 처리량이 0에 가까움
join()에서 영원히 대기- CPU 사용률이 낮고, 프로세스는 살아 있음
대응
- 가능하면
spawn으로 전환해 재현/분리 - 프로세스 생성 전에 스레드/세션/커넥션을 만들지 않기
- 워커 시작 후 initializer에서 리소스 생성
import multiprocessing as mp
def init_worker() -> None:
# 여기서 로거/세션/커넥션 초기화
pass
def task(x: int) -> int:
return x * 2
if __name__ == "__main__":
mp.set_start_method("spawn", force=True)
with mp.Pool(4, initializer=init_worker) as p:
print(p.map(task, range(100)))
6) 디버깅 체크리스트: “어디서 멈췄는지”를 먼저 고정
멀티프로세싱 문제는 감으로 고치기 어렵습니다. 아래를 순서대로 확인하면 원인 범위를 빠르게 좁힐 수 있습니다.
6.1 워커가 살아 있는지, 어디서 막혔는지
ps,top, 작업 관리자에서 프로세스가 살아있는지- CPU가 0%에 가까우면 락/큐/IO 대기일 가능성
6.2 Queue/Pipe 사용 시
put()/get()에 타임아웃을 걸어 “영원한 대기”를 끊기
from queue import Empty
import multiprocessing as mp
def worker(q: mp.Queue) -> None:
while True:
try:
item = q.get(timeout=5)
except Empty:
# 상태 로깅 후 continue/break
continue
if item is None:
return
6.3 예외가 자식에서 삼켜지지 않게
Pool.map대신apply_async로 에러 콜백을 달아 원인을 노출
import multiprocessing as mp
def task(x: int) -> int:
if x == 3:
raise RuntimeError("boom")
return x
def on_error(e: BaseException) -> None:
print("worker error:", repr(e))
if __name__ == "__main__":
with mp.Pool(2) as p:
for i in range(5):
p.apply_async(task, (i,), error_callback=on_error)
p.close()
p.join()
7) 실전 권장 구조: 안정적인 프로세스 풀 + 초기화 + 순수 함수
아래 구조는 pickle/fork 문제를 줄이고, 종료도 예측 가능하게 만듭니다.
- 워커 함수는 모듈 최상단
- 프로세스별 리소스는
initializer에서 생성 - 입력/출력은 pickle 가능한 값
spawn을 기본으로 두고(특히 크로스플랫폼), 성능이 필요하면fork를 신중히
import multiprocessing as mp
from dataclasses import dataclass
@dataclass(frozen=True)
class Job:
id: int
payload: str
def init_worker() -> None:
# 프로세스별 초기화(세션/커넥션/로거 등)
pass
def run_job(job: Job) -> tuple[int, int]:
# 순수 함수에 가깝게: 입력을 받아 결과만 반환
return (job.id, len(job.payload))
def main() -> None:
jobs = [Job(i, "x" * (i % 10)) for i in range(1000)]
with mp.Pool(processes=4, initializer=init_worker) as p:
for job_id, size in p.imap_unordered(run_job, jobs, chunksize=50):
# 결과 처리
pass
if __name__ == "__main__":
mp.set_start_method("spawn", force=True)
main()
imap_unordered와 chunksize는 대량 작업에서 오버헤드를 줄이고, 특정 작업이 느려도 전체가 덜 막히게 해줍니다.
8) 마무리: 증상별 빠른 처방 요약
프로그램이 멈춘다
Queue백프레셔/센티널/join()순서 점검get(timeout=...)로 영원한 블로킹 제거fork환경에서 락/스레드/세션 상속 여부 확인
daemonic processes are not allowed to have children- 워커 내부에서 또 프로세스 만들지 않기(중첩 병렬 제거)
- 내부 병렬이 필요하면 스레드로
Can't pickle local object ...- 워커 함수는 모듈 최상단
lambda/클로저 금지- 커넥션/파일 핸들 등 비직렬화 객체를 인자로 넘기지 말고 initializer에서 생성
멀티프로세싱은 “잘 되는 코드 템플릿”을 정해두고 그 틀에서만 확장하면 안정적으로 운영할 수 있습니다. 특히 spawn 기준으로 설계하면 pickle 제약 때문에 초반에 불편하지만, 장기적으로는 재현 가능한 버그가 줄고 디버깅 난이도가 크게 내려갑니다.
추가로, 외부 호출이 섞인 워커에서 멈춤이 발생한다면 단순 병렬 문제가 아니라 타임아웃/재시도/서킷브레이커 부재일 수도 있습니다. 관련 사례는 OpenAI Responses API 408 타임아웃 재현과 해결 실전 가이드도 함께 참고하면 원인 분리에 도움이 됩니다.