목차
19 장

GIL과 동시성 — threading vs multiprocessing vs asyncio

GIL의 정체, threading/multiprocessing/asyncio 세 도구의 분담, 그리고 Python 3.13~3.14의 free-threaded 빌드(PEP 703/779)까지 한곳에 정리합니다.

18장 비동기 깊이에서 “CPU 바운드는 asyncio로 해결되지 않는다” 고 짧게 봤습니다. 본 챕터가 그 주제입니다. GIL의 정체, threading / multiprocessing / asyncio 세 도구의 분담, 그리고 Python 3.13~3.14가 가져온 free-threaded 빌드 까지를 정리합니다.

본 챕터는 21장 성능과 짝을 이룹니다. 21장이 “내 코드의 병목이 어디인지 측정"이라면, 본 챕터는 “병목이 CPU / I/O 중 어디냐에 따라 어떤 도구를 고를지"를 판단하는 기준입니다.

GIL — Global Interpreter Lock #

CPython (표준 파이썬 구현)에는 GIL이라는 전역 락이 있습니다. 한 번에 하나의 스레드만 파이썬 바이트코드를 실행 할 수 있습니다.

왜 있나 #

CPython 내부의 객체 참조 카운팅을 안전하게 만들기 위해서. 락 없이 동시에 객체 참조를 만지면 카운팅이 어긋나 메모리가 망가집니다. 간단함 + 단일 스레드 성능 + C 확장 호환을 위해 도입됐고 30년 넘게 유지됐습니다.

결과 — CPU 바운드 멀티스레딩이 무의미 #

🚫 멀티 스레드인데 안 빨라짐
import threading

def cpu_heavy(n):
    total = 0
    for i in range(n):
        total += i ** 2
    return total

threads = [threading.Thread(target=cpu_heavy, args=(10_000_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()

스레드 4개로 같은 일을 4번 한다고 4배 빠르지 않습니다. GIL 때문에 사실상 직렬로 실행됩니다. CPU 코어가 8개 있어도 1개만 쓰고 있는 것입니다.

GIL이 풀리는 시점 — I/O #

다행히 I/O 동작 중에는 GIL이 풀립니다. socket.recv, time.sleep, 파일 읽기, DB 쿼리 등은 다른 스레드가 동시에 진행할 수 있습니다. 그래서 I/O 바운드 멀티스레딩은 의미 있고, CPU 바운드는 의미 없는 것.

NumPy, Pandas 같은 C 확장도 무거운 연산 중에는 GIL을 푸는 경우가 많아 수치 계산은 어느 정도 멀티스레드 효과가 있습니다. 라이브러리마다 다릅니다.

세 도구의 분담 #

도구어울리는 경우코어 활용
asyncioI/O 바운드, 동시 N 천 ~ N만1
threadingI/O 바운드, 동기 라이브러리, 적은 동시성1
multiprocessingCPU 바운드N
concurrent.futures두 모드 통합 인터페이스모드에 따라

threading — 동기 코드의 동시성 #

threading 기본
import threading

def fetch(url):
    response = requests.get(url)
    return response.text

threads = []
for url in urls:
    t = threading.Thread(target=fetch, args=(url,))
    t.start()
    threads.append(t)

for t in threads:
    t.join()

장점:

  • 동기 라이브러리를 그대로 사용
  • 기존 코드 변경 최소
  • I/O 바운드는 충분히 빠름

단점:

  • 동시성이 N 천 단위로 가면 스레드 자체 비용 (메모리, 컨텍스트 스위칭)
  • 락 / 공유 상태가 잦으면 디버그 어려움
  • CPU 바운드는 GIL 때문에 무의미

Lock, RLock, Semaphore, Event #

공유 상태 보호
counter = 0
lock = threading.Lock()

def increment():
    global counter
    with lock:
        counter += 1

+=가 원자적이지 않습니다 (load → add → store). 락 없이는 race condition이 일어납니다. 비동기에는 이 문제가 없는데 (한 번에 한 코루틴), 스레드에는 있습니다.

concurrent.futures.ThreadPoolExecutor — 더 편한 인터페이스 #

ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor

with ThreadPoolExecutor(max_workers=10) as pool:
    results = list(pool.map(fetch, urls))

직접 Thread 객체를 만들 일은 거의 없습니다. ThreadPoolExecutor가 표준 답입니다.

multiprocessing — 진짜 병렬 #

CPU 바운드는 별도 프로세스로 보냅니다. 각 프로세스가 자기 GIL을 가지므로 진짜 병렬 실행이 됩니다.

multiprocessing 기본
from concurrent.futures import ProcessPoolExecutor

def cpu_heavy(n):
    return sum(i ** 2 for i in range(n))

if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=8) as pool:
        results = list(pool.map(cpu_heavy, [10**7] * 8))

8개의 프로세스가 8개 코어를 동시에 씁니다. 진짜 8배 가까운 속도.

비용 — 프로세스 생성과 IPC #

  • 프로세스 생성이 스레드보다 비쌈
  • 데이터 전송이 직렬화 / 역직렬화 (pickle)를 거침 — 큰 데이터 전송은 비쌈
  • 디버깅이 어려워짐 (프로세스 격리)

그래서 무거운 계산을 묶어서 보내야 효과가 있습니다. 작은 일을 자주 보내면 IPC 비용이 계산보다 큽니다.

if __name__ == "__main__": — 필수 #

진입점 가드 필수
def worker(x): ...

if __name__ == "__main__":
    with ProcessPoolExecutor() as pool:
        pool.map(worker, [1, 2, 3])

multiprocessing은 자식 프로세스를 만들 때 모듈을 다시 import 합니다. 그 때 pool.map(...) 같은 호출이 자식에서 또 실행되면 무한 재귀입니다. 가드가 필수.

공유 상태 — Queue, Manager, shared_memory #

프로세스 간 데이터 공유는 까다롭습니다.

공유 큐
from multiprocessing import Queue, Process

def worker(q):
    q.put("hello from worker")

if __name__ == "__main__":
    q = Queue()
    p = Process(target=worker, args=(q,))
    p.start()
    print(q.get())   # hello from worker
    p.join()
  • multiprocessing.Queue — 프로세스 간 안전한 큐
  • multiprocessing.Manager — dict / list 등을 프록시로 공유
  • multiprocessing.shared_memory (3.8+) — 큰 numpy 배열 등을 복사 없이 공유

asyncio — 한 번 더 #

14장 / 18장의 비동기는 단일 스레드 + 협력적 양도입니다.

항목asyncio
강점동시성 N만, 적은 메모리, 명시적 양도점
약점비동기 라이브러리 필요, CPU 바운드 효과 없음

asyncio와 threading 섞기 #

asyncio.to_thread로 동기 함수를 비동기 안에서 안전하게.

섞기
import asyncio

async def fetch(url):
    return await asyncio.to_thread(requests.get, url)

asyncio와 multiprocessing 섞기 #

비동기 + 프로세스 풀
import asyncio
from concurrent.futures import ProcessPoolExecutor

async def main():
    loop = asyncio.get_running_loop()
    with ProcessPoolExecutor() as pool:
        result = await loop.run_in_executor(pool, cpu_heavy, 10_000_000)

CPU 바운드 일을 별도 프로세스로 보내고, 결과는 비동기로 await.

Free-threaded — Python 3.13~3.14의 큰 변화 #

PEP 703이 GIL 없는 빌드를 도입했습니다. 3.13에서 실험 단계, 3.14에서 PEP 779로 정식 지원 단계로 들어왔습니다.

무엇이 바뀌나 #

  • GIL이 사라진다 — CPU 바운드 멀티스레딩이 진짜로 동작
  • 기존 동기 코드를 멀티스레드로 돌리면 자동으로 코어 수만큼 가속
  • 새 동시성 모델 (단일 프로세스 + 진짜 멀티스레드)가 가능

비용 — 단일 스레드 성능 #

GIL이 단순한 만큼 단일 스레드 성능을 끌어올리는 도구이기도 했습니다. 없애면 약간의 단일 스레드 성능 손실이 있습니다. 3.14 시점 기준 약 5~10% 손실 정도. 시간이 지나며 줄어드는 추세.

어떻게 쓰나 #

free-threaded 빌드 사용
# uv 로 free-threaded 빌드 설치
uv python install 3.14t
# (t 가 free-threaded 빌드)

# 프로젝트가 이걸 쓰게
uv init my-app --python 3.14t

라이브러리 호환성 #

C 확장 라이브러리들이 GIL을 가정 한 코드가 많아서 호환성 마이그레이션이 진행 중입니다. NumPy, PyTorch, Pillow 등 주요 라이브러리는 free-threaded 호환을 진행했거나 진행 중입니다. 새 프로젝트라면 free-threaded부터 시도해볼 만하지만, 레거시 의존성이 있는 프로젝트는 호환 확인 필수.

sub-interpreter — PEP 734 (3.14) #

GIL 회피의 또 다른 방향. 하나의 프로세스 안에 여러 인터프리터를 두고, 각 인터프리터가 자기 GIL을 가지는 모델.

sub-interpreter (3.14+)
from concurrent.futures import InterpreterPoolExecutor

with InterpreterPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(cpu_heavy, [10**7] * 4))

multiprocessing보다 가볍고, free-threaded보다 호환성이 안전한 중간 지대. 3.14 시점에는 아직 새로운 영역이지만 CPU 바운드 동시성의 중요한 옵션으로 역할잡을 가능성이 큽니다.

결정 가이드 — 한 표 #

첫 시도
HTTP 요청 1000개 동시asyncio (httpx)
HTTP 요청 50개, 동기 라이브러리 사용 중ThreadPoolExecutor
무거운 수치 계산 8 코어 활용ProcessPoolExecutor
numpy가 GIL을 푸는 무거운 연산ThreadPoolExecutor (또는 numpy 자체 병렬화)
새 프로젝트, CPU + I/O 동시 활용3.14 free-threaded + threading
안정적인 격리, 큰 데이터 공유multiprocessing + shared_memory
단일 프로세스에서 CPU 바운드 다중InterpreterPoolExecutor (3.14+)

자주 만나는 함정 #

1) time.sleep vs asyncio.sleep 섞기 #

🚫 비동기 안에서 동기 sleep
async def fetch(url):
    time.sleep(1)    # GIL 보유 + 이벤트 루프 정지
    ...

14장에서 본 것. 항상 await asyncio.sleep.

2) print가 thread-safe 하지 않음 #

thread + print
def worker(i):
    print(f"start {i}")
    do_work()
    print(f"end {i}")

동시에 여러 스레드가 print 하면 줄이 섞일 수 있습니다. logging 모듈은 thread-safe 하니 그쪽을 쓰세요. 31장 logging과 관측성에서 운영 환경 logging 셋업을 다룹니다.

3) multiprocessing으로 NumPy 배열을 자주 보내기 #

🚫 큰 배열을 매번 pickle
with ProcessPoolExecutor() as pool:
    pool.map(process, [big_numpy_array] * 100)

배열을 pickle로 직렬화 → 자식에 전송 → 자식이 deserialize. 계산보다 IPC 비용이 더 큽니다. shared_memory 또는 numpy 자체 병렬화 (BLAS / LAPACK 멀티스레드)를 검토.

4) 데드락 #

여러 락을 다른 순서로 잡으면 데드락이 납니다.

🚫 데드락
def t1():
    with lock_a:
        with lock_b:    # t2 가 b 잡고 a 기다리는 중이면 데드락
            ...

def t2():
    with lock_b:
        with lock_a:
            ...

룰: 락의 획득 순서를 전체 코드에서 일관 되게 두기. 또는 threading.RLock으로 재진입 가능 락 사용.

실전 — 어떻게 시작할까 #

단계별 권장
1. 일단 동기 코드로 작동시킴
2. 병목이 어디인지 측정 (21장 [성능](./performance/) 에서 다룸)
3. 병목이 I/O 바운드 → asyncio 또는 ThreadPoolExecutor
4. 병목이 CPU 바운드 → ProcessPoolExecutor 또는 free-threaded
5. 위 모두로 안 되면 라이브러리 자체를 더 빠른 것으로 바꾸기 (Cython, Rust 확장)

조기 최적화는 여기서도 적용 됩니다. 동기 코드가 충분히 빠르면 동시성을 쓰지 않는 편이 더 단순하고 안전합니다.

연습문제 #

  1. cpu_heavy(n) (예: sum(i ** 2 for i in range(n))) 함수를 (1) 동기 직렬, (2) ThreadPoolExecutor(8), (3) ProcessPoolExecutor(8) 세 방식으로 같은 입력 8개에 적용해 시간을 측정합니다. GIL의 효과가 직접 보입니다.
  2. requests.get(url) 동기 라이브러리로 URL 100개를 fetch 하는 코드를 (1) 동기 직렬, (2) ThreadPoolExecutor(20)으로 비교합니다. I/O 바운드는 스레드가 효과를 내는 것을 확인합니다.
  3. 같은 일을 httpx.AsyncClient + asyncio.gather로 작성해 위 (2)와 비교합니다. 동시성이 1000개로 늘어나면 어느 모델이 더 잘 견디는지 가설을 세우고 측정합니다.

한 줄 요약: GIL은 CPython의 전역 락, 한 번에 한 스레드만 바이트코드. I/O 중에는 GIL이 풀려 threading 유효, CPU 바운드는 multiprocessing / 3.14 free-threaded. asyncio는 단일 스레드 N만 동시성, CPU 바운드는 못 푼다. 도구 분담은 ThreadPoolExecutor (I/O 동기) / ProcessPoolExecutor (CPU) / asyncio (대규모 I/O 비동기) / 3.14+ free-threaded (CPU 동시성, 호환성 주의). 시작은 동기 → 측정 → 병목 선택.

다음 챕터 #

다음 20장 typing 고급 — Variance, ParamSpec, Self, overload에서는 9장 typing 본격의 다음 단계 — Variance, ParamSpec, Self, TypeGuard / TypeIs, overload까지 typing의 어려운 부분들을 다룹니다.

X