요약
monkey.patch_all()이 바꾸는 것은 소켓·DNS·시간·select·스레드·os(fork/waitpid)·ssl·subprocess·signal·queue·contextvars다. 일반 파일 읽고 쓰기는 목록에 없다.- 그래서 파일을 읽는 동안 허브로 제어권이 넘어가지 않고, 그 워커의 모든 greenlet이 함께 멈춘다. 스레드 워커였다면 한 스레드만 느려지고 끝날 일이 gevent에서는 전면 정지가 된다.
- gevent 자신의 해법도 "논블로킹으로 만든다"가 아니라 "네이티브 스레드풀로 넘긴다"이다(
FileObjectThread).
본문
무엇이 패치되는가 (소스 확인, gevent 25.9.1)
def patch_all(socket=True, dns=True, time=True, select=True, thread=True, os=True,
ssl=True, subprocess=True, sys=False, aggressive=True, Event=True,
builtins=True, signal=True, queue=True, contextvars=True, **kwargs):
os=True도 파일 접근과는 무관하다. patch_os()는 os.fork와 os.waitpid를 교체할 뿐이다.
왜 파일은 못 바꾸나
소켓은 논블로킹 모드로 두고 readiness를 이벤트 루프가 감시할 수 있지만, 일반 파일 디스크립터는 그런 식으로 다루기 어렵다. gevent의 gevent.fileobject 문서가 이를 그대로 드러낸다 — FileObjectThread는 "uses the built-in native threadpool to avoid blocking the entire interpreter"라고 설명한다. 논블로킹으로 만드는 게 아니라 다른 스레드로 밀어내는 방식이다.
어디서 물리는가
파일 읽기가 요청 경로에 없다고 생각하기 쉬운데, 실제로는 곳곳에 숨어 있다. 직접 관측한 사례:
- 패키지 메타데이터 조회 — APM 에이전트가 버전을 알아내려고 설치 배포판의 METADATA를 읽는다. 배포판 242개 환경에서 한 번에 184ms.
- 지연 임포트 — 처음 타는 코드 경로가 모듈을 임포트하면서 파일을 읽는다.
- URLconf 지연 구축, 번역 카탈로그 로딩 같은 프레임워크 초기화.
이들은 각각은 짧아도 연달아 일어나면 그 구간 전체가 안 끊긴다. 허브 블로킹 감지 기준이 "일정 시간 동안 greenlet 스위치 없음"이므로, 작은 파일 읽기 수십 개가 하나의 긴 정지로 잡힌다.
대응
- 요청 경로에서 파일 읽기를 걷어낸다. 기동 시 미리 읽어 캐싱하는 게 가장 확실하다.
- 불가피하면
gevent.get_hub().threadpool.apply(...)로 넘긴다. 다만 파이썬 코드가 섞여 있으면 GIL 때문에 효과가 반감된다 — C 확장이 GIL을 놓는 경우에만 온전히 통한다. FileObject/FileObjectThread를 쓴다.
검증 상태
- 소스 확인:
patch_all시그니처,patch_osdocstring,fileobject.pydocstring (gevent 25.9.1) - 관측: 위 사례 세 가지는 프로덕션 카나리에서 허브 블로킹 스택으로 직접 확인
관련 노트
- New Relic 파이썬 에이전트의 패키지 목록 수집이 gevent 허브를 harvest마다 최대 2초 막는다
- GIL을 놓는 C 확장 함수는 gevent threadpool로 넘기면 허브 정지가 사라진다
- gevent는 I∕O 동시성만 늘리므로 적용 전 워크로드가 CPU bound인지 확인해야 한다
- 비동기 프레임워크는 head-of-line blocking과 C10K 두 문제를 해결한다