CPython GIL: 메커니즘, 한계, 그리고 시니어 개발자를 위한 해결책
CPython에서 GIL은 레퍼런스 카운팅이 경쟁 상태에 빠지는 것을 방지합니다. GIL이 없다면, 두 개의 스레드가 동시에 같은 객체의 ob_refcnt를 감소시킬 수 있습니다: 둘 다 값이 1이라고 보고, 둘 다 메모리를 해제하게 되어 세그멘테이션 폴트가 발생합니다.
핵심 용어:
- 세그멘테이션 폴트: 해제된 메모리에 접근하여 SIGSEGV 신호를 발생시킴.
- 옵코드: 원자적인 바이트코드 명령어 (dis 모듈로 확인 가능).
- 레퍼런스 카운팅: ob_refcnt가 0이 되면 객체가 파괴됨.
- 뮤텍스: 한 번에 하나의 스레드만 임계 구역에 진입 가능.
- 경쟁 상태: 결과가 스레드 실행 순서에 따라 달라짐.
- 바이트코드: .py 파일이 .pyc로 컴파일되어 VM에서 실행됨.
- 컨텍스트 스위칭: 약 마이크로초 단위의 오버헤드, CPU 캐시를 방해함.
GIL이 없을 때 발생하는 경쟁 상태 예시:
# 스레드1: refcount=1 -> free(obj)
# 스레드2: refcount=1 -> free(obj) -> 세그멘테이션 폴트
CPython에서 GIL의 목적
GIL은 전역 뮤텍스로, 한 번에 하나의 스레드만 바이트코드를 실행할 수 있게 합니다. 시스템 호출 중에는 스레드가 병렬로 작업할 수 있지만, Python 코드에서는 불가능합니다.
필요한 이유:
- ob_refcnt가 동시에 수정되는 것을 방지합니다.
- 스레드 안전성 걱정 없이 C 확장을 단순화합니다.
- 복잡한 동기화 없이 저렴한 스레드 안전성을 제공합니다.
GIL은 CPython에만 해당됩니다. Jython과 IronPython에는 없습니다.
GIL의 내부 메커니즘 (3.2+ 이후)
Python/ceval_gil.c의 새로운 구현: _gil_runtime_state 구조체.
핵심 구성 요소:
locked: 원자적 플래그 (0/1).mutex+cond_var: 대기를 위한 POSIX 기본 요소.eval_breaker: 각 옵코드 실행 후 확인됨.gil_drop_request: 대기 중인 스레드의 요청.
알고리즘:
- 스레드가 GIL을 보유하고 바이트코드를 실행합니다.
- 5ms(sys.getswitchinterval()) 후, 다른 스레드가 신호를 보냅니다.
- 현재 스레드가 eval_breaker를 통해 플래그를 확인하고 GIL을 해제합니다.
- 대기 중인 스레드가 GIL을 획득하고, 현재 스레드는 cond_var에서 대기합니다.
sys.setswitchinterval(): 기본값은 0.005초입니다. NumPy와 함께 CPU 집약적 작업을 할 때는 0.05–0.1초로 늘려 스레드 스래싱을 줄이세요. I/O 작업에는 조정하지 마세요.
GIL이 있음에도 Lock/RLock이 필요한 이유
GIL은 옵코드 단위로 원자적이지만, counter += 1은 LOAD_GLOBAL + BINARY_ADD + STORE_GLOBAL을 포함합니다.
counter += 1
# LOAD_GLOBAL 이후에 인터럽트될 수 있음
# 두 스레드: +2가 아닌 +1
해결책:
import threading
counter = 0
lock = threading.Lock()
def safe_increment():
global counter
with lock:
counter += 1
재진입성을 위한 RLock:
class SafeTree:
def __init__(self):
self._lock = threading.RLock()
def insert(self, value):
with self._lock:
self._rebalance() # 데드락 없음
def _rebalance(self):
with self._lock:
pass
규칙: GIL은 인터프리터를 보호하고, Lock은 비즈니스 로직을 보호합니다.
CPU 집약적 vs I/O 집약적 작업
I/O 집약적 (네트워크/디스크): 시스템 호출(read/write) 중에 GIL이 해제됩니다. threading/asyncio가 동시성을 제공합니다.
import threading, requests
def fetch(url):
r = requests.get(url) # GIL 해제됨
return r.status_code
CPU 집약적 (계산): GIL이 병렬 처리를 차단합니다. multiprocessing을 사용하세요.
from multiprocessing import Pool
def heavy_compute(n):
return sum(i * i for i in range(n))
with Pool(4) as p:
results = p.map(heavy_compute, [10**7]*4) # 4개의 GIL
C 확장에서 GIL 해제하기
NumPy/pandas는 무거운 작업 중에 GIL을 해제합니다:
Py_BEGIN_ALLOW_THREADS
matrix_multiply(a, b, result, n);
Py_END_ALLOW_THREADS
threading + NumPy = 진정한 병렬 처리. 순수 Python 스레드는 그렇지 않습니다.
asyncio와 GIL 독립성
단일 스레드 내 협력적 멀티태스킹. await에서 전환이 발생합니다.
import asyncio
import aiohttp
async def fetch_data(session, url):
async with session.get(url) as resp:
return await resp.json()
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch_data(session, url) for url in urls]
results = await asyncio.gather(*tasks)
I/O 작업을 위한 threading vs asyncio:
- threading: OS 스레드, 오버헤드, 경쟁 상태.
- asyncio: 1개의 스레드, 오버헤드 없음, 명시적 제어.
스레드/프로세스/비동기 안전성 수준
| 유형 | 문제점 | 공유 메모리 | 도구 | 사용 사례 |
|------|---------|---------------|-------|----------|
| 스레드 | 스레드 간 경쟁 | 예 | Lock, Queue, local() | ThreadPoolExecutor |
| 프로세스 | 프로세스 간 경쟁 | 명시적으로 | multiprocessing.Lock, Manager | Celery, Gunicorn |
| 비동기 | 코루틴 간 경쟁 | 단일 스레드 | asyncio.Lock, 가변성 피하기 | FastAPI |
비동기 경쟁 예시:
# 안전하지 않음
async def transfer(from_acc, to_acc, amount):
balance = await db.get_balance(from_acc)
if balance >= amount: # 여기서 yield 발생 가능
await db.deduct(from_acc, amount)
asyncio.Lock 또는 DB 트랜잭션을 사용하세요.
핵심 요약
- GIL은 레퍼런스 카운트를 보호할 뿐, 비즈니스 로직은 아닙니다—항상 Lock을 사용하세요.
- CPU 집약적 작업: threading이 아닌 multiprocessing을 사용하세요.
- C 확장(NumPy)은 스레드 내 병렬 처리를 가능하게 합니다.
- asyncio는 GIL을 무시합니다: 단일 스레드 내 협력적 멀티태스킹.
- sys.setswitchinterval()은 거의 필요하지 않으며, NumPy 집약적 작업에만 사용하세요.
— Editorial Team
아직 댓글이 없습니다.