- Published on
Python GIL & CPython 내부 완전 가이드 2025: Global Interpreter Lock, Bytecode, No-GIL, Subinterpreters, Async 심층 분석
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며: Python의 가장 큰 비밀
유명한 문제
"Python은 왜 멀티스레드에서 빠르지 않나요?"
답: GIL (Global Interpreter Lock) 때문입니다.
20년 넘게 Python 커뮤니티가 GIL로 싸웠다. 제거하려 했고, 우회하려 했고, 받아들이려 했다. 2024년 PEP 703이 승인되며 마침내 No-GIL Python이 현실이 되고 있다.
간단한 실험
import threading
import time
def cpu_bound():
count = 0
for _ in range(50_000_000):
count += 1
# Single thread
start = time.time()
cpu_bound()
print(f"1 thread: {time.time() - start:.2f}s")
# Multi thread
start = time.time()
threads = [threading.Thread(target=cpu_bound) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f"4 threads: {time.time() - start:.2f}s")
예상: 4 스레드는 4배 빨라야.
실제 (CPython 3.12):
1 thread: 3.2s
4 threads: 13.8s
4배 느려졌다. 왜?
답: GIL이 한 번에 하나의 스레드만 Python 바이트코드를 실행하도록 강제한다.
이 글에서 다룰 것
- CPython 아키텍처: Compiler, bytecode, VM.
- GIL의 정체: 왜 존재하는가.
- GIL의 구현: 내부 메커니즘.
- GIL의 영향: 어떤 작업이 느려지나.
- asyncio: GIL을 우회하는 법.
- Multiprocessing: 별도 프로세스.
- PEP 703 (No-GIL): 2024년의 혁명.
- Subinterpreters (PEP 684): 또 다른 접근.
- Alternative Pythons: PyPy, Jython, GraalPy.
1. CPython: 대부분 Python의 실제 모습
Python ≠ CPython
"Python" 은 언어 스펙. 여러 구현체가 있다:
- CPython: C로 작성. 표준, 참조 구현. 99%가 사용.
- PyPy: Python으로 작성 (RPython). JIT 컴파일러. 빠름.
- Jython: JVM 기반.
- IronPython: .NET 기반.
- GraalPy: GraalVM 기반.
- MicroPython: 임베디드용.
이 글의 "Python GIL"은 CPython의 GIL이다. 다른 구현체는 다를 수 있다.
CPython 실행 흐름
Python 코드를 실행하면:
source code (.py)
↓
Lexer (tokens)
↓
Parser (AST)
↓
Compiler (bytecode, .pyc)
↓
Virtual Machine (interpreter loop)
↓
출력
Bytecode
CPython은 bytecode를 실행한다. Python code를 중간 표현으로 컴파일:
def add(a, b):
return a + b
Bytecode:
0 LOAD_FAST a
2 LOAD_FAST b
4 BINARY_OP 0 (+)
8 RETURN_VALUE
dis 모듈로 확인:
import dis
def add(a, b):
return a + b
dis.dis(add)
Bytecode Cache
.pyc 파일로 저장됨 (__pycache__/ 디렉토리). 다음 실행 시 재컴파일 생략.
Stack-Based VM
CPython은 stack-based virtual machine. 연산은 stack에서 일어남:
LOAD_FAST a # a를 stack에 push
LOAD_FAST b # b를 stack에 push
BINARY_OP + # a, b pop, a+b push
RETURN_VALUE # stack top을 반환
간단하고 이식성 좋다. 단점: register-based VM보다 조금 느림 (JIT이 덜 유리).
Interpreter Loop
CPython의 심장: ceval.c의 _PyEval_EvalFrameDefault. 메인 루프:
for (;;) {
opcode = NEXTOP();
oparg = NEXTARG();
switch (opcode) {
case LOAD_FAST:
// push frame->f_locals[oparg] to stack
DISPATCH();
case BINARY_OP:
// pop two, apply op, push result
DISPATCH();
case RETURN_VALUE:
// return top of stack
...
}
}
각 bytecode가 C 함수로 구현됨. 루프가 이를 디스패치.
Python 3.11의 Specializing Adaptive Interpreter
Python 3.11부터 JIT-like 최적화:
- Hot loop 감지.
- Specialized bytecode 생성: 타입 기반 최적화.
- Quickening: 실행 중 bytecode 교체.
예시: BINARY_OP가 두 int를 더하는 경우, 특화된 BINARY_OP_ADD_INT 로 교체. 더 빠름.
이게 Python 3.11의 "25% 더 빠름"의 출처.
Python 3.13은 실험적 JIT 추가. 본격 JIT은 Python 3.14+ 예정.
2. GIL: 왜 존재하는가
GIL의 정의
GIL (Global Interpreter Lock): 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있게 하는 mutex.
Thread 1: GIL 획득 → Python code 실행 → GIL 해제
Thread 2: GIL 획득 대기 → ... → GIL 획득 → 실행 → 해제
모든 Python thread는 GIL을 공유. 한 번에 하나만.
왜 GIL이 필요한가
이유 1: Reference Counting의 안전성
CPython의 메모리 관리는 reference counting:
x = [1, 2, 3] # refcount = 1
y = x # refcount = 2
del y # refcount = 1
del x # refcount = 0 → 메모리 해제
각 객체에 ob_refcnt 필드. 참조할 때 증가, 해제할 때 감소. 0이 되면 객체 삭제.
문제: Multi-thread에서 ob_refcnt 업데이트가 race condition. 두 스레드가 동시에 increment → 잘못된 값.
해결 옵션:
A. 모든 ob_refcnt를 atomic:
- 매 참조마다 atomic 연산.
- 수백 배 느려질 수 있음 (single-thread조차).
B. 각 객체마다 lock:
- 엄청난 lock 오버헤드.
- Deadlock 위험.
C. 하나의 전역 lock (GIL):
- Single-thread는 영향 없음.
- Multi-thread는 직렬화.
- 구현 단순.
Python은 C를 선택. 이것이 GIL.
이유 2: C extension의 안전성
많은 C extension이 thread-safe하지 않다. NumPy, scikit-learn 등 초기 버전은 특히. GIL이 있으면:
- C extension 개발자는 thread safety 걱정 안 해도.
- Python 런타임이 보호.
이유 3: 단순성
Python의 설계 철학: "simple is better than complex".
Fine-grained locking은 복잡. GIL은 단순. Guido가 이를 선택한 이유 중 하나.
GIL의 역사
1992년: Guido가 Python 0.9.8에 GIL 도입. "임시 조치". 다시 안 뺐음.
2000년대 초: "GIL 없애자" 시도 여러 번. 모두 실패. 이유: Single-thread 성능 감소.
2010: Unladen Swallow (Google). JIT + no-GIL 시도. 실패.
2015: Larry Hastings의 "Gilectomy". 수년 작업. Single-thread가 2배 느려져 폐기.
2023: Sam Gross의 PEP 703. 새로운 접근. 승인됨. Python 3.13에 실험적 포함. Python 3.14에 기본 가능성.
3. GIL의 구현
3.12 이전: Check Intervals
Python 3.2 이전: N개의 bytecode마다 GIL 해제 시도.
Python 3.2+: 시간 기반. 기본 5 ms (sys.setswitchinterval(0.005)).
로직:
while True:
execute_bytecode()
if time_since_last_release > 5ms:
release_GIL()
yield_to_other_thread()
acquire_GIL()
Voluntary vs Forced Release
Voluntary release:
- I/O 작업 시:
open(),socket.recv()등. C 함수 호출 전에 GIL 해제. time.sleep(): GIL 해제.- 긴 C 연산: NumPy가 이를 적극 활용.
Forced release:
- 5 ms 타이머 경과.
- CPython이 강제로 GIL 해제.
GIL Switching 비용
Context switch 비용:
- Thread state 저장/복원.
- GIL 획득 대기.
- Cache pollution.
~수 μs 오버헤드. 많이 switch하면 누적.
스케줄링의 함정
Python 3.2 이전의 문제:
CPU-bound 스레드 + I/O-bound 스레드:
- I/O 스레드가 wake up.
- CPU 스레드가 GIL 보유.
- I/O 스레드가 100 bytecode 기다림 (~1 ms).
- 응답성 저하.
Python 3.2 해결: 시간 기반 + GIL 요청 알림. I/O 스레드가 GIL을 요청하면 즉시 CPU 스레드가 양보.
완벽하진 않지만 크게 개선.
4. GIL의 영향
CPU-bound Multi-thread
가장 큰 피해자:
import threading
def heavy_computation():
total = 0
for i in range(10_000_000):
total += i * i
return total
threads = [threading.Thread(target=heavy_computation) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
4개의 스레드, 1개의 CPU만 사용. 직렬화된다. 멀티코어 이점 없음.
해결: multiprocessing 또는 C extension.
I/O-bound Multi-thread
거의 영향 없음:
import threading
import requests
def fetch(url):
return requests.get(url)
threads = [threading.Thread(target=fetch, args=(url,)) for url in urls]
for t in threads: t.start()
for t in threads: t.join()
I/O 대기 중엔 GIL 해제. 여러 HTTP 요청이 병렬로 진행.
이점:
- I/O 대기 시간 활용.
- N개 요청 ≈ 가장 느린 하나의 시간.
C Extension
GIL 외부에서 작업 가능:
NumPy:
import numpy as np
# 이 연산 중엔 GIL 해제됨
result = np.dot(large_matrix1, large_matrix2)
NumPy C 코드가 Py_BEGIN_ALLOW_THREADS 매크로로 GIL 해제. BLAS가 실행되는 동안 다른 Python 스레드도 작동.
이것이 NumPy가 Python에서 고성능 수치 계산을 가능하게 하는 이유.
scikit-learn, Pandas도 마찬가지.
Locking
GIL이 있어도 Python 객체는 thread-safe 아님:
counter = 0
def increment():
global counter
for _ in range(1_000_000):
counter += 1 # NOT atomic!
threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # < 4_000_000 (race condition)
왜?: counter += 1은:
LOAD_GLOBAL counterLOAD_CONST 1BINARY_OP +STORE_GLOBAL counter
4개 bytecode. GIL switching이 중간에 일어나면 lost update.
해결: threading.Lock() 사용.
결론: GIL은 interpreter를 보호하지만 사용자 코드를 보호하지 않는다. 여전히 락이 필요.
5. asyncio: GIL을 우회하는 우아한 방법
Event Loop Pattern
asyncio: Python의 동시성 라이브러리. 단일 스레드에서 여러 I/O 작업 동시 처리.
import asyncio
async def fetch(url):
# ... I/O 작업 ...
await asyncio.sleep(1)
return url
async def main():
results = await asyncio.gather(
fetch("url1"),
fetch("url2"),
fetch("url3"),
)
asyncio.run(main())
핵심: 하나의 스레드에서 여러 coroutine이 협력적으로 실행.
Coroutines
Coroutine: 일시 중단과 재개가 가능한 함수.
async def my_coro():
print("start")
await asyncio.sleep(1) # 여기서 일시 중단
print("end")
await: "여기서 멈추고 다른 일 해도 됨".
Event Loop의 작동
[ready queue]
→ task1
→ task2
→ task3
Event loop:
while True:
task = ready_queue.pop()
resume task
task runs until next `await`
if task awaits I/O:
register I/O with selector
continue
else:
task done
if ready queue empty:
wait for I/O events (selector.select())
when I/O ready, add corresponding tasks to ready queue
GIL과 asyncio
단일 스레드라서 GIL 문제 없음. 모든 coroutine이 같은 스레드에서 순차 실행. 하지만 I/O 대기는 효율적.
장점:
- 단일 스레드: GIL 무관.
- 저 비용: 스레드보다 coroutine이 훨씬 가벼움.
- 수천 concurrent I/O.
단점:
- CPU-bound 불가: 여전히 단일 스레드.
- async 전염: 한 번 async면 전파.
- 복잡도: Sync보다 어려움.
실전 예시
100,000 HTTP 요청:
Sync (threads):
- 수백 스레드.
- GIL 영향 (약간).
- 수 분.
Async (asyncio):
- 1 스레드.
- 수만 coroutine.
- 수 초~수십 초.
수십 배 빠름. I/O-bound 작업의 표준.
라이브러리
aiohttp: HTTP client/server (async).
asyncpg: PostgreSQL async.
aioboto3: AWS async.
FastAPI: Web framework.
Uvicorn: ASGI server.
현재 Python async 생태계는 매우 성숙하다.
6. Multiprocessing: 별도 프로세스
기본 아이디어
Multiprocessing: Python을 여러 프로세스로 실행. 각 프로세스가 자기 GIL.
from multiprocessing import Process
def heavy(x):
return sum(i*i for i in range(x))
processes = [Process(target=heavy, args=(10_000_000,)) for _ in range(4)]
for p in processes: p.start()
for p in processes: p.join()
4 프로세스 → 4 CPU 완전 활용. 스레드와 달리 GIL 영향 없음.
프로세스 vs 스레드
| 항목 | Thread | Process |
|---|---|---|
| GIL | 영향 있음 | 영향 없음 |
| Memory | 공유 | 격리 |
| Overhead | 작음 | 큼 |
| Communication | Shared memory | IPC, pipes, queues |
| 장애 | 공유 (프로세스 크래시) | 격리 |
IPC (Inter-Process Communication)
프로세스 간 데이터 교환:
Queue:
from multiprocessing import Process, Queue
q = Queue()
def worker(q):
q.put("hello")
p = Process(target=worker, args=(q,))
p.start()
print(q.get()) # "hello"
p.join()
Pipe: 1:1. Manager: 동기화된 데이터 구조. SharedMemory (3.8+): Numpy 배열 등 대용량 공유.
Multiprocessing Pool
from multiprocessing import Pool
def process_item(item):
return item * 2
with Pool(4) as pool:
results = pool.map(process_item, range(1000))
4개 프로세스가 작업 분배.
함정
1. Pickle 문제:
Process 간 데이터는 pickle로 직렬화. Lambda, nested function 등은 pickle 불가.
p = Pool(4)
p.map(lambda x: x*2, data) # ERROR: can't pickle lambda
해결: 모듈 레벨 함수 사용.
2. 메모리:
각 프로세스가 자기 메모리. 4 프로세스 = 4배 메모리.
대용량 데이터는 SharedMemory 사용.
3. 시작 오버헤드:
Fork (Linux): 빠름. Spawn (Windows, macOS 기본): 느림. 새 Python 시작.
from multiprocessing import set_start_method
set_start_method('fork')
4. 디버깅:
멀티 프로세스 디버깅 어려움. 로그가 섞임.
7. PEP 703: No-GIL Python
역사적 전환점
2022년: Facebook의 Sam Gross가 "nogil" 프로토타입 공개. 성공적.
2023년 7월: PEP 703 승인. Python steering council이 동의.
2024년 Python 3.13: 실험적 빌드 (--disable-gil).
Python 3.14+: Default 전환 계획.
접근 방식
Sam Gross의 nogil은 다른 전략:
1. Immortal Objects:
True,False,None, 작은 정수 등은 영원히 존재.- Refcount 변경 불필요.
2. Deferred Reference Counting:
- 지역 변수의 참조는 deferred.
- 함수 종료 시 한 번에 처리.
3. Biased Reference Counting:
- 각 객체에 소유자 스레드.
- 같은 스레드면 atomic 아님 (빠름).
- 다른 스레드면 atomic.
4. Per-Object Locks:
- 객체마다 자체 lock.
- Critical section만 lock.
5. Fine-grained Locking:
- 기존 GIL 대신 세밀한 lock.
성능 영향
Single-thread:
- 기존 대비 ~5% 느림.
- 이전 nogil 시도 (Gilectomy)의 2배 느림보다 훨씬 낫다.
Multi-thread:
- 진짜 병렬.
- CPU 수에 비례한 확장.
벤치마크 예시:
# CPU-bound, 4 threads
# GIL: 4 threads → 1x
# No-GIL: 4 threads → 3.8x
API 영향
대부분 호환:
- 기존 Python 코드 그대로 작동.
threading모듈 동일.
C extension의 영향:
- Thread safety 확인 필요.
- 기존 extension은 재검증 해야.
- NumPy, Pandas 등이 작업 중.
전환 일정
2024 (Python 3.13):
- 실험적 빌드.
PYTHONGIL=0환경 변수.- Opt-in.
2025 (Python 3.14):
- 성숙.
- 일부 배포판에서 기본.
2027+ (Python 3.16+):
- 전면 기본.
- GIL 버전 유지 (호환성).
8. Subinterpreters (PEP 684)
또 다른 접근
Subinterpreters: Python 내부에 여러 interpreter.
각 subinterpreter는:
- 자기 메모리 영역.
- 자기 GIL (Python 3.12+).
- 완전 격리.
기존 기능: Python 3.1부터 있었음. C API만.
PEP 684: 각 subinterpreter가 독립 GIL.
PEP 734: Python API (3.12+).
사용
# Python 3.13+
from concurrent import interpreters
def run_in_sub():
return 42
interp = interpreters.create()
result = interp.run(run_in_sub)
각 subinterpreter가 독립적으로 실행. 진정한 병렬.
Multiprocessing과의 차이
Multiprocessing:
- 별도 OS 프로세스.
- IPC로 통신.
- 느린 시작.
- 높은 메모리.
Subinterpreters:
- 같은 OS 프로세스.
- 공유 C 런타임.
- 빠른 시작.
- 낮은 메모리.
- Python 객체 공유 불가 (격리).
Subinterpreters는 multiprocessing과 threading의 중간.
데이터 교환
Shared memory:
interpreters.get_default().channels # 통신 채널
제약: Python 객체 직접 공유 불가. pickle 필요 (multiprocessing과 유사).
언제 유용한가
- 웹 서버: 각 요청이 subinterpreter.
- 플러그인: 격리된 실행.
- No-GIL 대안: 기존 extension과의 호환성.
아직 실험적. 생태계가 적응 중.
9. 실전 성능 가이드
언제 무엇을 쓸 것인가
CPU-bound + 단일 코어 충분:
- Sync Python. 단순.
CPU-bound + 멀티코어 필요:
- Multiprocessing (지금).
- No-GIL (미래).
- C extension (NumPy 등).
I/O-bound + 작은 규모:
- Threading + sync 라이브러리. 간단.
I/O-bound + 대규모:
- asyncio. 수천~수만 동시.
Mixed (I/O + CPU):
- asyncio + multiprocessing.
- CPU 작업은
ProcessPoolExecutor로.
성능 비교
같은 작업, 다른 접근:
100만 숫자 제곱 합:
# Sync
def sync_sum():
return sum(i*i for i in range(1_000_000))
# ~100 ms
# Threads (GIL 영향)
# 4 threads: ~100 ms (not faster)
# Multiprocessing
# 4 processes: ~30 ms (~3.3x speedup)
# NumPy
import numpy as np
def numpy_sum():
arr = np.arange(1_000_000)
return (arr * arr).sum()
# ~5 ms (20x)
# Numba (JIT)
from numba import jit
@jit
def numba_sum():
total = 0
for i in range(1_000_000):
total += i * i
return total
# ~2 ms (50x)
교훈: Python의 "느림"은 사용 방식에 달려 있음.
프로파일링
cProfile: 내장 프로파일러.
python -m cProfile -o profile.stats script.py
py-spy: Rust로 작성. 프로덕션 안전.
py-spy record -o profile.svg -- python script.py
scalene: CPU, memory, GPU 프로파일.
line_profiler: 줄 단위.
10. 대안 Python 구현체
PyPy
PyPy: CPython의 대안. JIT 컴파일러.
장점:
- CPU 집약적 코드에서 10-100배 빠름.
- API 호환 (대부분).
단점:
- C extension 호환성 제한.
- 메모리 사용 약간 많음.
- 시작 시 warm-up.
언제 사용: 순수 Python 계산.
Cython
Cython: Python + C 타입.
# cython
def sum_cython(int n):
cdef int total = 0
cdef int i
for i in range(n):
total += i * i
return total
C로 컴파일. C 속도. NumPy, Pandas 등이 내부적으로 사용.
Numba
Numba: JIT 데코레이터. 간단.
from numba import jit
@jit(nopython=True)
def fast_function(x):
# ...
NumPy 친화적. 과학 계산에 인기.
Mojo (미래)
Mojo: Modular (Chris Lattner)가 개발 중인 새 언어.
- Python과 호환.
- 수천 배 빠름 (주장).
- ML/AI 타겟.
아직 preview. 주목할 만.
퀴즈로 복습하기
Q1. 왜 NumPy는 Python의 GIL에서 자유로운가?
A.
답: NumPy의 계산 집약적 연산은 C 코드로 작성되어 있고, 그 C 코드 내에서 GIL을 해제하기 때문이다.
상세 설명:
CPython의 GIL 규칙:
- Python bytecode 실행 중엔 GIL 필요.
- C 함수 실행 중엔 C 코드가 결정:
- GIL 유지: 짧은 연산, Python 객체 직접 조작.
- GIL 해제: 긴 연산, Python 객체 건드리지 않음.
NumPy의 전략:
numpy.dot(A, B) 같은 매트릭스 연산은:
- Python에서
numpy.dot호출. - NumPy의 C 함수 진입.
Py_BEGIN_ALLOW_THREADS매크로로 GIL 해제.- 순수 C/BLAS 코드로 계산 수행.
Py_END_ALLOW_THREADS로 GIL 재획득.- Python 결과 객체로 반환.
핵심: 3-5 단계 동안 다른 Python 스레드가 자유롭게 실행.
예시:
import numpy as np
import threading
def heavy_numpy():
A = np.random.rand(1000, 1000)
B = np.random.rand(1000, 1000)
C = np.dot(A, B) # GIL 해제됨
threads = [threading.Thread(target=heavy_numpy) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
실제 측정:
- 1 thread: 1.0 초.
- 4 threads: 0.3 초 (거의 3배 빠름).
일반 Python 코드는 같은 조건에서 4배 느려지는 것과 대조적.
왜 이것이 가능한가:
NumPy 연산의 특징:
- C 배열 직접 조작 (Python 객체 아님).
- BLAS (Basic Linear Algebra Subprograms) 호출.
- MKL, OpenBLAS 등이 실제 계산.
- 멀티 스레드 안전 (BLAS 자체가 그렇게 설계).
GIL 해제 조건이 완벽히 충족:
- Python 객체 안 건드림 (C 배열만).
- 긴 연산 (수 ms+).
- Re-entrant.
어떤 NumPy 연산이 GIL 해제:
GIL 해제:
np.dot,np.matmul: 행렬 곱.np.add,np.multiply: 큰 배열 연산.np.linalg.solve,np.linalg.eig: 선형대수.np.fft: FFT.np.sort: 정렬 (큰 배열).
GIL 유지 (짧음):
- 스칼라 연산.
- 작은 배열 (수백 개 이하).
- 인덱싱
arr[i]. - 타입 변환.
판단 기준: 대략 50 μs 이상 걸리는 연산은 GIL 해제.
BLAS와 병렬성:
BLAS 자체가 멀티스레드. 예를 들어 MKL은 OMP (OpenMP)로 여러 CPU 코어 사용.
import os
os.environ['OMP_NUM_THREADS'] = '4'
이중 병렬성:
- Python 스레드 레벨: 4개.
- BLAS 스레드 레벨: 각 Python 스레드 × 4 BLAS 스레드 = 16.
주의: Over-subscription 위험. CPU 16 core인데 16 × 16 = 256 스레드.
관리 방법:
from threadpoolctl import threadpool_limits
with threadpool_limits(limits=1, user_api='blas'):
# BLAS는 1 스레드만 사용
np.dot(A, B)
다른 예:
scikit-learn: 대부분의 알고리즘이 NumPy/Cython 기반. GIL 해제.
Pandas: DataFrame 연산도 마찬가지.
SciPy: NumPy 기반.
PIL/Pillow: 이미지 처리. 일부 GIL 해제.
OpenCV Python: 대부분 GIL 해제.
TensorFlow, PyTorch: 자체 C++ 런타임. GIL 해제.
중요한 구분:
GIL-friendly Python 코드:
# Pure Python: GIL 영향
for i in range(1_000_000):
total += i * i
GIL-free NumPy 코드:
# NumPy: GIL 해제
arr = np.arange(1_000_000)
total = (arr * arr).sum()
두 번째가 GIL 관점에서도, 순수 성능에서도 훨씬 빠르다.
"Python이 느리다"의 진짜 의미:
느림:
- 순수 Python 루프.
- 객체 생성 많음.
- 동적 타입 검사.
빠름 (C 속도):
- NumPy 벡터 연산.
bytes,str연산.- C extension 일반.
함정: NumPy를 쓰되 안 쓰는 방식으로 쓰면 느림:
# 느림: 루프
result = np.zeros(1_000_000)
for i in range(1_000_000):
result[i] = arr[i] * 2
# 빠름: 벡터화
result = arr * 2
NumPy는 벡터화가 핵심. 루프로 쓰면 Python의 느림 + NumPy 오버헤드.
고급 기술: Py_BEGIN_ALLOW_THREADS:
Custom C extension을 작성할 때:
static PyObject *
my_long_func(PyObject *self, PyObject *args) {
// Python 인자 파싱
long n;
if (!PyArg_ParseTuple(args, "l", &n)) return NULL;
long result = 0;
Py_BEGIN_ALLOW_THREADS // GIL 해제
// 긴 C 계산
for (long i = 0; i < n; i++) {
result += heavy_computation(i);
}
Py_END_ALLOW_THREADS // GIL 재획득
return PyLong_FromLong(result);
}
중요:
- GIL 해제 중엔 Python 객체 접근 금지.
- 로컬 변수, C 함수 호출만.
- GIL 없이
PyLong_FromLong등 호출하면 크래시.
성능 지침:
CPU-bound 작업이 많다면:
- 순수 Python: 느림. GIL 문제.
- NumPy: 빠름. GIL 자유.
- Cython: 더 빠름. GIL 제어 가능.
- Numba: JIT. GIL 옵션.
- C extension: 완전 제어.
- Rust + PyO3: 최신 트렌드.
- Multiprocessing: 별도 프로세스. GIL 없음.
NumPy가 첫 번째 선택인 이유:
- 가장 단순.
- 거의 모든 곳에서 빠름.
- GIL도 해결.
교훈:
"Python의 GIL이 싫으면 Python을 덜 써라".
모순적이지만 사실이다. CPU 집약적 작업은:
- NumPy 벡터화.
- C 확장.
- 또는 Python 밖 (Rust, Go).
"Python은 glue language". 고수준 로직은 Python, 성능 중요한 부분은 C/C++. 이것이 Python 생태계의 철학이며, GIL이 큰 문제 안 되는 이유다.
NumPy, Pandas, TensorFlow, PyTorch 등 주요 과학/AI 라이브러리는 모두 이 패턴을 따른다. 그래서 Python이 GIL 있어도 데이터 과학의 표준이 되었다.
당신의 Python 코드가 느리다면, 먼저 물어보라: "이걸 NumPy로 쓸 수 있나?". 대부분 답은 "예"이고, 10-100배 빨라진다. GIL은 부차적 문제가 된다.
당신의 I/O 코드가 느리다면, asyncio로. 이것도 GIL 영향 없음.
진짜 순수 Python CPU 병렬성이 필요하다면 (드물다), multiprocessing 또는 No-GIL Python (미래). 그때까지는 대부분의 경우 NumPy + asyncio 조합이 답이다.
마치며: Python의 미래
핵심 정리
- CPython: Bytecode interpreter. Stack-based VM.
- GIL: 한 번에 한 스레드. Reference counting 보호.
- asyncio: GIL 우회. I/O-bound 강함.
- Multiprocessing: 별도 프로세스. CPU-bound 해결.
- No-GIL (PEP 703): 진짜 멀티스레드. Python 3.14+.
- Subinterpreters: 격리된 interpreter 여러 개.
- NumPy 등: C extension이 GIL 해제.
Python이 살아남은 이유
Python은 1991년에 시작. 2025년 현재 가장 인기 있는 프로그래밍 언어 중 하나. 왜?
철학:
- 간단함.
- 가독성.
- "There should be one obvious way".
생태계:
- 수만 개의 라이브러리.
- 모든 도메인 커버.
- 활발한 커뮤니티.
데이터 과학 + AI의 표준:
- NumPy, Pandas, scikit-learn.
- TensorFlow, PyTorch, JAX.
- Jupyter notebooks.
GIL에도 불구하고 성공한 것은, GIL이 실제로 큰 문제가 되지 않았기 때문이다:
- I/O-bound: asyncio, threading 충분.
- CPU-bound: NumPy, multiprocessing.
- 대부분의 앱: 단일 스레드 충분.
마지막 교훈
Python은 완벽한 언어가 아니다. 느리고 복잡하고 GIL이 있다. 하지만 가장 접근 가능한 언어 중 하나다. 초보자도 수 분 만에 의미 있는 코드를 쓴다. 전문가는 C extension으로 극한 성능을 낸다.
이 유연성이 Python의 진짜 강점이다. 모든 수준의 개발자가 쓸 수 있고, 모든 종류의 문제를 풀 수 있다.
GIL은 오랜 논란의 대상이었지만, Python의 성공을 막지 못했다. No-GIL이 와도, asyncio와 NumPy가 여전히 주류일 것이다. 도구는 문제에 맞게 쓰는 것이 중요하다.
당신이 Python을 쓸 때, 이 글의 지식이 다음 질문에 답할 수 있게 도와줄 것이다:
- 왜 내 멀티스레드 코드가 느린가?
- asyncio를 써야 할까?
- Multiprocessing이 필요한가?
- NumPy가 왜 빠른가?
- No-GIL Python은 언제 써야 하나?
이 답들을 알면 Python을 훨씬 효과적으로 사용할 수 있다. 언어의 한계를 알면, 그 안에서 최대로 활용할 수 있다.
참고 자료
- CPython GitHub
- CPython Internals (Anthony Shaw)
- Python's Innards Blog (Yaniv Aknin)
- PEP 703: Making the GIL Optional
- PEP 684: A Per-Interpreter GIL
- PEP 734: Multiple Interpreters in the Stdlib
- Python docs: threading module
- Python docs: asyncio
- Sam Gross's nogil fork
- Real Python: Speed Up Python
- David Beazley: Understanding the Python GIL (PyCon talk)