Zettelkasten

gevent의 monkey.patch_all은 파일 IO를 논블로킹으로 만들지 않는다

·수정 1회

요약

  • 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 스위치 없음"이므로, 작은 파일 읽기 수십 개가 하나의 긴 정지로 잡힌다.

대응

  1. 요청 경로에서 파일 읽기를 걷어낸다. 기동 시 미리 읽어 캐싱하는 게 가장 확실하다.
  2. 불가피하면 gevent.get_hub().threadpool.apply(...)로 넘긴다. 다만 파이썬 코드가 섞여 있으면 GIL 때문에 효과가 반감된다 — C 확장이 GIL을 놓는 경우에만 온전히 통한다.
  3. FileObject/FileObjectThread를 쓴다.

검증 상태

  • 소스 확인: patch_all 시그니처, patch_os docstring, fileobject.py docstring (gevent 25.9.1)
  • 관측: 위 사례 세 가지는 프로덕션 카나리에서 허브 블로킹 스택으로 직접 확인

관련 노트

참고