Python의 GIL이란 무엇이고 성능에 어떻게 영향을 미치나

1. 개요

Java나 Go에서 Python으로 전환한 많은 개발자들이 같은 문제를 겪습니다. 8코어 컴퓨터에서 4개의 thread를 실행하지만 프로그램이 더 빨라지지 않고, 때로는 더 느려집니다. 주범은 보통 **GIL (Global Interpreter Lock)**입니다.

이 글은 GIL이 무엇이고 왜 존재하는지 설명합니다. GIL이 CPU-bound 코드와 I/O-bound 코드에 미치는 영향을 측정할 수 있습니다. 글의 끝에서는 threading을 언제 사용하고, multiprocessing을 언제 사용하는지, 그리고 Python 3.13+ 이상의 free-threaded 빌드가 무엇을 바꾸는지 알게 됩니다.

2. GIL이란 무엇인가?

GIL은 CPython (가장 널리 사용되는 Python 구현)의 mutex입니다. 각 시점에서 한 프로세스 내에서 오직 한 thread만 Python bytecode를 실행할 수 있습니다.

여전히 많은 thread를 생성할 수 있습니다. thread들은 여전히 운영 체제의 진정한 thread입니다. 하지만 bytecode를 실행하려면 thread가 GIL을 획득해야 합니다. 다른 thread들은 기다려야 합니다.

2.1. CPython이 GIL이 필요한 이유는?

주된 이유는 reference counting을 사용한 메모리 관리 메커니즘입니다. 각 객체는 그것을 참조하는 개수를 나타내는 카운터를 가지고 있습니다:

import sys

order = {"id": 1, "total": 250_000}
print(sys.getrefcount(order))

alias = order
print(sys.getrefcount(order))
2
3

sys.getrefcount는 참조 개수를 반환합니다. 결과는 항상 1보다 큽니다. 함수 호출 자체도 임시 참조를 생성하기 때문입니다. alias = order를 할당하면 카운터가 1 증가합니다.

두 thread가 이 카운터를 동시에 증가시키거나 감소시킨다면 잠금 없이 카운터가 잘못될 수 있습니다. 결과는 메모리 누수이거나, 더 나쁘게는, 여전히 사용 중인 객체가 해제될 수 있습니다. 하나의 전역 잠금을 설정하는 것이 모든 객체를 보호하는 가장 간단한 방법입니다. 또한 C 확장을 작성하기 쉽게 해주고 단일 스레드 코드를 빠르게 만듭니다.

2.2. Thread는 언제 GIL을 해제하는가?

GIL을 보유한 thread는 두 가지 경우에 해제합니다:

  1. I/O를 기다릴 때: 파일 읽기, 네트워크 호출, time.sleep… 이러한 함수들은 block하기 전에 GIL을 해제합니다.
  2. “차례”가 끝났을 때: interpreter는 주기적으로 실행 중인 thread가 GIL을 해제하도록 요청하여 다른 thread에게 실행 기회를 줍니다.

차례의 시간 간격을 확인할 수 있습니다:

import sys

print(sys.getswitchinterval())
0.005

기본값은 5ms입니다. sys.setswitchinterval()으로 변경할 수 있지만 거의 필요하지 않습니다.

3. CPU-bound 코드에 대한 영향

직접 측정해봅시다. 다음 함수는 소수를 세는데, 순수 계산입니다:

import time
from concurrent.futures import ThreadPoolExecutor


def count_primes(limit: int) -> int:
    count = 0
    for n in range(2, limit):
        if all(n % d for d in range(2, int(n ** 0.5) + 1)):
            count += 1
    return count


def run_sequential(jobs: list[int]) -> float:
    start = time.perf_counter()
    for job in jobs:
        count_primes(job)
    return time.perf_counter() - start


def run_threads(jobs: list[int]) -> float:
    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=4) as pool:
        list(pool.map(count_primes, jobs))
    return time.perf_counter() - start


if __name__ == "__main__":
    jobs = [200_000] * 4
    print(f"순차 실행 : {run_sequential(jobs):.2f}s")
    print(f"4개 thread: {run_threads(jobs):.2f}s")

run_sequential은 4개의 작업을 순서대로 실행합니다. run_threads는 4개의 작업을 4개의 thread에 할당합니다. 8코어 머신에서의 결과 (참고용):

순차 실행 : 2.31s
4개 thread: 2.40s

4개의 thread는 더 빠르지 않습니다. 실제로 더 느립니다. GIL 때문에 thread들은 한 개의 코어에서 차례로 실행됩니다. 추가 시간은 GIL을 놓고 벌어지는 경합과 컨텍스트 전환의 오버헤드입니다.

주의: 이것이 가장 흔한 실수입니다. CPython의 GIL이 있는 순수 Python 계산 코드에 thread를 추가하는 것은 속도를 높이지 않습니다.

4. I/O-bound 코드에 대한 영향

주로 I/O를 기다리는 코드의 경우 상황이 완전히 다릅니다. 원격 PaymentService 호출이 0.5초를 소요하는 경우를 시뮬레이션해봅시다:

import time
from concurrent.futures import ThreadPoolExecutor


def charge_payment(order_id: int) -> str:
    time.sleep(0.5)  # 결제 게이트웨이로의 HTTP 요청을 시뮬레이션
    return f"order-{order_id}: OK"


if __name__ == "__main__":
    order_ids = list(range(10))

    start = time.perf_counter()
    for oid in order_ids:
        charge_payment(oid)
    print(f"순차 실행  : {time.perf_counter() - start:.2f}s")

    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=10) as pool:
        list(pool.map(charge_payment, order_ids))
    print(f"10개 thread: {time.perf_counter() - start:.2f}s")
순차 실행  : 5.01s
10개 thread: 0.50s

10배 빠릅니다. time.sleep은 socket 및 파일 읽기와 마찬가지로 대기 중에 GIL을 해제합니다. 10개의 thread는 동시에 대기합니다. GIL이 필요한 시간은 결과를 처리하는 짧은 순간뿐입니다.

결론: Python의 thread는 I/O-bound 작업에 여전히 매우 유용합니다. API 호출, 데이터베이스 쿼리 또는 파일 다운로드 예를 들어. I/O 량이 매우 많을 때는 asyncio도 좋은 선택지입니다.

5. multiprocessing으로 GIL 극복하기

계산을 위해 여러 코어를 활용하려면 여러 프로세스를 사용하는 것이 표준입니다. 각 프로세스는 자체 interpreter와 자체 GIL을 가집니다.

import time
from concurrent.futures import ProcessPoolExecutor


def count_primes(limit: int) -> int:
    count = 0
    for n in range(2, limit):
        if all(n % d for d in range(2, int(n ** 0.5) + 1)):
            count += 1
    return count


if __name__ == "__main__":
    jobs = [200_000] * 4
    start = time.perf_counter()
    with ProcessPoolExecutor(max_workers=4) as pool:
        results = list(pool.map(count_primes, jobs))
    print(results)
    print(f"4개 process: {time.perf_counter() - start:.2f}s")
[17984, 17984, 17984, 17984]
4개 process: 0.68s

순차 실행의 2.31초와 비교하면 약 3.4배 빠릅니다. 프로세스 시작 비용 때문에 정확히 4배는 아닙니다.

if __name__ == "__main__": 블록은 필수입니다. Windows와 macOS에서는 자식 프로세스가 모듈을 다시 import하여 생성됩니다. 이 블록이 없으면 자식 프로세스가 다시 pool을 생성하고 프로그램이 오류가 됩니다.

주의: Multiprocessing은 자체 비용이 있습니다. 매개변수와 결과는 프로세스 간에 전송되기 위해 pickle로 직렬화되어야 합니다. 수백 MB의 DataFrame 같은 큰 객체를 작은 작업에 보내면, 복사 비용이 얻은 이득보다 클 수 있습니다. Lambda와 중첩 함수도 pickle될 수 없습니다.

6. C 확장은 GIL을 해제할 수 있습니다

NumPy, hashlib, zlib 같은 많은 C 작성 라이브러리는 무거운 부분을 실행할 때 GIL을 주도적으로 해제합니다. 그러면 thread들은 진정으로 병렬로 실행됩니다:

import hashlib
import os
import time
from concurrent.futures import ThreadPoolExecutor

payloads = [os.urandom(50_000_000) for _ in range(4)]


def digest(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


if __name__ == "__main__":
    start = time.perf_counter()
    for p in payloads:
        digest(p)
    print(f"순차 실행 : {time.perf_counter() - start:.2f}s")

    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=4) as pool:
        list(pool.map(digest, payloads))
    print(f"4개 thread: {time.perf_counter() - start:.2f}s")
순차 실행 : 0.98s
4개 thread: 0.27s

hashlib은 큰 데이터(약 2KB 이상)를 hash할 때 GIL을 해제합니다. 그래서 4개의 thread가 4개의 코어에서 실행됩니다. 실제로 GIL은 Python bytecode만 차단하고 네이티브 라이브러리의 기계 코드는 차단하지 않습니다.

7. GIL은 코드를 thread-safe 하게 만들지 않습니다

많은 사람들이 “GIL이 있으면 lock이 필요 없다”고 생각합니다. 이것은 잘못된 것입니다. GIL은 interpreter의 내부 상태를 보호하지만 논리를 보호하지 않습니다. Thread는 두 명령어 사이 어디서나 전환될 수 있습니다.

import threading
import time


class BankAccount:
    def __init__(self) -> None:
        self.balance = 0
        self._lock = threading.Lock()

    def deposit_unsafe(self, amount: int) -> None:
        current = self.balance
        time.sleep(0)  # GIL을 해제하고, 중간에 I/O 작업을 시뮬레이션
        self.balance = current + amount

    def deposit_safe(self, amount: int) -> None:
        with self._lock:
            current = self.balance
            time.sleep(0)
            self.balance = current + amount


def run(method_name: str, threads: int = 10, times: int = 100) -> int:
    account = BankAccount()
    method = getattr(account, method_name)

    def worker() -> None:
        for _ in range(times):
            method(1)

    pool = [threading.Thread(target=worker) for _ in range(threads)]
    for t in pool:
        t.start()
    for t in pool:
        t.join()
    return account.balance


if __name__ == "__main__":
    print("unsafe:", run("deposit_unsafe"))
    print("safe  :", run("deposit_safe"))
unsafe: 112
safe  : 1000

10 × 100 = 1000을 기대합니다. deposit_unsafe에서 여러 thread가 같은 오래된 current 값을 읽고 서로 덮어써서 대부분의 입금이 손실됩니다. time.sleep(0)은 오류를 확실히 드러냅니다. 실제 코드에서 GIL을 해제하는 위치는 로그 또는 데이터베이스 호출일 수 있습니다. deposit_safe는 threading.Lock을 사용하여 읽기-수정-쓰기 작업을 원자적으로 만듭니다.

pytest를 사용하여 이 동작을 테스트로 작성합니다. 설치:

pip install pytest
# test_bank_account.py
from bank_account import run


def test_safe_deposit_keeps_correct_balance():
    assert run("deposit_safe", threads=8, times=200) == 1600


def test_unsafe_deposit_loses_updates():
    assert run("deposit_unsafe", threads=8, times=200) < 1600
pytest -q test_bank_account.py
..                                                       [100%]
2 passed in 0.41s

두 번째 테스트는 CPython에 GIL이 있음에도 불구하고 race condition이 실제로 존재함을 증명합니다.

8. Python free-threaded: GIL 없는 미래

Python 3.13부터 CPython에는 free-threaded (PEP 703) 빌드가 있어 GIL을 비활성화할 수 있습니다. 보통 이 버전은 python3.13t 또는 python3.14t 명령으로 실행합니다. Python 3.14에서는 이 버전이 공식 지원됩니다 (PEP 779), 하지만 여전히 기본값은 아닙니다.

실행 중 GIL 상태를 확인할 수 있습니다:

import sys
import sysconfig

print("Free-threading 지원:", bool(sysconfig.get_config_var("Py_GIL_DISABLED")))
print("GIL 활성화됨:", sys._is_gil_enabled())

free-threaded 빌드로 실행:

python3.14t check_gil.py
Free-threading 지원: True
GIL 활성화됨: False

이 버전으로는 섹션 3의 count_primes 예제가 ThreadPoolExecutor로 진정으로 여러 코어를 활용하게 됩니다.

그럼에도 불구하고 전환 전에 고려할 몇 가지 점이 있습니다:

  • 단일 스레드 코드는 조금 느립니다. Interpreter가 더 정교한 동기화 메커니즘을 사용해야 하기 때문입니다.
  • C 확장이 호환되어야 합니다. Free-threading 지원을 선언하지 않은 확장을 import하면 CPython이 자동으로 GIL을 다시 활성화하고 경고를 출력합니다. PYTHON_GIL=0으로 강제할 수 있지만 그렇게 하는 것은 위험을 감수하는 것입니다.
  • Race condition이 더 쉽게 발생합니다. 섹션 7의 예제가 “단 하나의 thread만 한 번에 실행”되는 것으로 더 이상 “보호”되지 않습니다.

9. 어떤 도구를 선택할까?

작업 유형 추천 도구
I/O-bound (HTTP, DB, 파일) threading, ThreadPoolExecutor, asyncio
순수 Python CPU-bound ProcessPoolExecutor / multiprocessing
NumPy, hashlib 등에서의 CPU-bound Thread도 좋습니다. 라이브러리가 GIL을 해제하기 때문입니다
CPU-bound, 메모리 공유 필요, 라이브러리 호환성 있음 Python free-threaded (3.13t/3.14t) 검토

주의: 직감으로 최적화하지 마세요. time.perf_counter() 또는 cProfile로 측정하세요. “GIL 때문에 느리다”는 많은 프로그램이 실제로는 알고리즘이나 데이터베이스 쿼리 때문에 느립니다.

10. 결론

GIL은 CPython의 전역 잠금입니다. 한 번에 한 thread만 Python bytecode를 실행할 수 있도록 하며, 주로 reference counting을 보호하기 위한 것입니다.

실제 결과는 네 가지입니다:

  • Thread는 순수 Python CPU-bound 코드를 가속하지 않습니다.
  • Thread는 I/O-bound 및 GIL을 해제하는 네이티브 라이브러리에 매우 효과적입니다.
  • 계산을 위해 여러 코어를 활용하려면 multiprocessing을 사용하거나 Python 3.13+ free-threaded 빌드를 시도하세요.
  • GIL은 lock을 대체하지 않습니다. Thread 간에 공유되는 데이터는 여전히 올바르게 동기화되어야 합니다.
pythongilconcurrencymultithreadingperformance