요약
- gevent에서 CPU 바운드 작업을 스레드풀로 넘기는 건 보통 소용없다. GIL 때문에 파이썬 코드는 어차피 직렬화된다.
- 예외는 GIL을 놓는 C 확장이다. 이런 함수는 진짜 OS 스레드에서 병렬로 돌므로, 넘기는 순간 허브가 자유로워진다.
- 측정: PBKDF2 해싱을 허브에서 직접 하면 다른 greenlet이 64ms 멈추고,
threadpool.apply로 넘기면 6ms로 떨어진다. 총 CPU는 그대로다 — 사라지는 건 head-of-line blocking뿐이다.
본문
판별 기준 — 그 함수가 GIL을 놓는가
넘기기 전에 반드시 확인한다. 스레드 여러 개로 같은 작업을 돌려 가속되면 GIL을 놓는 것이고, 순차와 같으면 안 놓는 것이다.
import hashlib, time, threading
ARGS = ('sha256', b'pw', b'salt', 600000)
def work(): hashlib.pbkdf2_hmac(*ARGS)
t0 = time.perf_counter()
for _ in range(4): work()
seq = time.perf_counter() - t0
ts = [threading.Thread(target=work) for _ in range(4)]
t0 = time.perf_counter()
for t in ts: t.start()
for t in ts: t.join()
par = time.perf_counter() - t0
print(f'{seq/par:.2f}x') # 3.00x -> GIL 해제
hashlib.pbkdf2_hmac은 3.00배가 나왔다. 반면 순수 파이썬 루프나 대량 객체 생성은 1.0배 근처라 넘겨도 소용없다.
허브 정지가 실제로 사라지는지 확인
총 tick 수를 세면 안 된다. 해싱 전후로 채워져서 차이가 안 보인다. tick 사이 최대 간격을 봐야 한다.
허브에서 직접 해싱 : 다른 greenlet 최대 정지 64ms
threadpool 오프로드 : 다른 greenlet 최대 정지 6ms (sleep 간격 수준)
적용
from gevent import get_hub
get_hub().threadpool.apply(user.set_password, (password,))
threadpool.apply는 호출한 greenlet만 블로킹하고 허브에는 제어를 넘긴다. 기본 풀 크기는 10이다.
해당되는 사례
Django의 기본 비밀번호 해셔가 대표적이다. 4.2 기준 PBKDF2 반복이 600,000회이고(django/contrib/auth/hashers.py), 내부적으로 hashlib.pbkdf2_hmac을 부른다(django/utils/crypto.py). SNS 로그인이 아닌 가입·로그인마다 수백 ms를 쓴다. 그 외 압축·암호화·이미지 처리·수치연산 등 GIL을 놓는 C 라이브러리 호출이 후보다.
착각하면 안 되는 것
- 총 CPU는 안 줄어든다. 해싱 비용은 그대로 든다. 줄어드는 건 그동안 다른 요청이 밀리는 것뿐이다.
- 그 요청 자체는 조금 느려질 수 있다. 스레드 전환 비용이 붙는다. 목적이 head-of-line blocking 제거라면 맞는 거래다.
- gevent 허브가 없는 프로세스에서 같은 코드가 불리면 곤란하다. 배치·워커처럼 monkey patch가 안 된 곳에서
get_hub()를 부르면 허브를 새로 만든다. 호출 지점이 공유 코드라면 분기가 필요하다.
검증 상태
- 측정: 위 두 실험 모두 직접 실행 (gevent 25.9.1, CPython 3.11)
- 소스 확인: Django 4.2 PBKDF2 반복수,
crypto.pbkdf2가hashlib.pbkdf2_hmac을 호출하는 것
관련 노트
- gevent의 monkey.patch_all은 파일 IO를 논블로킹으로 만들지 않는다
- gevent는 I∕O 동시성만 늘리므로 적용 전 워크로드가 CPU bound인지 확인해야 한다
- ctranslate2의 GIL 해제와 num_workers는 독립적인 두 메커니즘이다
- 비동기 프레임워크는 head-of-line blocking과 C10K 두 문제를 해결한다