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을 푸는 경우가 많아 수치 계산은 어느 정도 멀티스레드 효과가 있습니다. 라이브러리마다 다릅니다.
세 도구의 분담 #
| 도구 | 어울리는 경우 | 코어 활용 |
|---|---|---|
asyncio | I/O 바운드, 동시 N 천 ~ N만 | 1 |
threading | I/O 바운드, 동기 라이브러리, 적은 동시성 | 1 |
multiprocessing | CPU 바운드 | N |
concurrent.futures | 두 모드 통합 인터페이스 | 모드에 따라 |
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 — 더 편한 인터페이스
#
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=10) as pool:
results = list(pool.map(fetch, urls))직접 Thread 객체를 만들 일은 거의 없습니다. ThreadPoolExecutor가 표준 답입니다.
multiprocessing — 진짜 병렬
#
CPU 바운드는 별도 프로세스로 보냅니다. 각 프로세스가 자기 GIL을 가지므로 진짜 병렬 실행이 됩니다.
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% 손실 정도. 시간이 지나며 줄어드는 추세.
어떻게 쓰나 #
# 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을 가지는 모델.
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 섞기
#
async def fetch(url):
time.sleep(1) # GIL 보유 + 이벤트 루프 정지
...14장에서 본 것. 항상 await asyncio.sleep.
2) print가 thread-safe 하지 않음 #
def worker(i):
print(f"start {i}")
do_work()
print(f"end {i}")동시에 여러 스레드가 print 하면 줄이 섞일 수 있습니다. logging 모듈은 thread-safe 하니 그쪽을 쓰세요. 31장 logging과 관측성에서 운영 환경 logging 셋업을 다룹니다.
3) multiprocessing으로 NumPy 배열을 자주 보내기 #
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 확장)조기 최적화는 여기서도 적용 됩니다. 동기 코드가 충분히 빠르면 동시성을 쓰지 않는 편이 더 단순하고 안전합니다.
연습문제 #
cpu_heavy(n)(예:sum(i ** 2 for i in range(n))) 함수를 (1) 동기 직렬, (2)ThreadPoolExecutor(8), (3)ProcessPoolExecutor(8)세 방식으로 같은 입력 8개에 적용해 시간을 측정합니다. GIL의 효과가 직접 보입니다.requests.get(url)동기 라이브러리로 URL 100개를 fetch 하는 코드를 (1) 동기 직렬, (2)ThreadPoolExecutor(20)으로 비교합니다. I/O 바운드는 스레드가 효과를 내는 것을 확인합니다.- 같은 일을
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의 어려운 부분들을 다룹니다.