모던 파이썬 고급 #5 GIL과 동시성 — threading vs multiprocessing vs asyncio
#4 비동기 깊이에서 “CPU 바운드는 asyncio로 해결되지 않는다” 고 짧게 봤습니다. 이번 글이 그 주제입니다. GIL의 정체, threading/multiprocessing/asyncio 세 도구의 분담, 그리고 Python 3.13~3.14가 가져온 free-threaded 빌드 까지를 정리합니다.
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 — 한 번 더
#
#4의 비동기는 단일 스레드 + 협력적 양도입니다.
| 항목 | 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 보유 + 이벤트 루프 정지
...중급 #7에서 본 것입니다. 항상 await asyncio.sleep을 씁니다.
2) print가 thread-safe 하지 않음 #
def worker(i):
print(f"start {i}")
do_work()
print(f"end {i}")동시에 여러 스레드가 print하면 줄이 섞일 수 있습니다. logging 모듈은 thread-safe하니 그쪽을 쓰세요.
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. 병목이 어디인지 측정 (#7에서 다룸)
3. 병목이 I/O 바운드 → asyncio 또는 ThreadPoolExecutor
4. 병목이 CPU 바운드 → ProcessPoolExecutor 또는 free-threaded
5. 위 모두로 안 되면 라이브러리 자체를 더 빠른 것으로 바꾸기 (Cython, Rust 확장)조기 최적화는 여기서도 적용됩니다. 동기 코드가 충분히 빠르면 동시성을 안 쓰는 게 정답입니다.
정리 #
이번 글에서 잡은 것:
- GIL — CPython의 전역 락, 한 번에 한 스레드만 바이트코드 실행
- I/O 동작 중에는 GIL이 풀려 멀티스레딩이 의미 있음, CPU 바운드는 무의미
asyncio(단일 스레드, 대규모 동시성),threading(I/O + 동기 코드),multiprocessing(CPU 바운드)concurrent.futures의 ThreadPoolExecutor / ProcessPoolExecutor가 표준- multiprocessing은 IPC 비용 — 무거운 일을 묶어 보냄,
if __name__ == "__main__":가드 필수 - Free-threaded (3.13~3.14, PEP 703/779) — GIL 없는 빌드, CPU 동시성이 진짜로 동작
- Sub-interpreter (3.14, PEP 734) — 프로세스 안 여러 인터프리터, 새 옵션
- 함정: 동기 sleep, print 동시성, IPC 비용, 데드락
- 시작은 동기 → 측정 → 병목 따라 선택
다음 글(#6 typing 고급)에서는 중급 #2의 다음 단계 — Variance, ParamSpec, Self, TypeGuard/TypeIs, overload까지 typing의 어려운 부분들을 다룹니다.