왜 파이썬은 스레드만으로 충분하지 않은가
스레드 200개를 띄웠는데 왜 안 빨라질까
크롤러를 하나 짠다고 해봅시다. URL 200개에 요청을 보내고 응답을 모아야 합니다. ThreadPoolExecutor 로 워커 200개를 띄우면 순식간에 끝날 것 같습니다. 실제로 해보면 꽤 빨라지긴 합니다 — 그런데 워커를 2,000개로 늘리면 어떨까요? 대부분의 환경에서 이 시점부터 메모리 사용량이 눈에 띄게 늘고, 컨텍스트 스위칭 비용 때문에 오히려 느려지는 지점이 옵니다. OS 스레드는 공짜가 아닙니다. 스택만 기본 수백 KB~수 MB를 차지하고, 커널이 스케줄링을 책임집니다.
import time
from concurrent.futures import ThreadPoolExecutor
import requests
def fetch(url: str) -> int:
return requests.get(url, timeout=5).status_code
urls = [f"https://example.com/page/{i}" for i in range(200)]
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=200) as pool:
results = list(pool.map(fetch, urls))
print(f"{time.perf_counter() - start:.2f}s")이 코드는 잘 작동합니다. 문제는 워커 수를 늘리는 전략이 어느 순간부터 수익 체감을 넘어 손해로 바뀐다는 점입니다. 그리고 그 원인 중 하나가 바로 GIL(Global Interpreter Lock)입니다.
GIL은 스레드를 막지 않는다 — 정확히는 "동시에 바이트코드를 실행하는 것"을 막는다
GIL에 대해 흔히 오해하는 지점이 있습니다. "파이썬은 GIL 때문에 멀티스레딩이 안 된다"는 말은 절반만 맞습니다. GIL은 한 시점에 하나의 스레드만 파이썬 바이트코드를 실행하도록 강제하는 락입니다. 그런데 requests.get() 이 네트워크 응답을 기다리는 동안, 그 스레드는 바이트코드를 실행하고 있지 않습니다 — 커널 시스템 콜이 끝나길 기다리는 중이죠. 이때 GIL은 풀립니다. 그래서 I/O 바운드 작업은 스레드로 어느 정도 병렬성을 얻습니다. 위 크롤러 예제가 실제로 빨라졌던 이유입니다.
반대로 CPU 바운드 작업을 스레드로 돌리면 이야기가 달라집니다.
from concurrent.futures import ThreadPoolExecutor
def cpu_heavy(n: int) -> int:
total = 0
for i in range(n):
total += i * i
return total
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(cpu_heavy, [10_000_000] * 4))이 코드는 스레드 4개를 쓰지만 코어 4개를 동시에 쓰지 못합니다. GIL이 계속 한 스레드에게만 바이트코드 실행권을 주기 때문에, 단일 스레드로 순서대로 돌린 것과 총 실행 시간이 거의 같습니다(스레드 전환 오버헤드 때문에 오히려 조금 더 느릴 수도 있습니다). CPU 바운드 작업을 병렬화하려면 스레드가 아니라 multiprocessing 이 필요합니다 — 이건 이 책의 마지막 장에서 다시 짚습니다.
그럼 I/O 바운드에는 스레드만 쓰면 되는 것 아닌가
여기서 asyncio가 끼어드는 이유는 GIL이 아니라 스케일입니다. 웹 크롤러가 아니라 동시 접속자 만 명을 받는 API 서버를 생각해보죠. 요청마다 스레드를 하나씩 배정하면 만 개의 OS 스레드가 필요합니다. 대부분의 시스템은 이 수준의 스레드를 감당하지 못하거나, 감당하더라도 스케줄러 오버헤드로 응답 지연이 커집니다.
코루틴은 다릅니다. OS 스레드가 아니라 파이썬 인터프리터가 직접 관리하는 객체이고, 하나 만드는 데 드는 메모리가 스레드보다 훨씬 적습니다(수 KB 수준). 이벤트 루프 하나가 단일 OS 스레드 위에서 수만 개의 코루틴을 스케줄링할 수 있는 이유입니다. 대기 중인 코루틴은 커널 스레드를 붙잡고 있지 않으므로, "동시에 열려 있는 연결 수"가 스레드 개수의 제약을 받지 않습니다.
주의할 점은, 이것이 asyncio가 스레드보다 "항상 더 빠르다"는 뜻은 아니라는 겁니다. 동시 연결이 수십 개 수준이라면 스레드 풀로도 충분하고, 코드는 오히려 더 단순합니다. asyncio가 값을 발휘하는 지점은 동시성의 규모가 스레드로는 감당이 안 되는 수준일 때입니다. 이 책은 그 지점부터 이야기를 시작합니다 — 이벤트 루프가 정확히 무엇을 하는지부터요.