Zettelkasten

GIL은 race condition을 가릴 수 있어 안 보인다가 안전하다를 의미하지 않는다

·수정 2026.05.01·수정 1

요약

  • CPython의 GIL은 한 번 잡으면 5ms 또는 명시적 양보 전까지 안 놓는다.
  • 빠른 작업은 한 스레드가 작업을 다 끝낼 때까지 다른 스레드가 못 끼어들어 race가 가려진다.
  • "여러 번 돌려도 매번 정답이 나온다" ≠ "thread-safe하다". Lock은 race가 발생할 수 있는 상태 자체를 막기 위함이다.

본문

counter += 1은 atomic하지 않다

counter += 1은 단일 명령처럼 보이지만 실제로는 여러 바이트코드로 컴파일된다:

LOAD_GLOBAL counter
LOAD_CONST 1
BINARY_ADD
STORE_GLOBAL counter

각 바이트코드 사이에서 GIL이 다른 스레드로 넘어가면 두 스레드가 같은 값을 읽고 같은 값을 쓰는 race가 일어난다.

그런데 왜 race가 잘 안 보이나

CPython 3.10의 GIL 동작:

  1. 한 번 GIL 잡으면 5ms 또는 명시적 양보 전까지 안 놓음 (sys.getswitchinterval())
  2. for _ in range(1000): tmp = counter; counter = tmp+1 같은 빠른 루프는 5ms 안에 끝남
  3. → 한 스레드가 1000번 다 끝낼 때까지 다른 스레드 대기
  4. → 사실상 순차 실행 = race 미발생

스레드를 10,000개 만들어도 결과가 정확히 나오는 이유: 작업이 너무 빨라서 GIL 양보 시점이 안 찾아오는 것. 멀티스레드인데 직렬 실행과 같다.

Race 강제로 노출시키기

import sys, time
from threading import Thread

sys.setswitchinterval(0.000001)   # GIL 스위칭 간격 극단적으로 짧게

counter = 0

def increment():
    global counter
    for _ in range(1000):
        tmp = counter
        time.sleep(0)              # 명시적으로 GIL 양보
        counter = tmp + 1

threads = [Thread(target=increment) for _ in range(100)]
for t in threads: t.start()
for t in threads: t.join()

print(counter)   # 100,000보다 훨씬 작은 값 (예: 3,000~50,000)

time.sleep(0)은 "지금 GIL 놓고 다른 스레드한테 기회 줘"라는 의미. read와 write 사이에 강제로 스위칭 기회를 만든다.

핵심 교훈

Race condition은 항상 일어나는 게 아니라, 일어날 수 있는 상태다.

  • 100번 돌려서 99번 맞아도 1번 틀리면 프로덕션 버그
  • 빠른 작업/적은 스레드/낮은 부하에서는 안 보이다가 실제 트래픽에서 터짐
  • "운 좋게 안 일어났다" ≠ "안전하다"
  • 공유 가변 상태에 동시 접근하면 무조건 Lock (또는 다른 동기화 메커니즘)

참고