Python 3.14로 올렸더니 multiprocessing 워커가 갑자기 실패하기 시작한 이유
문제 발생
Python 3.13에서 잘 돌던 배치 작업을 3.14로 올렸더니, 워커 프로세스가 PicklingError를 내거나 부모에서 열어둔 DB 커넥션을 못 찾겠다며 죽기 시작했습니다. 코드는 그대로였고 라이브러리 버전도 고정돼 있었습니다.
from concurrent.futures import ProcessPoolExecutor
conn = create_connection() # 모듈 로드 시점에 한 번
def handle(row_id):
return conn.execute(...) # 워커에서 부모의 커넥션을 그대로 사용
with ProcessPoolExecutor() as pool:
list(pool.map(handle, ids))원인 분석
Python 3.14부터 macOS를 제외한 유닉스 플랫폼에서 multiprocessing과 concurrent.futures의 기본 start method가 fork에서 forkserver로 바뀌었습니다. 스레드가 있는 프로세스에서 fork()를 호출하는 것이 안전하지 않기 때문에 내려진 결정입니다.
두 방식의 차이가 위 코드의 성패를 가릅니다.
fork— 부모 프로세스를 통째로 복제합니다. 그래서 모듈 전역에 만들어둔 커넥션, 열린 파일, 이미 로드된 캐시가 자식에도 "그냥 있었습니다". 코드가 그 사실에 기대고 있어도 동작했습니다.forkserver— 깨끗한 서버 프로세스를 하나 띄워두고 거기서 자식을 만듭니다. 부모의 메모리를 복제하지 않으므로 워커에 필요한 것은 전부 pickle되어 전달돼야 합니다. 소켓이나 DB 커넥션처럼 pickle할 수 없는 객체는 이 지점에서 실패합니다.
즉 코드가 잘못되기 시작한 게 아니라, 원래 잘못돼 있던 의존이 기본값 변경으로 드러난 것입니다. fork는 스레드를 쓰는 라이브러리(HTTP 클라이언트, 로깅 핸들러, 일부 드라이버)와 만나면 자식이 데드락에 걸리는 오래된 위험이 있었고, 이번 변경은 그 위험을 기본값에서 걷어낸 것입니다.
해결 방안
- 워커가 쓰는 자원은 워커 안에서 만듭니다. 부모에서 만들어 물려주는 대신
initializer로 자식 프로세스마다 초기화합니다.
from concurrent.futures import ProcessPoolExecutor
conn = None
def init_worker():
global conn
conn = create_connection() # 자식 프로세스에서 각자 연결
def handle(row_id):
return conn.execute(...)
with ProcessPoolExecutor(initializer=init_worker) as pool:
list(pool.map(handle, ids))- 당장 배포를 막아야 한다면 start method를 명시해 되돌립니다. 다만 이건 시간을 버는 조치일 뿐,
fork의 스레드 안전성 문제는 그대로입니다.
import multiprocessing as mp
ctx = mp.get_context("fork") # 명시적으로 이전 동작 유지if __name__ == "__main__":가드를 확인합니다.fork에서는 없어도 동작하던 스크립트가forkserver/spawn에서는 모듈을 다시 import하므로, 가드가 없으면 무한히 프로세스를 만듭니다.- 전달하는 인자가 pickle 가능한지 먼저 확인합니다.
pickle.dumps(arg)를 한 번 돌려보는 것만으로 배포 전에 걸러집니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.