Python프로그래밍Python 개발자

하나의 CPU 코어에서 단일 **Python** 프로세스의 여러 **스레드**가 순차적으로 실행되는 이유는 무엇인가요? 호스트 시스템에서 여러 코어를 사용할 수 있음에도 불구하고?

Hintsage AI 어시스턴트로 면접 통과

질문에 대한 답변

Python의 **전역 인터프리터 잠금 (GIL)**은 Python 객체에 대한 접근을 보호하는 뮤텍스(mutex)로, 특정 순간에 오직 하나의 스레드만 Python 바이트코드를 실행할 수 있도록 보장합니다. 이 디자인 결정은 메모리 관리를 단순화하고 객체 참조 수의 경쟁 조건을 방지하기 위해 CPython에서 이루어졌습니다. 따라서 멀티코어 프로세서에서도 스레드Python 코드를 병렬로 실행할 수 없으며, 대신 한 코어에서 빠르게 실행을 전환하여 CPU 바운드 멀티스레딩이 비효율적이 됩니다.

import threading import multiprocessing import time def cpu_intensive_task(n): """순수 Python CPU 바운드 작업""" count = 0 for i in range(n): count += i ** 2 return count # 스레드 제한 시연 start = time.time() threads = [threading.Thread(target=cpu_intensive_task, args=(5_000_000,)) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() print(f"스레딩 시간: {time.time() - start:.2f}s") # 출력은 GIL 경합으로 인해 약 4배의 단일 스레드 시간을 보여줍니다. start = time.time() processes = [multiprocessing.Process(target=cpu_intensive_task, args=(5_000_000,)) for _ in range(4)] for p in processes: p.start() for p in processes: p.join() print(f"멀티프로세싱 시간: {time.time() - start:.2f}s") # 출력은 약 1배의 단일 스레드 시간 (4배의 속도 향상)을 보여줍니다.

실생활 상황

문제: 우리의 분석 플랫폼은 복잡한 정규 표현식 추출 및 통계 계산을 통해 10GB의 로그 파일을 처리해야 했습니다. 엔지니어링 팀은 16코어 서버에서 16개의 스레드를 사용하여 concurrent.futures.ThreadPoolExecutor 기반의 스레딩 작업 풀을 구현했습니다. 놀랍게도 CPU 사용률은 6-7% (한 코어)로 유지되었고, 처리 시간이 3시간이 걸렸으며, 순차 처리에는 45분이 걸렸습니다. GIL은 순차 실행을 강제하며 스레드 전환 오버헤드를 추가하고 있었습니다.

해결책 1: C 확장을 통한 최적화된 스레딩 우리는 무거운 계산을 NumPy 작업으로 이동하고 실행 중에 GIL을 해제하는 C 가속 라이브러리를 사용하는 것을 평가했습니다.

장점: 최소한의 코드 변경이 필요; 공유 메모리는 직렬화 비용을 제거; 스레드가 주소 공간을 공유하므로 메모리 발자국이 줄어듭니다.

단점: NumPy가 지원하는 작업으로 제한됨; 사용자 정의 알고리즘은 여전히 Python 바이트코드 실행을 요구; C 확장 상호 작용에 대한 디버깅은 복잡성을 추가합니다.

해결책 2: multiprocessing을 통한 프로세스 기반 병렬 처리 우리는 multiprocessing.Pool 또는 concurrent.futures.ProcessPoolExecutor로 전환하여 별도의 Python 인터프리터를 생성하는 것을 고려했습니다.

장점: 모든 CPU 코어를 활용하는 진정한 병렬 처리; CPU 바운드 작업에 대한 선형 확장성; 격리는 GIL 경합을 완전히 방지합니다.

단점: 더 높은 메모리 사용량 (각 프로세스는 별도의 Python 인터프리터를 약 50-100MB 로 로드); 프로세스 간 통신을 위해 데이터는 피클링/언피클링되어야 합니다; 프로세스 시작 대기 시간 오버헤드.

선택한 해결책: 우리는 청크 데이터 처리를 통해 multiprocessing을 선택했습니다. 로그는 16개의 세그먼트로 분할되어 ProcessPoolExecutor로 처리되었고 결과가 병합되었습니다. 청크 전략은 IPC 빈도를 줄여 pickle 오버헤드를 최소화했습니다.

결과: 처리 시간은 3시간에서 4분으로 감소하였고 (45배 속도 향상). CPU 사용률은 모든 16개 코어에서 98%에 도달했습니다. 메모리 사용량은 프로세스 당 800MB 증가하여 (총 12.8GB) 128GB 서버에서는 수용 가능했습니다. 우리는 여러 배치 작업 간에 시작 비용을 분산하기 위해 프로세스 풀 싱글톤을 구현했습니다.

후보자가 종종 놓치는 사항


GIL이 I/O 바운드 스레딩 성능에 영향을 미치지 않습니까?

많은 후보자들이 Python에서 스레드가 완전히 쓸모가 없다고 잘못 믿고 있습니다. GIL은 I/O 작업(네트워크 요청, 디스크 읽기, 데이터베이스 쿼리) 중에 해제되며, 이를 명시적으로 해제하는 C 확장을 호출할 때도 마찬가지입니다(예: NumPy 행렬 작업). 스레드가 I/O를 위해 블록될 때 다른 스레드Python 코드를 실행할 수 있습니다. 따라서 스레딩은 웹 스크래핑이나 asyncio 기반 서버에서 수천 개의 동시 연결을 처리하는 것과 같은 동시 I/O 처리에 매우 효과적입니다.


PyPy 또는 Jython과 같은 대체 Python 구현이 GIL이 있습니까?**

후보자들은 종종 GIL을 제거하는 것이 단순히 다른 인터프리터를 사용하는 것이라고 가정합니다. PyPy(JIT-컴파일된 Python) 또한 스레드 안전성을 유지하기 위해 GIL을 구현하지만, 그 다른 객체 모델 때문에 스레드 전환이 더 효율적일 수 있습니다. 그러나 Jython(JVM에서 실행됨)과 IronPython(.NET CLR에서 실행됨)은 기본 가상 머신의 가비지 수집 및 스레드 프리미티브에 의존하기 때문에 GIL이 없으며, JVM 스레드에서 진정한 스레드 수준의 병렬 처리를 가능하게 합니다.


새 프로세스를 생성하지 않고 수동으로 GIL을 해제할 수 있습니까?

많은 개발자들은 C 확장에서 수동 GIL 관리에 대해 잘 알지 못합니다. Cython 또는 C 코드를 작성할 때, 긴 실행을 수행하는 동안 Py_BEGIN_ALLOW_THREADSPy_END_ALLOW_THREADS 매크로를 사용하여 명시적으로 GIL을 해제할 수 있습니다. 또한 Python 3.12+에서는 여러 프로세스 내에서 별도의 GIL을 가지는 서브 인터프리터를 허용하는 개별 인터프리터 GIL(PEP 684)이 도입되었지만, 이는 실험적인 interpreters 모듈을 필요로 하며 인터프리터 간에 객체를 직접 공유하지는 않습니다.