Changing def to async def let one blocking call freeze an entire FastAPI app
def를 async def로 바꿨더니 블로킹 호출 하나가 FastAPI 앱 전체를 멈췄습니다
FastAPI에서 같은 동기식 HTTP 호출도 일반 def 엔드포인트에서는 스레드 풀로 넘어가지만 async def 안에서는 이벤트 루프를 막습니다. 글쓴이는 부하 실험으로 스레드 풀의 기본 한도 40과 CPU 작업에서 asyncio.to_thread가 제한적으로만 효과를 내는 이유를 측정하고, 프로세스 풀과 스택 추적 워치독을 비교합니다.
- 주제
에디터 노트
일반 def가 동기 호출을 스레드 풀로 보내 이벤트 루프를 살린다는 측정은 명확합니다. 다만 40개라는 경계는 엔드포인트 본문만이 아니라 동기식 의존성도 스레드 풀을 쓴다는 댓글의 질문과 함께 읽어야 합니다. 글에서는 의존성을 포함한 실제 요청 경로를 따로 측정하지 않았으므로, 40은 기본 한도이지 모든 앱의 동시 요청 기준은 아닙니다. CPU 작업에서는 스레드로 옮기는 것만으로 경쟁이 사라지지 않았고, 프로세스 풀에서 응답성과 처리량이 함께 나아졌습니다. 이 결과는 테스트한 Python 빌드와 직렬화 작업에 한정되며 free-threaded 빌드는 측정하지 않았습니다. 댓글의 비동기 호출 중단 질문에도 본문은 취소나 타임아웃 동작을 다루지 않아 답을 제공하지 않습니다.
AI 요약
FastAPI 엔드포인트에서 동기식 호출을 처리하는 위치에 따라 서버 응답이 크게 달라집니다. 글쓴이는 Python 3.14.4, FastAPI 0.143.0, Starlette 1.7.0, Uvicorn 0.54.0 환경에서 업스트림 요청이 1초 걸리는 상황을 만들고, 느린 엔드포인트에 동시 요청 20개를 보내는 동안 간단한 /fast 엔드포인트의 지연을 측정했습니다. 각 조건은 15초씩 세 차례 실행했습니다.
동기 호출은 엔드포인트 선언 방식에 따라 다르게 동작합니다
기준 상태에서 /fast의 중앙값은 1.6ms였습니다. async def 엔드포인트에서 httpx.AsyncClient를 쓰거나 일반 def 엔드포인트에서 requests.get을 쓰는 경우에도 1.6ms를 유지했고, 15초 동안 느린 요청을 각각 320건 처리했습니다. 반면 async def 안에서 같은 requests.get을 호출하면 /fast 중앙값이 34,130ms로 늘었고 느린 요청은 35건에 그쳤습니다. 부하 측정 중 /fast는 한 번만 응답했으며, 그 응답에 34초가 걸렸습니다.
async def 안의 동기식 requests.get은 이벤트 루프를 실행하는 스레드를 응답이 올 때까지 막습니다. 그동안 다른 요청도 진행하지 못해 서버가 순차 처리처럼 동작합니다. 일반 def 엔드포인트는 FastAPI가 AnyIO 스레드 풀에서 실행하므로, 해당 스레드가 대기해도 이벤트 루프는 다른 요청을 처리합니다. 따라서 동기 라이브러리를 그대로 둔 채 def를 async def로 바꾸면 성능이 나빠질 수 있다고 설명합니다.
스레드 풀 기본 한도는 40입니다
일반 def 엔드포인트도 느린 동기 요청이 많아 스레드 풀을 모두 점유하면 대기열이 생깁니다. AnyIO 기본 스레드 풀 한도는 40입니다. 느린 요청이 20개 또는 39개일 때 간단한 일반 def 엔드포인트의 중앙 응답 시간은 1.9ms였지만, 40개에서는 890ms로 늘었습니다. 60개에서는 953ms, 100개에서는 1,955ms였습니다. 40개 경계에서 실행 세 번 중 두 번은 중앙값이 890ms였고, 한 번은 중앙값 11.9ms와 최악 914ms를 기록했습니다. 스레드가 풀리는 순간과 측정 요청이 도착하는 시점에 따라 결과가 달라졌습니다.
async def 엔드포인트는 이 스레드 풀을 사용하지 않아 느린 일반 def 요청이 100개 진행 중이어도 /fast 응답은 1.6ms였습니다. 글쓴이는 앱 시작 시 lifespan에서 total_tokens를 200으로 늘리는 방법도 제시합니다. 이 설정에서 느린 요청 100개가 진행돼도 간단한 엔드포인트는 1.8ms를 유지했고, 1초짜리 동시 요청 150개는 1.28초에 모두 끝났습니다. 스레드 수를 늘리면 메모리 사용량이 늘 수 있다고 덧붙입니다.
CPU 작업에는 스레드 이동만으로 부족할 수 있습니다
글쓴이는 30만 행 목록을 JSON으로 직렬화하는 작업도 측정했습니다. 작업 자체는 약 117ms였지만, 동시 요청 하나만으로 다른 엔드포인트 지연이 크게 늘었고 네 개가 동시에 직렬화하면 /fast 중앙 지연은 427~435ms, p99는 891~932ms였습니다. 이벤트 루프 안에서 json.dumps를 실행하는 경우 /fast 중앙 지연은 세 차례 측정에서 393~397ms였고 JSON 요청은 120건 처리됐습니다.
asyncio.to_thread를 적용하자 중앙 지연은 292~315ms로 약 4분의 1 줄었지만, 처리 요청은 112건으로 오히려 적었습니다. 글쓴이는 이 Python 빌드에 GIL이 있어 파이썬 객체 직렬화가 스레드 사이에서 같은 인터프리터 잠금을 두고 경쟁하기 때문이라고 설명합니다. 프로세스 풀은 별도 인터프리터와 GIL을 사용해 /fast 지연을 1.6ms로 낮췄고, JSON 요청은 365건 처리했습니다. 측정 전 워커 프로세스를 준비했으며 첫 풀 호출에는 워커 시작에 약 0.5초가 걸렸습니다. free-threaded Python에서는 결과가 달라질 수 있지만 이 실험에서는 확인하지 않았습니다.
이벤트 루프 정지 위치를 찾는 방법
asyncio 디버그 모드는 느린 콜백과 정지 시간을 경고하지만, 어느 엔드포인트나 코드 줄이 원인인지 알려주지는 않았습니다. 글쓴이는 이벤트 루프에서 50ms마다 갱신하는 하트비트를 두고, 별도 스레드가 갱신 중단을 감시하는 워치독을 작성했습니다. 정지가 250ms를 넘으면 메인 스레드 스택을 읽어 애플리케이션 코드의 파일과 줄을 출력합니다. 실제 경고는 async_blocking 엔드포인트의 requests.get 호출을 가리켰습니다. 117ms 직렬화 정지는 임계값보다 짧아 보고되지 않았으므로, 임계값은 관찰하려는 지연에 맞춰야 한다고 설명합니다.
dev.to 반응
- @mercerk17 — 40개 스레드 한도에 무엇이 포함되는지 궁금합니다. FastAPI 문서에 따르면 일반 def 의존성도 외부 스레드 풀에서 실행됩니다. 일반 def 엔드포인트에 일반 def 의존성이 몇 개 있으면 한 번에 하나씩 실행되더라도 전체 수명 동안 여러 토큰을 사용할 수 있지 않을까요? 의존성도 한도에 포함된다면 실제 앱에서는 동시 요청 40개보다 훨씬 적은 요청으로도 경계에 도달할 수 있으니 표에 관련 측정이 있으면 좋겠습니다.
- @wrobeltomasz — FastAPI 엔드포인트에서 비동기 호출이 멈추면 어떤 대체 동작이 실행되나요?
원문: dev.to / 번역·요약: Trawling