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 동작:
- 한 번 GIL 잡으면 5ms 또는 명시적 양보 전까지 안 놓음 (
sys.getswitchinterval()) for _ in range(1000): tmp = counter; counter = tmp+1같은 빠른 루프는 5ms 안에 끝남- → 한 스레드가 1000번 다 끝낼 때까지 다른 스레드 대기
- → 사실상 순차 실행 = 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 (또는 다른 동기화 메커니즘)