본문으로 건너뛰기
개발 머꼬
개발 노트Python
hohyeon.dev27

Python 3.14로 올렸더니 multiprocessing 워커가 갑자기 실패하기 시작한 이유

  • #Concurrency
  • #Engineering Note
  • #Python
  • #Upgrade

문제 발생

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를 제외한 유닉스 플랫폼에서 multiprocessingconcurrent.futures의 기본 start method가 fork에서 forkserver로 바뀌었습니다. 스레드가 있는 프로세스에서 fork()를 호출하는 것이 안전하지 않기 때문에 내려진 결정입니다.

두 방식의 차이가 위 코드의 성패를 가릅니다.

  • fork — 부모 프로세스를 통째로 복제합니다. 그래서 모듈 전역에 만들어둔 커넥션, 열린 파일, 이미 로드된 캐시가 자식에도 "그냥 있었습니다". 코드가 그 사실에 기대고 있어도 동작했습니다.
  • forkserver — 깨끗한 서버 프로세스를 하나 띄워두고 거기서 자식을 만듭니다. 부모의 메모리를 복제하지 않으므로 워커에 필요한 것은 전부 pickle되어 전달돼야 합니다. 소켓이나 DB 커넥션처럼 pickle할 수 없는 객체는 이 지점에서 실패합니다.

즉 코드가 잘못되기 시작한 게 아니라, 원래 잘못돼 있던 의존이 기본값 변경으로 드러난 것입니다. fork는 스레드를 쓰는 라이브러리(HTTP 클라이언트, 로깅 핸들러, 일부 드라이버)와 만나면 자식이 데드락에 걸리는 오래된 위험이 있었고, 이번 변경은 그 위험을 기본값에서 걷어낸 것입니다.

해결 방안

  1. 워커가 쓰는 자원은 워커 안에서 만듭니다. 부모에서 만들어 물려주는 대신 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))
  1. 당장 배포를 막아야 한다면 start method를 명시해 되돌립니다. 다만 이건 시간을 버는 조치일 뿐, fork의 스레드 안전성 문제는 그대로입니다.
import multiprocessing as mp
ctx = mp.get_context("fork")        # 명시적으로 이전 동작 유지
  1. if __name__ == "__main__": 가드를 확인합니다. fork에서는 없어도 동작하던 스크립트가 forkserver/spawn에서는 모듈을 다시 import하므로, 가드가 없으면 무한히 프로세스를 만듭니다.
  2. 전달하는 인자가 pickle 가능한지 먼저 확인합니다. pickle.dumps(arg)를 한 번 돌려보는 것만으로 배포 전에 걸러집니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.