dev.to

Python 3.14's concurrent.interpreters module turns a 3.6-second GIL bottleneck into 0.9 seconds

Python 3.14의 concurrent.interpreters, GIL 작업 시간을 3.6초에서 0.9초로 줄이다

Python 3.14의 concurrent.interpreters로 CPU 작업을 네 개의 서브인터프리터에 나누자 네 스레드보다 훨씬 빠르게 처리됐습니다. 다만 인터프리터 하나에는 동시 호출을 하나만 허용하고 시작 비용도 프로세스보다 높아, 데이터 전달 방식과 작업 수명에 맞춘 설계가 필요합니다.

에디터 노트

이 측정에서 서브인터프리터의 이점은 CPU 작업을 네 코어로 나눈 경우에 분명합니다. 다만 시작 비용은 fork보다 두 배가량 들고, 인터프리터 하나는 동시 호출을 거부하므로 워커를 재사용하는 실제 작업에서도 같은 결과가 날지는 확인되지 않았습니다. 댓글은 그 빈틈을 구체적으로 짚습니다. 결과를 버리는 벤치마크라 실제 데이터 반환 비용을 비교하지 못했고, 장수명 InterpreterPoolExecutor를 통한 검증이 필요하다는 지적입니다. 반면 Linux의 forkserver 기본값 변경은 기존 코드에 바로 영향을 주는 별도 사안이며, 서브인터프리터 성능 비교와 구분해 받아들일 만합니다.

AI 요약

Python 3.14에 추가된 concurrent.interpreters는 일반 Python 코드에서 서브인터프리터를 다루는 표준 라이브러리 모듈입니다. 각 인터프리터는 별도의 GIL을 사용하므로 CPU 집약 작업을 여러 코어에서 실행할 수 있습니다. 글쓴이는 소수 계산 작업과 워커 생성 비용을 스레드, 서브인터프리터, 프로세스에서 비교하고 동시 호출, 데이터 전달, 메모리 사용도 확인합니다.

CPU 작업 성능

400,000 미만의 소수를 세는 순수 Python 작업을 네 단위 실행했습니다. 네 개의 OS 스레드는 3.51~3.70초가 걸렸고, 네 서브인터프리터는 0.89~0.98초, 네 프로세스는 0.95~1.30초였습니다. 단일 작업은 0.77~0.80초였습니다. 네 스레드의 GIL 경합 때문에 네 배의 작업에 약 3.6초가 걸린 반면, 서브인터프리터는 각자 GIL을 사용해 작업을 병렬로 처리했습니다. 비교는 4코어를 쓸 수 있는 python:3.14-slim 컨테이너에서 각각 세 차례 진행했습니다.

인터프리터 하나는 한 번에 한 호출

같은 Interpreter 객체에 두 스레드가 동시에 호출하면 호출을 대기열에 넣거나 번갈아 실행하지 않습니다. 두 번째 호출은 InterpreterError: interpreter already running으로 즉시 실패합니다. 한 번 호출은 0.84초, 두 번 순차 호출은 1.63초였고, 동시 호출은 벽시계 기준 0.84초가 걸린 뒤 두 번째 호출에서 예외가 발생했습니다. 따라서 N개 작업을 동시에 실행하려면 N개의 인터프리터가 필요합니다. 하나의 인터프리터를 공유하는 ThreadPoolExecutor 패턴은 그대로 적용되지 않습니다.

생성 비용과 메모리

200,000개 원소의 리스트를 각 워커에 채워 네 워커를 준비하는 데 fork는 0.037~0.038초, forkserver는 0.057~0.065초, 서브인터프리터는 0.076~0.078초가 걸렸습니다. 짧은 작업이라면 서브인터프리터의 시작 비용이 더 크게 작용할 수 있습니다. 작업 시간이 수 밀리초를 넘으면 이 비용은 글의 3.6초 대 0.9초 비교에 비해 작아집니다. 인터프리터를 닫은 뒤에도 프로세스 RSS는 초기 수치로 돌아오지 않았습니다. 반복 생성 때 증가 폭은 줄었지만 메모리가 계속 상주했으며, 글쓴이는 이를 메모리 누수로 단정하지 않고 CPython 할당기가 해제된 메모리를 보유하는 상황일 가능성을 제시합니다.

데이터 전달과 프로세스 시작 방식

int, str, bytes, None, bool, 정수 튜플, float은 is_shareable()에서 공유 가능으로 나왔습니다. list와 dict, threading.Lock, 사용자 정의 객체는 직접 call() 인자로 전달할 때 거부됩니다. 반면 concurrent.interpreters의 Queue는 객체를 pickle로 직렬화하고 반대편에서 새 객체로 복원합니다. 값은 유지되지만 원본과 객체 정체성을 공유하지 않으며, 변경 내용도 서로 동기화되지 않습니다.

Python 3.14의 Linux 기본 multiprocessing 시작 방식은 fork에서 forkserver로 바뀌었습니다. forkserver는 자식 프로세스에서 모듈을 다시 불러오므로, 최상위 수준에서 Process를 시작하는 코드에는 if __name__ == "__main__": 보호가 필요합니다. 글쓴이는 보호가 없는 기존 코드가 시작 단계 오류를 냈다고 설명합니다. 서브인터프리터의 pickle 대체 경로에서 모듈 최상위 코드가 다시 실행된 듯한 결과도 관찰했지만, 원인은 완전히 검증하지 않았다고 밝혔습니다.

dev.to 반응

  • @paullovelace — 제가 실제로 가져갈 내용은 forkserver의 예상 밖 변화입니다. 저도 main guard가 없는 오래된 multiprocessing 코드가 있고, 3.14에서 Linux가 그 문제를 드러내는 일은 제목의 0.9초보다 훨씬 당장 닥친 문제입니다. 서브인터프리터 네 개가 0.076초, fork가 0.037초라는 시작 비용도 기억에 남았습니다. '프로세스보다 가볍다'는 말을 단서 없이 반복하기 어려워집니다.
  • @stefan_07d23f966225156b50 — 장수명 InterpreterPoolExecutor를 사용하고 결과를 모아 검증하는 후속 비교를 보고 싶습니다. 현재 벤치마크는 소수 개수를 버리므로 계산 부분만 측정하고 유용한 결과를 가져오는 비용은 포함하지 않습니다. 워커를 재사용하고 더 큰 결과를 반환하면 실제 작업에서 multiprocessing과 비교하기가 더 적절해집니다.

원문: dev.to / 번역·요약: Trawling