홈으로 돌아가기

GIL CPython: 작동 방식과 고급 우회 방법

이 글은 CPython의 GIL을 분석합니다: refcount 보호, 스위칭 메커니즘, CPU/I/O-bound 차이점. Lock/RLock, multiprocessing, C extensions와 asyncio에서의 GIL 해제에 대해 논의합니다.

CPython의 GIL: 시니어 인터뷰를 위한 완전 분석
Advertisement 728x90

CPython GIL: 메커니즘, 한계, 그리고 시니어 개발자를 위한 해결책

CPython에서 GIL은 레퍼런스 카운팅이 경쟁 상태에 빠지는 것을 방지합니다. GIL이 없다면, 두 개의 스레드가 동시에 같은 객체의 ob_refcnt를 감소시킬 수 있습니다: 둘 다 값이 1이라고 보고, 둘 다 메모리를 해제하게 되어 세그멘테이션 폴트가 발생합니다.

핵심 용어:

  • 세그멘테이션 폴트: 해제된 메모리에 접근하여 SIGSEGV 신호를 발생시킴.
  • 옵코드: 원자적인 바이트코드 명령어 (dis 모듈로 확인 가능).
  • 레퍼런스 카운팅: ob_refcnt가 0이 되면 객체가 파괴됨.
  • 뮤텍스: 한 번에 하나의 스레드만 임계 구역에 진입 가능.
  • 경쟁 상태: 결과가 스레드 실행 순서에 따라 달라짐.
  • 바이트코드: .py 파일이 .pyc로 컴파일되어 VM에서 실행됨.
  • 컨텍스트 스위칭: 약 마이크로초 단위의 오버헤드, CPU 캐시를 방해함.

GIL이 없을 때 발생하는 경쟁 상태 예시:

Google AdInline article slot
# 스레드1: refcount=1 -> free(obj)
# 스레드2: refcount=1 -> free(obj) -> 세그멘테이션 폴트

CPython에서 GIL의 목적

GIL은 전역 뮤텍스로, 한 번에 하나의 스레드만 바이트코드를 실행할 수 있게 합니다. 시스템 호출 중에는 스레드가 병렬로 작업할 수 있지만, Python 코드에서는 불가능합니다.

필요한 이유:

  • ob_refcnt가 동시에 수정되는 것을 방지합니다.
  • 스레드 안전성 걱정 없이 C 확장을 단순화합니다.
  • 복잡한 동기화 없이 저렴한 스레드 안전성을 제공합니다.

GIL은 CPython에만 해당됩니다. Jython과 IronPython에는 없습니다.

Google AdInline article slot

GIL의 내부 메커니즘 (3.2+ 이후)

Python/ceval_gil.c의 새로운 구현: _gil_runtime_state 구조체.

핵심 구성 요소:

  • locked: 원자적 플래그 (0/1).
  • mutex + cond_var: 대기를 위한 POSIX 기본 요소.
  • eval_breaker: 각 옵코드 실행 후 확인됨.
  • gil_drop_request: 대기 중인 스레드의 요청.

알고리즘:

Google AdInline article slot
  • 스레드가 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

Advertisement 728x90

다음 읽기