GIL은 무엇을 지키고 무엇을 지키지 않는가
Python

GIL은 무엇을 지키고 무엇을 지키지 않는가

2025.02.17.수정 2026.08.09.
Python4편
  1. 1참조 카운팅은 어떻게 메모리를 해제하고 어디서 실패하는가
  2. 2세대별 가비지 컬렉터가 순환 참조를 찾아내는 다섯 단계
  3. 3GIL은 무엇을 지키고 무엇을 지키지 않는가읽는 중
  4. 4Lock부터 Barrier까지, GIL이 있어도 동기화가 필요한 이유

GIL이 지키는 것

CPython에서 GIL(Global Interpreter Lock)은 인터프리터 내부 상태를 보호하는 뮤텍스입니다. 여러 스레드가 동시에 파이썬 바이트코드를 실행하는 것을 막습니다.

여기서 GIL이 무엇을 지켜주는지 정확히 짚고 갈 필요가 있습니다. GIL이 보호하는 것은 참조 카운트 같은 인터프리터 내부 자료구조이지, 우리가 짠 코드의 불변식이 아닙니다. 원자성이 보장되는 단위도 바이트코드 명령 하나까지입니다. 파이썬 공식 FAQ는 i = i + 1, L.append(L[-1]), D[x] = D[x] + 1을 원자적이지 않은 연산으로 분류하고, “의심스러우면 뮤텍스를 쓰라”는 문장으로 답을 맺습니다.

스레드 3개, GIL 1개 스레드 1 실행 실행 스레드 2 실행 실행 스레드 3 실행 실행 시간 GIL 대기 전환 간격 기본 0.005초

각 스레드는 GIL을 획득해야만 바이트코드를 실행할 수 있고, 그동안 다른 스레드는 대기합니다. 전환 간격은 sys.getswitchinterval()로 확인할 수 있고 기본값은 0.005초입니다. GIL이 켜져 있는 한 스레드를 몇 개 띄우든 파이썬 바이트코드는 한 번에 하나씩만 실행됩니다. 다만 커널을 기다리는 I/O 구간이나 GIL을 명시적으로 놓는 C 코드 안에서는 실제로 동시에 진행됩니다. 표준 라이브러리에도 그런 자리가 있어서, hashlib은 한 번에 2047바이트를 넘는 데이터를 받으면 해시를 계산하는 동안 GIL을 놓습니다.

스레드가 실제로 전환되는 자리

전환은 바이트코드 명령 사이에서 일어납니다. 명령 여러 개로 쪼개지는 연산은 그 사이에서 끊길 수 있다는 뜻입니다. total += 1 한 줄은 반복문 안에서 다음처럼 컴파일됩니다.

LOAD_GLOBAL     total
LOAD_CONST      1
BINARY_OP       +=
STORE_GLOBAL    total
JUMP_BACKWARD

그런데 CPython 3.13에서 GIL을 넘겨줄지 검사하는 자리는 명령 하나하나가 아니라 세 종류로 정해져 있습니다. 반복문이 되돌아가는 JUMP_BACKWARD, 함수를 부르는 CALL 계열, 함수 본문이 시작되는 RESUME입니다. Python/bytecodes.c에서 CHECK_EVAL_BREAKER()가 붙어 있는 자리가 여기뿐입니다.

위 다섯 줄에는 값을 읽는 LOAD_GLOBAL과 되돌려 쓰는 STORE_GLOBAL 사이에 검사 지점이 없습니다. 스레드 네 개가 각각 백만 번씩 total += 1을 돌려도 400만이 그대로 나오는 이유입니다.

GIL 인계를 검사하는 자리 total += 1 LOAD_GLOBAL total LOAD_CONST 1 BINARY_OP += STORE_GLOBAL total JUMP_BACKWARD 검사 지점 읽기와 쓰기 사이 검사 지점 없음 total = total + one() LOAD_GLOBAL total LOAD_GLOBAL one CALL 검사 지점 BINARY_OP + STORE_GLOBAL total 읽기와 쓰기 사이 검사 지점 있음

한 줄에 함수 호출이 하나 끼는 순간 이야기가 달라집니다.

import threading

total = 0


def one():
    return 1


def worker():
    global total
    for _ in range(1_000_000):
        total = total + one()


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

print(total)

CALL에서 GIL이 넘어가면 이미 읽어 둔 total 값은 그 스레드의 스택에 그대로 남습니다. 다른 스레드가 그동안 수십만 번을 더해 놓아도, 원래 스레드는 자기가 읽었던 옛 값에 1을 더해 덮어씁니다. 결과는 400만의 절반 안팎이고 실행할 때마다 달라집니다.

검사 지점은 기댈 곳이 아닙니다

total += 1이 값을 잃지 않는 것은 3.13 인터프리터의 구현 세부사항입니다. 공식 FAQ는 i = i + 1을 원자적이지 않은 연산으로 분류하고 있고, 사이에 프로퍼티나 __add__ 같은 파이썬 코드가 끼는 순간 그대로 깨집니다.

왜 GIL이었나

파이썬은 모든 것이 객체이고, 객체마다 자신을 가리키는 참조의 개수를 들고 있습니다. 참조가 늘면 카운트가 오르고 0이 되면 그 자리에서 메모리가 해제됩니다. 이 갱신은 인터프리터가 코드를 실행하는 내내 수도 없이 일어납니다.

여러 스레드가 이 카운트를 동시에 건드리면 카운트가 실제 참조 수와 어긋납니다. 아직 쓰이는 객체가 해제되거나, 아무도 쓰지 않는 객체가 영원히 남습니다. 인터프리터 전체에 자물쇠 하나를 걸면 이 문제가 통째로 사라집니다. 객체마다 lock을 붙이고 획득 순서를 관리하는 일도, 그 lock을 잡고 푸는 비용도 필요 없어집니다.

대가는 분명합니다. 파이썬 코드로 짠 CPU 작업은 스레드를 몇 개 띄우든 한 번에 하나씩만 돕니다.

멀티스레딩이 이득인 경우

CPU 바운드 작업은 계산 자체가 시간을 잡아먹고, I/O 바운드 작업은 디스크나 네트워크의 응답을 기다리느라 시간을 씁니다. 기다리는 동안 CPython은 GIL을 놓습니다. 이 차이가 결과에 그대로 나타납니다.

같은 작업 여덟 개를 순차 실행, 스레드 4개, 프로세스 4개로 처리해 봤습니다. CPU 바운드 쪽은 파이썬 반복문 계산이고, I/O 바운드 쪽은 한 번에 0.25초씩 걸리는 블로킹 호출입니다.

실행 방식 CPU 바운드 I/O 바운드
순차 1.16초 2.03초
스레드 4개 1.16초 0.51초
프로세스 4개 0.43초 0.58초

CPU 바운드에서 스레드는 아무것도 하지 못하고 GIL을 주고받는 비용만 더합니다. 여기서 병렬성을 얻으려면 프로세스를 띄우거나 GIL을 놓는 C 확장으로 계산을 내려보내야 합니다. 반대로 I/O 바운드에서는 스레드가 프로세스와 같은 성능을 내면서 기동 비용과 메모리는 훨씬 적게 씁니다. 웹 요청, DB 조회, 파일 읽기가 대부분인 코드라면 스레드로 충분합니다.

GIL을 걷어내는 작업

Python 3.13에 PEP 703에 따른 free-threaded 빌드가 실험 단계로 들어갔습니다. --disable-gil로 빌드하면 ABI 태그에 t가 붙고 실행 파일은 보통 python3.13t라는 이름을 갖습니다. 이어서 PEP 779가 통과되면서 free-threaded 파이썬은 3.14에서 실험 단계를 벗어나 공식 지원 단계로 올라섰습니다.

남은 걸림돌은 두 가지입니다. 하나는 단일 스레드 성능입니다. GIL이 있다는 전제로 최적화되어 있던 부분을 세밀한 lock으로 바꾸면 오버헤드가 붙습니다. PEP 779는 pyperformance 기준으로 이 손해를 10% 정도(macOS에서는 3% 정도)로 적고, 15%를 넘지 않는 것을 목표로 잡았습니다. 다른 하나는 서드파티 C 확장입니다. GIL을 가정하고 작성된 확장 모듈은 각자 손을 봐야 합니다.

지금 쓰는 인터프리터가 어느 쪽인지 확인하는 방법도 알아둘 만합니다. sys._is_gil_enabled()는 3.13부터 일반 빌드에도 있어서, 함수가 존재한다는 사실만으로는 판별이 되지 않습니다. 빌드 자체를 확인하려면 sysconfig.get_config_var("Py_GIL_DISABLED")를 봅니다. 공식 문서가 권하는 방법입니다.

>>> import sys, sysconfig
>>> sysconfig.get_config_var("Py_GIL_DISABLED")
0
>>> sys._is_gil_enabled()
True

GIL을 없애는 것과 별개로, 인터프리터마다 GIL을 따로 갖는 방향도 있습니다. PEP 684의 per-interpreter GIL이 3.12에 C API로 들어갔고, 파이썬 코드에서 쓸 수 있는 concurrent.interpreters는 PEP 734로 3.14에 들어왔습니다.

마치며

GIL은 인터프리터의 메모리 관리를 지키는 장치입니다. 참조 카운트가 어긋나지 않게 해 줄 뿐, 우리가 만든 공유 상태까지 지켜주지는 않습니다. 그래서 GIL이 있는 파이썬에서도 lock은 여전히 필요합니다.

실무에서 판단이 갈리는 지점은 결국 작업의 성격입니다. 기다리는 시간이 대부분이라면 스레드가 값싸고 잘 듭니다. 계산이 대부분이라면 스레드를 아무리 늘려도 소용이 없고, 프로세스로 나누거나 계산을 C 쪽으로 내려보내야 합니다. free-threaded 빌드가 공식 지원 단계에 올라섰지만, 쓰는 확장 모듈이 전부 대응하기 전까지 이 판단 기준은 그대로 유효합니다.

함께 보면 좋은 글

참고자료

Thank You for Visiting My Blog, Have a Good Day 😆