CPython 인터프리터 락, 바이트코드 레벨에서 정확히 어떻게 동작하나

CPython GIL은 실제로 어떻게 동작하는가 — 바이트코드 레벨에서 본 스레드 전환

GIL(Global Interpreter Lock)을 설명하는 글은 많아요. 근데 대부분 “파이썬은 멀티스레딩이 안 된다”는 결론만 던지고 끝나더라고요. 저도 처음엔 그렇게 외웠어요. 면접에서 “GIL이 뭔가요?” 물어보면 “인터프리터 수준의 락이요”라고 답하는 수준.

근데 어느 순간 “그래서 정확히 어느 시점에 스위칭이 일어나는 거야?”라는 질문에 말문이 막혔어요. 그래서 CPython 소스(ceval.c)랑 dis 모듈 직접 뜯어보면서 정리했어요. 이 글은 “GIL이 뭔지 몰라요” 수준이 아니라, 동작 메커니즘을 정확하게 알고 싶은 분들을 위한 글이에요.


GIL은 뭘 보호하는가 — 참조 카운팅의 원자성 문제

GIL의 존재 이유는 단순해요. CPython은 메모리 관리를 참조 카운팅(reference counting) 으로 해요. 오브젝트마다 ob_refcnt 필드가 있고, 이걸 증감하면서 0이 되면 해제하는 방식이에요.

문제는 ob_refcnt 증감이 하드웨어 레벨에서 원자적 연산이 아니라는 거예요. INCREF는 load → increment → store 세 단계로 이루어지는데, 두 스레드가 동시에 같은 오브젝트의 refcnt를 건드리면 카운트가 잘못 계산돼요. 결과는 메모리 해제 안 됨(leak) 또는 이중 해제(double free)예요.

GIL은 이 문제를 “인터프리터 전체에 락 하나”라는 방식으로 해결해요. 다소 무식하지만 구현이 단순하고, CPython 초기부터 붙어 있던 구조라 뜯어내기가 어려웠던 거예요. 결국 GIL은 Python 오브젝트 레벨의 thread safety를 보장하는 글로벌 뮤텍스예요.

요약: GIL은 CPython의 참조 카운팅 구조 때문에 필요한 글로벌 뮤텍스이며, ob_refcnt 증감의 비원자성을 커버한다.


ceval.c와 sys.setswitchinterval — GIL은 어느 단위로 릴리즈되는가

여기서부터가 핵심이에요.

CPython의 인터프리터 루프는 Python/ceval.c_PyEval_EvalFrameDefault 함수예요. 이 함수가 바이트코드 명령어를 하나씩 fetch → decode → execute하는 메인 루프예요.

Python 3.2 이전에는 GIL 릴리즈 단위가 “100개의 바이트코드 명령어” 였어요. sys.getcheckinterval()로 확인할 수 있었고, 기본값이 100이었죠. 명령어 100개 실행 후 GIL을 잠깐 놓고, 다른 스레드가 획득할 기회를 주는 방식이에요.

근데 Python 3.2부터 이 방식이 바뀌었어요. 명령어 카운트 기반에서 시간 기반으로 전환했고, sys.setswitchinterval()이 그 인터페이스예요.

import sys
print(sys.getswitchinterval())  # 기본값: 0.005 (5ms)

기본값은 5밀리초예요. 인터프리터는 5ms마다 eval_breaker 플래그를 세트해요. 이 플래그는 ceval.c의 메인 루프 안에서 체크되는데, 플래그가 세트되어 있으면 현재 스레드가 GIL을 릴리즈하고 gil_drop_request를 통해 다른 스레드에게 넘겨줄 의향을 표명해요.

중요한 건 이게 강제 선점이 아니라는 점이에요. 플래그를 확인하는 시점은 여전히 바이트코드 명령어 경계예요. 즉, 하나의 바이트코드 opcode가 실행되는 도중에 스레드가 뺏기지는 않아요. 하지만 바이트코드와 바이트코드 사이, 즉 다음 명령어를 fetch하기 전에 eval_breaker를 체크하고 전환이 일어나요.

Python 3.12부터는 이 메커니즘이 _PyEval_EvalFrameDefault 내부의 DISPATCH 매크로와 연동되어 더 정밀하게 동작해요.

요약: Python 3.2+에서 GIL 전환은 시간 기반(기본 5ms)이며, eval_breaker 플래그를 통해 바이트코드 명령어 경계에서 발생한다.


dis로 바이트코드 까보기 — i += 1이 원자적이지 않은 이유

많은 분들이 “파이썬 정수 연산은 GIL이 보호해주니까 괜찮지 않나?”라고 생각해요. 그게 틀린 이유를 직접 보여드릴게요.

import dis

def increment():
    i = 0
    i += 1

dis.dis(increment)

출력 결과는 이렇게 나와요.

  2           0 LOAD_CONST               1 (0)
              2 STORE_FAST               0 (i)

  3           4 LOAD_FAST                0 (i)
              6 LOAD_CONST               2 (1)
              8 BINARY_OP               13 (+=)
             10 STORE_FAST               0 (i)
             12 LOAD_CONST               0 (None)
             14 RETURN_VALUE

i += 1 한 줄이 바이트코드 레벨에서 LOAD_FAST → LOAD_CONST → BINARY_OP → STORE_FAST 네 개의 opcode로 분해돼요.

GIL 전환은 바이트코드 명령어 경계에서 일어날 수 있으니, LOAD_FASTSTORE_FAST 사이에 컨텍스트 스위치가 일어날 수 있어요. 예를 들어 두 스레드가 동시에 전역 변수 counter를 증가시키는 상황을 생각해 보면:

  1. Thread A가 LOAD_FASTcounter = 100을 스택에 로드
  2. GIL이 Thread B로 전환
  3. Thread B가 counter를 100 → 101로 올리고 STORE_FAST 완료
  4. GIL이 Thread A로 복귀
  5. Thread A는 스택에 이미 100을 갖고 있으니 100 + 1 = 101STORE_FAST
  6. 최종 counter = 101 — Thread B의 업데이트가 사라짐

이걸 실제로 재현하는 코드도 있어요.

import threading

counter = 0

def increment_many():
    global counter
    for _ in range(100000):
        counter += 1

threads = [threading.Thread(target=increment_many) for _ in range(10)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)  # 1000000이 아닌 값이 나올 수 있음

실행할 때마다 다른 값이 나와요. GIL이 있음에도 불구하고요. i += 1이 하나의 원자적 연산처럼 보이지만, 바이트코드 레벨에서는 그렇지 않기 때문이에요.

요약: i += 1은 4개의 바이트코드 opcode로 분해되고, 그 경계에서 GIL 전환이 일어날 수 있어 race condition이 발생한다.


CPU-bound vs I/O-bound — GIL의 영향이 다른 이유

GIL이 항상 문제가 되는 건 아니에요. 워크로드 타입에 따라 영향이 달라요.

CPU-bound 작업 에서는 GIL이 명확한 병목이에요. 두 스레드가 동시에 계산을 수행하고 싶어도, GIL 때문에 실제로는 한 스레드만 실행돼요. 4코어 CPU에서 스레드 4개를 써도 단일 스레드랑 성능 차이가 없거나 오히려 느려요. 컨텍스트 스위칭과 GIL 경쟁 오버헤드만 추가되니까요.

# CPU-bound: 멀티스레딩 효과 없음
def compute():
    return sum(i * i for i in range(10**7))

이런 작업은 multiprocessing을 써야 해요. 프로세스는 각자 독립적인 GIL을 갖거든요.

I/O-bound 작업 에서는 얘기가 달라요. 파일 읽기, 네트워크 요청, DB 쿼리 같은 작업은 대부분의 시간을 시스템 콜 대기에 써요. 이때 GIL이 어떻게 되냐면 — 자발적으로 릴리즈돼요.

CPython의 I/O 관련 함수들은 시스템 콜 진입 전에 Py_BEGIN_ALLOW_THREADS 매크로를 호출하고, 반환 후에 Py_END_ALLOW_THREADS로 GIL을 다시 획득해요. 이 구간 동안 다른 Python 스레드가 GIL을 잡고 실행될 수 있어요.

// CPython 소스 패턴 (간략화)
Py_BEGIN_ALLOW_THREADS  // GIL 릴리즈
result = read(fd, buf, count);  // 시스템 콜
Py_END_ALLOW_THREADS   // GIL 재획득

C 확장 모듈도 같은 방식이에요. NumPy의 많은 연산, 암호화 라이브러리, 이미지 처리 라이브러리들이 내부적으로 Py_BEGIN_ALLOW_THREADS를 사용해서 GIL 없이 병렬 C 코드를 실행해요. 그래서 NumPy 연산이 멀티스레드 환경에서 효율적으로 동작하는 거예요.

핵심은 GIL을 놓는 주체가 인터프리터만이 아니라는 것이에요. C 레벨에서 명시적으로 Py_BEGIN_ALLOW_THREADS를 호출하면 그 블록 동안 GIL 없이 실행이 가능해요.

요약: CPU-bound는 GIL이 직접 병목, I/O-bound와 C 확장은 Py_BEGIN_ALLOW_THREADS로 GIL을 자발적으로 릴리즈해 실질적 병렬성이 가능하다.


PEP 703 — free-threading 빌드가 참조 카운팅을 어떻게 바꿨는가

Python 3.13부터 실험적으로 제공되는 free-threading 빌드 (GIL 없는 CPython)는 PEP 703이 기반이에요. 이게 단순히 “GIL 제거”가 아닌 이유는 참조 카운팅 문제가 그대로 남아 있기 때문이에요.

GIL을 제거하면 ob_refcnt 증감의 비원자성 문제가 다시 수면 위로 올라와요. free-threading 빌드는 이 문제를 두 가지 방식으로 접근했어요.

첫 번째, 참조 카운팅을 thread-local로 분산. 오브젝트마다 스레드별로 참조 카운트를 따로 관리하고, 합산해서 실제 refcnt를 계산하는 방식이에요. 이렇게 하면 ob_refcnt 자체의 경합이 줄어들어요.

두 번째, 바이모달 참조 카운팅(bimodal reference counting). 자주 공유되는 오브젝트(모듈, 클래스, 인터닝된 문자열 등)에 대해 “immortal object” 개념을 도입했어요. 이미 Python 3.12에서 PEP 683으로 들어온 개념인데, immortal object는 refcnt 자체를 변경하지 않아요. 따라서 락 없이도 안전해요.

문제는 단일 스레드 성능이에요. free-threading 빌드에서는 ob_refcnt 접근 자체가 더 비싸져요. 기존엔 단순 정수 증감이었던 게, 이제 원자적 연산(atomic increment)이나 thread-local 카운터 조회로 바뀌거든요. 원자적 연산은 CPU 캐시 라인 무효화(cache line invalidation)를 유발할 수 있어요.

벤치마크 결과를 보면 free-threading 빌드는 단일 스레드에서 일반 CPython 대비 약 5~10%의 성능 손해 가 있어요. PyPI 패키지 레벨에서의 벤치마크에서는 최대 30%까지 차이나는 경우도 있었어요. 이게 무시할 수 없는 트레이드오프예요.

PEP 703의 목표는 이 손해를 3% 이내로 줄이는 거였는데, 현재 3.13 기준으로는 아직 목표치에 완전히 도달하지 못했어요. 그래서 python3.13t (free-threading 빌드)는 여전히 실험적 빌드로 분류돼요.

추가로, free-threading 빌드에서는 전역 상태를 보호하는 인터프리터 수준의 뮤텍스들이 기존 GIL 역할을 부분적으로 대체하고 있어요. dict, list 같은 빌트인 컨테이너에 별도의 락이 붙어 있고, 이 락들의 오버헤드가 단일 스레드 성능에 영향을 줘요.

요약: PEP 703 free-threading 빌드는 참조 카운팅을 thread-local 분산과 atomic op으로 해결하지만, 단일 스레드 성능이 5~10% 저하되는 트레이드오프가 있다.


정리 — GIL 이해가 왜 실무에서 중요한가

GIL을 바이트코드 레벨까지 이해하면 실무에서 설계 결정을 더 명확하게 할 수 있어요.

  • CPU-bound 병렬화 가 필요하면 multiprocessing 또는 concurrent.futures.ProcessPoolExecutor
  • I/O-bound 병렬화threading도 유효하고, asyncio가 더 효율적인 경우도 많음
  • NumPy / pandas 연산 은 C 확장이 GIL을 릴리즈하기 때문에 멀티스레드 환경에서도 어느 정도 병렬성 확보 가능
  • 공유 상태를 수정하는 코드threading.Lock을 명시적으로 써야 함 — i += 1이 원자적이지 않다는 걸 이제 알았으니

솔직히 저도 이 내용을 제대로 정리하기 전까지는 “GIL이 있으니까 thread safe하다”는 막연한 믿음이 있었어요. 근데 ceval.c 구조랑 dis 모듈 뜯어보고 나서야 “아, GIL은 인터프리터를 보호하는 거지, 내 코드의 논리적 원자성을 보장해주는 게 아니구나”라는 게 명확하게 정리됐어요.

free-threading이 안정화되면 또 다른 고려사항들이 생기겠지만, 지금 시점에서는 GIL의 동작 메커니즘을 정확하게 이해하는 게 먼저예요. 이 글이 그 출발점이 됐으면 좋겠어요.

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤