FastAPI 동기/비동기 동작 방식 분해해보기
개요
회사에서 FastAPI 프레임워크로 개발해서 서비스 런칭하는 일이 잦아지면서 백엔드와 FastAPI 사용방법에 대한 지식이 부족하다는 것을 요즘 느끼고있다. 디테일하게 공부하고자하는 마음에 여러가지 실험한 결과에 대해 오랜만에 블로그에 포스팅하고자한다.
이론으로는 알지만 게을러서 실험해보지 못했던 FastAPI 동기/비동기 동작 방식 분해해보기 먼저 작성해보려고한다.
우선 FastAPI 동기/비동기 동작 방식을 알아보기 전에 동기식 웹 방식 vs 비동기식 웹 방식의 기본적인 배경 지식부터 알아보고 가자
동기식 (sync) 웹 방식
전통적인 WSGI (Web Server Gateway Interface) 프레임워크에서 1개의 요청당 1개의 스레드 (Worker)를 생성하여 처리하는 방식이었다.
비동기식 (async) 웹 방식
비동기 웹 방식은 싱글 스레드에 여러개의 task가 코루틴(concurrent) 으로 동시적으로 생성하고 동작한다.
FastAPI 동작방식
FastAPI 비동기 프레임워크로 웹 서버를 기동하기 위해서는 ASGI (Async Server Gateway Interface)가 필요하다.
uvicorn 패키지를 기본적으로 제공하는데 그 외에 hypercorn, daphne 등을 활용하여 대체 가능하다.
전통적인 방식의 WSGI(Web Server Gateway Interface)와 같이 1개의 요청당 1개의 Thread를 생성하여 기동하는 웹 서버 방식과 다르게 uvicorn은 하나의 Thread에서 여러 코루틴 이벤트를 동시적으로 생성되고 동작한다.
FastAPI 동기 vs 비동기
FastAPI 공식문서의 동시성 async/await 페이지를 읽어보면 아래와 같이 언급되어있다.
동기 (sync), 비동기 (async) 두가지 방식을 다 지원하고 있으며 방식에 따라 동작 방식이 달라진다고한다.
만약 당신의 응용프로그램이 (어째서인지) 다른 무엇과 의사소통하고 그것이 응답하기를 기다릴 필요가 없다면 async def를 사용하십시오. 모르겠다면, 그냥 def를 사용하십시오.
[매우 세부적인 기술적 사항] 경로 작동 함수를 async def 대신 일반적인 def로 선언하는 경우, (서버를 차단하는 것처럼) 그것을 직접 호출하는 대신 대기중인 외부 스레드풀에서 실행됩니다.
이 말은 비동기 프레임워크라고해서 동기 처리를 못하는 것은 아니다. 단, 권장하는 비동기(async)을 사용할때 FastAPI 이점을 최대한 활용하는 방식이기때문에 추천하지 않을뿐이다. 그리고 애매하면 async를 사용하지 말라는 말까지 적혀있다. 그만큼 비동기에 대한 지식이 어느 정도 필요하다는 말이다.
문득 궁금한 생각이 들어 아래와 같은 의문점을 해결하기 위하여 실험을 해보았다.
실험 목표
- 과연 동기 라우터일 경우는 멀티 스레드이고 비동기 라우터일 경우는 단일 스레드로 동작할까?
- 비동기 라우터에서 동기처리 함수가 있으면 blocking이 될까?
시나리오
- 동기/비동기 라우터 각각 1개씩 생성
- 동시에 각 라우터에 10번씩 동시요청
- 스레드 ID를 출력하여 단일/멀티 스레드인지 확인
- 각 라우터별로 5번째 요청일시에는 동기 sleep 함수 추가
- 비동기 라우터에서 동기처리 함수가 있을시 blocking 되는지 확인
실험 코드
1. FastAPI 기동
main.py를 작성하여 다음과 같이 동기/비동기 라우터 한개씩 만들어보자
import time
import threading
import uvicorn
from fastapi.responses import JSONResponse
from fastapi import FastAPI
import datetime
sync_cnt = 0
async_cnt = 0
app = FastAPI()
@app.get(path="/sync")
def sync_example():
global sync_cnt
sync_cnt += 1
thread_id = threading.get_ident()
request_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(">>> sync_cnt: {}, request_time: {}".format(sync_cnt, request_time))
if sync_cnt == 5:
time.sleep(20)
return JSONResponse({"message": "sync example (sleep)", "thread_id": thread_id, "request_time": request_time})
return JSONResponse({"message": "sync example", "thread_id": thread_id, "request_time": request_time})
@app.get(path="/async")
async def async_example():
global async_cnt
async_cnt += 1
thread_id = threading.get_ident()
request_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(">>> async_cnt: {}, request_time:{}".format(async_cnt, request_time))
if async_cnt == 5:
time.sleep(20)
return JSONResponse({"message": "async example (sleep)", "thread_id": thread_id, "request_time": request_time})
return JSONResponse({"message": "async example", "thread_id": thread_id, "request_time": request_time})
if __name__ == "__main__":
uvicorn.run("main:app", port=8000)
그 다음에 FastAPI를 uvicorn을 활용하여 다음과 같이 기동을 한다.
uvicorn main:app --port=8000
2. Client 테스트 코드 작성
Client 테스트 코드를 다음과 같이 작성하고 호출해보자
동기 라우터 호출
import asyncio
import httpx
async with httpx.AsyncClient() as client:
tasks = []
for _ in range(10):
task = client.get("http://localhost:8000/sync", timeout=60)
tasks.append(task)
responses = await asyncio.gather(*tasks)
for response in responses:
print(response.json())
비동기 라우터 호출
import asyncio
import httpx
async with httpx.AsyncClient() as client:
tasks = []
for _ in range(10):
task = client.get("http://localhost:8000/async", timeout=60)
tasks.append(task)
responses = await asyncio.gather(*tasks)
for response in responses:
print(response.json())
실험 결과
동기 라우터일 경우
response 메세지에서 thread_id를 확인해보면 요청당 새로운 스레드가 생성된다는 것을 알 수 있으며
request_time을 보면 멀티 스레드로 동작하기때문에 blocking 되지 않고 요청을 받을 수 있는 것을 확인할 수 있다.
{'message': 'sync example', 'thread_id': 22332, 'request_time': '2024-04-05 16:35:25'}
{'message': 'sync example', 'thread_id': 19976, 'request_time': '2024-04-05 16:35:25'}
{'message': 'sync example', 'thread_id': 18280, 'request_time': '2024-04-05 16:35:25'}
{'message': 'sync example', 'thread_id': 18836, 'request_time': '2024-04-05 16:35:25'}
{'message': 'sync example', 'thread_id': 17344, 'request_time': '2024-04-05 16:35:25'}
{'message': 'sync example', 'thread_id': 19976, 'request_time': '2024-04-05 16:35:25'}
{'message': 'sync example', 'thread_id': 22332, 'request_time': '2024-04-05 16:35:25'}
{'message': 'sync example', 'thread_id': 17344, 'request_time': '2024-04-05 16:35:25'}
{'message': 'sync example', 'thread_id': 26336, 'request_time': '2024-04-05 16:35:25'}
{'message': 'sync example', 'thread_id': 31532, 'request_time': '2024-04-05 16:35:25'}
FastAPI 프레임워크에서 출력한 메세지이다. request_time을 보면 blocking 없이 동일한 시간에 요청을 받았다는 것을 확인할 수 있다.
>>> sync_cnt: 1, request_time: 2024-04-05 16:35:25
>>> sync_cnt: 2, request_time: 2024-04-05 16:35:25
>>> sync_cnt: 3, request_time: 2024-04-05 16:35:25
>>> sync_cnt: 4, request_time: 2024-04-05 16:35:25
>>> sync_cnt: 5, request_time: 2024-04-05 16:35:25
>>> sync_cnt: 7, request_time: 2024-04-05 16:35:25
>>> sync_cnt: 8, request_time: 2024-04-05 16:35:25
>>> sync_cnt: 9, request_time: 2024-04-05 16:35:25
>>> sync_cnt: 10, request_time: 2024-04-05 16:35:25
>>> sync_cnt: 6, request_time: 2024-04-05 16:35:25
INFO: 127.0.0.1:60435 - "GET /sync HTTP/1.1" 200 OK
INFO: 127.0.0.1:60442 - "GET /sync HTTP/1.1" 200 OK
INFO: 127.0.0.1:60434 - "GET /sync HTTP/1.1" 200 OK
INFO: 127.0.0.1:60440 - "GET /sync HTTP/1.1" 200 OK
INFO: 127.0.0.1:60436 - "GET /sync HTTP/1.1" 200 OK
INFO: 127.0.0.1:60437 - "GET /sync HTTP/1.1" 200 OK
INFO: 127.0.0.1:60439 - "GET /sync HTTP/1.1" 200 OK
INFO: 127.0.0.1:60441 - "GET /sync HTTP/1.1" 200 OK
INFO: 127.0.0.1:60443 - "GET /sync HTTP/1.1" 200 OK
INFO: 127.0.0.1:60438 - "GET /sync HTTP/1.1" 200 OK
비동기 라우터일 경우
response 메세지에서 thread_id를 확인해보면 요청 전부가 단일 스레드에서 처리된다는 것을 알 수 있다.
{'message': 'async example', 'thread_id': 36764, 'request_time': '2024-04-05 16:36:36'}
{'message': 'async example', 'thread_id': 36764, 'request_time': '2024-04-05 16:36:36'}
{'message': 'async example', 'thread_id': 36764, 'request_time': '2024-04-05 16:36:16'}
{'message': 'async example', 'thread_id': 36764, 'request_time': '2024-04-05 16:36:36'}
{'message': 'async example', 'thread_id': 36764, 'request_time': '2024-04-05 16:36:16'}
{'message': 'async example (sleep)', 'thread_id': 36764, 'request_time': '2024-04-05 16:36:16'}
{'message': 'async example', 'thread_id': 36764, 'request_time': '2024-04-05 16:36:16'}
{'message': 'async example', 'thread_id': 36764, 'request_time': '2024-04-05 16:36:36'}
{'message': 'async example', 'thread_id': 36764, 'request_time': '2024-04-05 16:36:36'}
{'message': 'async example', 'thread_id': 36764, 'request_time': '2024-04-05 16:36:16'}
request_time을 보면 sleep처리를 한 5번째 요청에서 blocking이 되어서 뒤의 요청을 받지 못하여 기다리는 상태가 되었다.
5번째 request: 16:36:16
6번째 request: 16:36:36
6번째 요청부터 20초간 blocking이 발생하여 그 뒤의 요청 전부를 받지 못하였다.
>>> async_cnt: 1, request_time:2024-04-05 16:36:16
INFO: 127.0.0.1:60468 - "GET /async HTTP/1.1" 200 OK
>>> async_cnt: 2, request_time:2024-04-05 16:36:16
INFO: 127.0.0.1:60472 - "GET /async HTTP/1.1" 200 OK
>>> async_cnt: 3, request_time:2024-04-05 16:36:16
INFO: 127.0.0.1:60466 - "GET /async HTTP/1.1" 200 OK
>>> async_cnt: 4, request_time:2024-04-05 16:36:16
INFO: 127.0.0.1:60467 - "GET /async HTTP/1.1" 200 OK
>>> async_cnt: 5, request_time:2024-04-05 16:36:16
INFO: 127.0.0.1:60471 - "GET /async HTTP/1.1" 200 OK
>>> async_cnt: 6, request_time:2024-04-05 16:36:36
INFO: 127.0.0.1:60473 - "GET /async HTTP/1.1" 200 OK
>>> async_cnt: 7, request_time:2024-04-05 16:36:36
INFO: 127.0.0.1:60469 - "GET /async HTTP/1.1" 200 OK
>>> async_cnt: 8, request_time:2024-04-05 16:36:36
INFO: 127.0.0.1:60464 - "GET /async HTTP/1.1" 200 OK
>>> async_cnt: 9, request_time:2024-04-05 16:36:36
INFO: 127.0.0.1:60470 - "GET /async HTTP/1.1" 200 OK
>>> async_cnt: 10, request_time:2024-04-05 16:36:36
INFO: 127.0.0.1:60465 - "GET /async HTTP/1.1" 200 OK
결론
- FastAPI 동기 라우터일 경우 요청당 새로운 스레드를 생성하여 처리한다.
- FastAPI 비동기 라우터일 경우 단일 스레드이며 코루틴으로 처리한다.
- 비동기 라우터 안의 동기 함수처리가 포함되어 있을 경우 현재 요청이 끝날때까지 Blocking 처리된다.
옛말에 "잘쓰면 약 못쓰면 독"이라는 말처럼 FastAPI 비동기 라우터에서는 세심한 주의를 기울여야한다.
프로세스상 requests, sleep 같은 동기 함수 처리나 패키지가 있는지 살펴봐야할 것이며 이로 인하여 request 요청을 받지 못하거나 딜레이되는 치명적인 실수를 범할 수 있다.