FastAPI Uvicorn 동작 원리 - 100-hours-a-week/5-yeosa-wiki GitHub Wiki

참고 : https://velog.io/@byu0hyun/FastAPI%EC%9D%98-%EB%B9%84%EB%8F%99%EA%B8%B0-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%A3%A8%ED%94%84-%EC%BD%94%EB%A3%A8%ED%8B%B4-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%B2%98%EB%A6%AC#:~:text=aiomysql%29%EC%99%80%20%EB%B9%84%EB%8F%99%EA%B8%B0%20HTTP%20%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8,%EC%82%AC%EC%9A%A9%ED%95%B4%EC%95%BC%20%ED%95%A9%EB%8B%88%EB%8B%A4


0. WSGI vs ASGI: FastAPI의 기반 이해

a. WSGI (Web Server Gateway Interface)

  • 기존 Flask, Django (구버전) 등이 사용했던 동기 기반 Python 웹 서버 인터페이스 표준
  • 요청-응답이 동기적으로 한 번에 처리됨 → 비동기 처리 불가
  • 실시간 통신(WebSocket 등), 높은 I/O 동시성 처리가 어려움

b. ASGI (Asynchronous Server Gateway Interface)

  • 비동기 처리를 지원하는 웹 서버 인터페이스 표준
  • HTTP 요청뿐만 아니라 WebSocket, SSE 등 실시간 양방향 통신 지원
  • async def 기반의 코루틴 처리를 통해 수천 개의 요청을 동시에 처리할 수 있음
  • FastAPI는 이 ASGI 표준을 채택한 대표적인 프레임워크

1. FastAPI의 구조

  • FastAPI는 Python으로 작성된 ASGI 앱이며, 일반적으로 uvicorn이라는 ASGI 서버 위에서 실행
  • uvicorn은 단일 프로세스 안에서 단일 스레드 기반의 asyncio 이벤트 루프를 실행하며, 이 루프가 모든 HTTP 요청 수신, 처리, 응답을 관리
  • uvicorn main:app 명령어로 실행하면, main.py에서 정의된 FastAPI 인스턴스인 app을 가져와 이벤트 루프에 등록하여 요청을 처리

2. FastAPI의 동작 원리

a. 전체 요청 수신 및 처리 흐름

  1. 클라이언트가 http://0.0.0.0:8000/test로 요청을 보냄
  2. OS 커널이 해당 요청을 TCP 레벨에서 수신 → TCP 수신 버퍼에 저장
  3. Uvicorn의 이벤트 루프가 epoll 또는 **select**해 소켓 이벤트를 감지
  4. 소켓에 데이터가 존재하면 uvicorn이 ASGI 인터페이스 (scope, receive, send)를 통해 FastAPI 앱에 요청 전달
  5. FastAPI는 내부적으로 Starlette의 라우터 시스템을 이용해 URL과 메서드에 따라 적절한 핸들러를 찾음
  6. 해당 핸들러가 async def이면 코루틴으로 이벤트 루프에서 직접 실행되고, def이면 별도의 스레드(ThreadPoolExecutor)에서 실행됨
  7. 실행 완료 후 FastAPI가 send() 를 호출, 이벤트 루프가 실제 TCP 응답으로 전송

b. async defdef의 차이

  • async def(코루틴): 이벤트 루프에서 직접 실행되며, await 지점마다 다른 코루틴 작업으로 전환 가능
  • def: 코루틴이 아니기 때문에 루프에서 실행할 수 없음 → 자동으로 ThreadPoolExecutor에 offload됨

3. FastAPI의 장점 (ASGI 기반의 이점)

  • 비동기 프로그래밍을 지원하여, I/O 바운드 작업(DB, 파일, 외부 API 호출 등)을 매우 효율적으로 처리 가능
  • WebSocket, HTTP2, Server-Sent Events 같은 실시간 프로토콜을 지원
  • async def 기반의 코루틴을 통해 수천 개의 요청을 처리할 수 있어 고성능 웹 서버를 구성할 수 있음
  • Starlette 기반으로 구성되어 있어 라우팅, 미들웨어, 요청/응답 모델 유효성 검증이 강력함

4. GIL 환경에서 FastAPI 비동기 작업이 실행되는 방식

a. GIL 환경

  • Python은 GIL(Global Interpreter Lock)이라는 구조 때문에, 한 시점에 하나의 스레드만 Python 바이트코드를 실행할 수 있음
  • 하지만 Python 인터프리터는 일정 주기마다 (기본 5ms, 또는 100 bytecode 실행마다) GIL을 다른 스레드에게 넘김
  • PyTorch, NumPy 등 C 확장 모듈은 내부에서 GIL을 해제하고 연산하므로 병렬 실행이 가능(Python GIL 해제 연산의 병렬 처리 구조)

b. GIL의 작동 방식과 스레드 전환

  • GIL(Global Interpreter Lock)은 CPython 인터프리터에서 한 시점에 하나의 스레드만 Python 바이트코드를 실행하도록 제한한다.
  • 하지만 다음 조건 중 하나를 만족할 경우 GIL은 다른 스레드에게 양도된다:
    • 기본적으로 5ms마다 자동 양도 (또는 100 bytecode 실행 후)
    • I/O 작업이나 sleep처럼 GIL을 자동 해제하는 C 함수 호출 시
    • 명시적으로 GIL을 해제한 확장 모듈(Pytorch, NumPy 등) 내부 실행 시

c. FastAPI의 def, async def 처리 흐름에서 GIL의 영향

실행 유형 실행 위치 GIL 영향 실행 흐름
def 라우터 ThreadPoolExecutor (스레드) GIL 필요. 단, C 연산 시 해제됨 스레드 생성 후 함수 실행. Python 코드만 있을 경우 GIL 점유. C 확장 호출 시 병렬 가능
async def 라우터 asyncio 이벤트 루프 (메인 스레드) GIL 필요 코루틴으로 스케줄링되며 await마다 다른 task로 전환 가능
  • def 함수는 별도의 스레드에서 실행되며, 해당 스레드가 GIL을 점유하지만 일정 주기마다 양도되거나 C 연산 중 해제된다.
  • async def는 이벤트 루프에서 실행되며, await를 만나면 루프는 같은 스레드 내에서 다른 코루틴으로 전환한다 (스레드 간 전환 아님).

d. await 시 실제로 무슨 일이 일어나는가?

@app.get("/compute")
async def compute():
    await asyncio.sleep(1)  # 이 지점에서 다른 코루틴 실행 기회 제공
    return {"result": "ok"}
  • await는 해당 코루틴의 실행을 일시 중단하고 이벤트 루프에 제어권을 반환함
  • 이 시점에 루프는 다른 코루틴(예: 다른 async def 요청 처리)을 실행함
  • 스레드를 전환하는 것이 아니라, 동일한 이벤트 루프 안에서 실행 가능한 코루틴을 스케줄링

예시: PyTorch GIL 해제 동작

def infer():
    return model.encode_image(image_tensor)  # 내부는 C++ 연산 → GIL 영향 X
  • def 라우터에서 실행되면 FastAPI가 ThreadPoolExecutor에 넘기므로 이벤트 루프 점유는 없음
  • encode_image의 내부는 C++연산으로, GIL의 영향을 받지 않아 여러 연산이 동시에 실행될 수 있음

5. CPU Bound Task를 async def로 처리했을 때의 문제점

a. 잘못된 예시

@app.get("/block")
async def block():
    for _ in range(10**9):  # CPU 바운드 연산
        pass
    return {"ok": True}

b. 이때 발생하는 현상

  • 이벤트 루프가 epoll()을 돌리지 못함 → 다른 요청이 들어와도 감지하지 못함
  • TCP 소켓은 커널 버퍼에 도착하지만, Python 애플리케이션이 수신을 못 해서 응답 없음
  • 일정 시간 뒤 클라이언트에서 timeout 발생
  • 대기 요청이 많아지면 TCP 수신 버퍼가 차서 TCP RST로 연결 종료됨

6. FastAPI에서 CPU Bound Task를 효율적으로 실행하는 전략

a. asyncio.to_thread() 사용

@app.get("/infer")
async def infer():
    result = await asyncio.to_thread(model.encode_image, image_tensor)
    return result
  • CPU 바운드 작업을 기본 ThreadPoolExecutor에 offload하면서 이벤트 루프는 자유롭게 유지됨

b. 세마포어를 이용한 병렬 제한

sem = asyncio.Semaphore(4)

@app.get("/infer")
async def infer():
    async with sem:
        result = await asyncio.to_thread(model.encode_image, image_tensor)
    return result
  • 동시에 실행될 수 있는 CPU 작업의 개수를 제한하여 ThreadPoolExecutor의 과도한 스레드 점유를 방지
    • Python의 스레드는 많아질수록 컨텍스트 스위칭 비용, 메모리 소비, GIL 경쟁이 증가하게 된다.
    • 특히 CPU 바운드 연산이 많을 경우, 스레드 수가 많다고 해서 성능이 선형적으로 향상되지 않으며, 오히려 스케줄링 비용으로 서버 전체 응답 성능이 저하될 수 있다.
    • 따라서 세마포어를 사용해 동시 실행 수를 제한함으로써 서버 자원 소비를 예측 가능하게 유지하고, 과도한 부하로부터 안정적인 운영을 도모할 수 있다.

c. ThreadPoolExecutor 커스터마이징

from concurrent.futures import ThreadPoolExecutor
loop = asyncio.get_event_loop()
loop.set_default_executor(ThreadPoolExecutor(max_workers=8))
  • 전역으로 스레드풀의 크기를 조정해 자원 사용량을 통제

d. 적절한 병렬성 파라미터 조정 전략

가. 서버 CPU 코어 수 확인

  • os.cpu_count()로 사용 가능한 논리 코어 수를 확인
  • 일반적으로 병렬 처리 스레드 수는 이 수를 초과하지 않는 것이 좋음
  • 단, PyTorch와 같은 내부 병렬 연산 라이브러리가 있다면 내부 스레드 수까지 고려해야 함

나. PyTorch 내부 병렬성 조절

  • PyTorch는 연산마다 내부적으로 멀티스레드를 사용할 수 있으므로, torch.set_num_threads(n)를 통해 조절해야 한다
  • FastAPI의 외부 병렬성과 PyTorch 내부 병렬성이 충돌하지 않도록 주의해야 한다
    • 예: ThreadPoolExecutor(max_workers=8) + torch.set_num_threads(4)는 최악의 경우 8개의 요청이 동시에 처리되며 각 요청당 4개의 내부 스레드를 사용 → 총 32개 이상의 스레드가 동시에 돌게 되어 시스템 컨텍스트 스위칭 오버헤드 증가
  • 스레드 수를 어디서 제한할 것인가?
    • 일반적으로는 ThreadPoolExecutor 또는 세마포어를 통해 외부 병렬도를 우선 제한하고, PyTorch의 내부 스레드는 1~2로 낮추는 것이 안정적이다
      • 이유: FastAPI는 다수의 요청을 짧게 처리하는 서버 성격이므로, 짧고 많은 외부 요청을 빠르게 처리하는 데 적합한 구조가 필요함
    • 반대로, 요청 수가 매우 적고 한 요청당 연산량이 매우 크다면 PyTorch의 내부 스레드를 높게 설정하는 것이 유리할 수 있다 (ex. 배치 인퍼런스 서버)
  • 그럼 어디서 결정해야 할까?
    • 요청당 연산 시간, TPS, 서버 CPU 수, 평균 동시 요청 수 등을 기준으로 조절해야 한다
    • 예시 지표 기준:
      • CPU 코어 수: 8
      • TPS: 10, 요청당 처리 시간: 0.5s → 동시 처리량 5
      • PyTorch 연산이 무거우면 torch.set_num_threads(2) + Semaphore(4)
      • PyTorch 연산이 가볍고 TPS가 높으면 torch.set_num_threads(1) + Semaphore(8)
    • 또한 torch.get_num_threads(), psutil.Process().num_threads() 같은 수치를 통해 실시간 스레드 사용량을 추적해 조정 가능
  • PyTorch처럼 GIL을 해제하는 C 확장 모듈이 아닌, 일반 Python 작업이라면 Uvicorn에서 스레드가 많아질수록 CPU 사용량도 늘어나는가?
    • 답: 보통은 '예', 하지만 ‘비례적 증가’는 아니다.
      • 순수 Python 작업은 GIL의 영향을 받는다

        • Python에서 def 함수는 ThreadPoolExecutor를 통해 스레드풀에서 실행
        • 하지만 이 작업이 Python 바이트코드만 수행한다면 GIL을 점유한 스레드만 실행 가능
        • 여러 스레드가 있어도 한 번에 하나의 스레드만 실행 가능 (동시에는 안 됨)
      • 그럼에도 불구하고 CPU 사용량은 올라간다

        • 이유: 모든 스레드가 GIL을 얻기 위해 경쟁하고, 계속 깨어 있으며, 컨텍스트 스위칭이 계속 발생하기 때문
        • 예를 들어, 스레드 A가 GIL을 얻어 실행되고 B, C, D가 대기하면서 CPU를 사용하지는 않지만 일정 시간마다 스레드 전환(GIL 양도)이 일어나며, 이 과정이 CPU 자원을 소모함
      • 즉, GIL이 있음에도 스레드 수가 늘어나면 CPU 사용률이 “일정 수준까지는” 올라간다

        • 하지만 스레드 수만큼 병렬 처리되진 않기 때문에 CPU 전체 코어를 100%로 꽉 채우는 일은 PyTorch 같은 C 연산만큼 극적이지 않음
      • 비교: GIL 없는 작업과 있는 작업

        작업 종류 GIL 해제됨 병렬 실행 CPU 점유 증가
        PyTorch/Numpy 연산 ✅ (코어 수만큼)
        순수 Python for-loop ⚠️ (스레드 수 증가에 따라 일부 상승)
        asyncio I/O 대기 작업 ✅ (실행 안 됨) ❌ (거의 없음)
    • 결론
      • 순수 Python 연산을 하는 작업이 Uvicorn 스레드풀에 많아지면, 스레드는 GIL 때문에 실제 병렬 실행은 못 해도, 컨텍스트 스위칭과 GIL 경쟁으로 인해 CPU 사용률은 증가한다.
        • 다만 그 증가폭은 PyTorch처럼 C 코드 병렬 실행되는 경우보다 훨씬 낮다.

다. 병렬 작업 처리 시간과 TPS 분석

  • 한 요청당 평균 처리 시간(t)과 예상되는 초당 요청량(TPS, r)을 기준으로 동시 처리 수 ≒ t × r
  • 예: 요청당 평균 1.2초, TPS가 5면 → 약 6~7개의 동시 작업 슬롯 필요
  • 이를 기준으로 세마포어 수(Semaphore(n))를 조정하며 부하를 제어할 수 있음

라. 부하 테스트를 통한 실측 최적화

  • 적절한 세마포어 수나 병렬성 설정을 찾기 위해, 동시 요청 수를 하나씩 늘려가며 부하 테스트를 실행하는 것이 중요하다. 이때 다음과 같은 지표와 현상을 관찰해야 한다:
  • 관찰할 주요 지표
    • 평균 응답 시간: 증가하는 지점이 병목 발생 시점
    • p95, p99 응답 지연: tail latency 증가 여부 확인
    • 오류율: HTTP 5xx 오류, 타임아웃 발생 여부
    • CPU 사용률: 100%에 근접할 경우 스레드 경쟁 과다
    • 시스템 스레드 수 및 컨텍스트 스위칭 횟수: psutil 또는 top/htop으로 확인
  • '적절한 세마포어 수' 판단 기준
    • 성능 지표가 선형으로 증가하다가 꺾이는 지점
    • 응답 시간 증가 없이 최대 TPS를 유지할 수 있는 지점
    • CPU 사용률이 100% 가까워지기 직전
    • 오류율 없이 처리 가능한 최대 동시 요청 수
  • 테스트 시나리오 예시
    1. 세마포어 수를 1부터 시작하여 2씩 증가 (예: 1 → 3 → 5 → 7 ...)
    2. 각 설정마다 30~60초 동안 지속 부하를 주고 위 지표들을 측정
    3. p95 응답 시간 급증 또는 오류율이 1% 이상 되는 순간 이전 값으로 되돌림
    4. 최종적으로 "성능 효율 vs 안정성" 균형점에서 고정
    5. 이러한 방식으로 설정된 세마포어 수는 특정 머신 사양, 모델 크기, 요청 부하에 따라 최적화된 병렬 처리 수가 된다.
    • k6, Locust 같은 부하 테스트 도구를 이용해 다양한 설정으로 요청을 시뮬레이션
      • 주요 관측 지표:
        • 평균 응답 시간, p95/p99 응답 지연
        • 오류율 (타임아웃 또는 연결 끊김)
        • CPU 사용률 및 시스템 컨텍스트 스위칭
    • 테스트 결과를 기반으로 병렬도 수치를 증가/감소시키며 안정성과 처리량의 균형점 찾기(os.cpu_count())
    • PyTorch의 내부 쓰레드 수 조절 (torch.set_num_threads(n)) → 과도한 내부 병렬성 방지
    • 평균 요청 처리 시간과 초당 요청량(TPS)을 기준으로 세마포어 수 조정
    • 실제 트래픽 시뮬레이션(k6, Locust 등)으로 최적 병렬성 검증

7. 그 외 핵심 개념 요약

a. 이벤트 루프 점유 여부 비교

상황 이벤트 루프 점유 여부
def 라우터 ❌ (스레드에서 실행)
async def 내부에서 동기 함수 직접 실행 ✅ (루프 블로킹됨)
async def 내부에서 await to_thread() ❌ (스레드에서 실행)

b. 이벤트 루프 점유 시의 실제 현상

  • 요청이 도착해도 루프가 epoll을 돌리지 못해서 요청 수신 자체가 지연됨
  • 응답 없는 서버처럼 보이며 timeout 발생

c. to_thread()의 장점

  • GIL이 없는 연산(C 코드)와 결합 시 매우 효과적
  • 이벤트 루프를 유지하며 고성능 처리 가능
  • 세마포어 등으로 병렬성 제어까지 가능

8. 요약

  • FastAPI에서 CPU 바운드 연산은 반드시 이벤트 루프 외부에서 처리해야 하며,
  • 가장 실용적이고 안전한 기본 전략은 await asyncio.to_thread(...)
  • 필요 시 Semaphore, 커스텀 ThreadPoolExecutor, 또는 multiprocessing 전략을 함께 도입하면,
  • 고동시성 + 고부하 상황에서도 안정적인 응답성을 확보할 수 있다.
  • 이러한 전략을 통해 FastAPI는 Python의 GIL 제약 속에서도 훌륭한 성능을 내는 서버 프레임워크가 된다.