python / / 2024. 8. 30. 08:03

[fastapi] sync와 async 동작방식 이해하기

FastAPI로 웹 애플리케이션을 개발할 때, 가장 중요한 결정 중 하나는 동기(synchronous) 코드와 비동기(asynchronous) 코드 중 무엇을 사용할지 선택하는 것이다. 이 선택은 애플리케이션의 성능과 확장성에 큰 영향을 미칠 수 있다. FastAPI에서 Sync와 Async 코드의 차이점을 알아보고, 실행 시간을 측정하는 방법과 이를 사용하는 예제를 살펴보자.


FastAPI에서 sync와 async

동기(sync)

동기 코드는 작업을 하나씩 순차적으로 실행한다. 각 작업은 다음 작업이 실행되기 전까지 완료될 때까지 대기한다. 웹 애플리케이션의 경우, 이는 각 요청이 완전히 처리될 때까지 다음 요청 처리가 차단됨을 의미한다. 이 방식은 이해하기 쉽지만, 특히 I/O 작업에서 성능 병목 현상을 초래할 수 있다.

python은 기본적으로 싱글 쓰레드로 동작하므로 동기코드로 작성하면 모든 요청이 순차적으로 실행된다. 즉, fastapi의 특정 api에 2건의 http 요청이 오면 1번째 요청이 끝나고 나면 2번째 요청이 실행된다.

비동기(async)

비동기 코드는 비차단(non-blocking) 작업을 허용한다. 작업이 외부 작업(예: I/O)의 완료를 기다리는 동안, 다른 작업을 진행할 수 있다. 이를 통해 요청을 동시에 처리할 수 있어 I/O 바운드 작업에 적합합니다.

async/await를 활용하면 await 코드가 실행되는 동안 계속 대기하는 것이 아니라 중지(pause)되고 다른 요청을 우선 처리를 한다. 이는 새로운 쓰레드가 생겨서 실행되는 형태는 아니고 동일한 메인 쓰레드에서 실행을 하고 이벤트 루프를 통해서 주기적으로 체크를 한다고 한다. (즉, 위에서 중지된 응답이 왔는지 체크)


실행 시간 측정: Sync vs. Async

FastAPI에서 sync와 async 코드의 성능 차이를 이해하기 위해, sync와 async 라우트를 모두 포함하는 샘플 FastAPI 애플리케이션을 살펴보자. 아래 데코레이터를 사용해 이러한 라우트의 실행 시간을 측정할 것이다.

데코레이터

def elapsed_time(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.perf_counter()
        result = func(*args, **kwargs)
        end_time = time.perf_counter()
        print("Sync execution time:", (end_time - start_time))
        return result
    return wrapper

def elapsed_time_async(func):
    @wraps(func)
    async def wrapper(*args, **kwargs):
        start_time = time.perf_counter()
        result = await func(*args, **kwargs)
        end_time = time.perf_counter()
        print("Async execution time:", (end_time - start_time))
        return result
    return wrapper
  • elapsed_time: 동기 함수의 실행 시간을 측정합니다.
  • elapsed_time_async: 비동기 함수의 실행 시간을 측정합니다.

라우트

app = FastAPI()

@app.get("/async_sync")
@elapsed_time_async
async def async_sync(): 
    print("start")
    time.sleep(3) 
    print("end")
    return "ok"

@app.get("/async_async")
@elapsed_time_async
async def async_async(): 
    print("start")
    await asyncio.sleep(3) 
    print("end")
    return "ok"

@app.get("/sync")
@elapsed_time
def sync_sync(): 
    print("start")
    time.sleep(3)
    print("end")
    return "ok"
  • /async_sync: 비동기 라우트로 정의되었지만, 블로킹 I/O 작업인 time.sleep(3)을 사용한다. 이 라우트는 실행 중 다른 작업을 중단하지 못하므로 순차적으로 처리된다.
  • /async_async: await asyncio.sleep(3)를 사용한 비동기 라우트로, 이벤트 루프에서 다른 요청을 처리할 수 있도록 실행이 일시 중단된다.
  • /sync: 이 동기 라우트는 별도의 프로세스에서 실행되므로 동시에 요청을 처리할 수 있습니다.

작동 방식 이해

1. async + blocking I/O

@app.get("/async_sync")
@elapsed_time_async
async def async_sync(): # processed sequentially
    print("start")
    time.sleep(3) # Blocking I/O 작업, await를 사용할 수 없음
    # 함수 실행이 중단(pause)될 수 없음
    print("end")
    return "ok"

이 라우트는 비동기로 정의되었지만, 블로킹 I/O 작업을 사용하여 이벤트 루프를 차단한다. 이로 인해 요청이 순차적으로 처리된다.

예시 흐름: 2개의 request가 한번에 요청받으면 아래와 같은 순서로 실행이 된다.

  • [요청 1] start
  • [요청 1] 대기 (차단)
  • [요청 1] end
  • [요청 1] Async execution time: 3.0052816250245087
  • [요청 2] start
  • [요청 2] 대기 (차단)
  • [요청 2] stop
  • [요청 2] Async execution time: 3.0004953750176355

실행결과

start
end
Async execution time: 3.004529708006885
INFO:     127.0.0.1:52467 - "GET /async_sync HTTP/1.1" 200 OK
start
end
Async execution time: 3.0096035409951583
INFO:     127.0.0.1:52472 - "GET /async_sync HTTP/1.1" 200 OK

결론

async + blocking I/O 방식은 Main Thread에서 실행을 하고 순차적으로 실행이 된다. 즉, blocking I/O 때문에 pause 될 수가 없다.

2. async + non-blocking I/O

@app.get("/async_async")
@elapsed_time_async
async def async_async(): 
    print("start")
    await asyncio.sleep(3) 
    print("end")
    return "ok"

이 라우트는 논블로킹 I/O 작업을 사용하여, 이벤트 루프가 다른 요청을 처리할 수 있도록 실행을 일시 중지한다.

예시 흐름: 2개의 request가 한번에 요청받으면 아래와 같은 순서로 실행이 된다.

  • [요청 1] start
  • [요청 1] 일시 중지 (pause)
  • [요청 2] start
  • [요청 2] 일시 중지 (pause)
  • [요청 1] end
  • [요청 1] Async execution time: 3.0052816250245087
  • [요청 2] end
  • [요청 2] Async execution time: 3.0004953750176355

실행결과

start
start
end
Async execution time: 3.001721083011944
INFO:     127.0.0.1:51878 - "GET /async_async HTTP/1.1" 200 OK
end
Async execution time: 3.0011972500069533
INFO:     127.0.0.1:51765 - "GET /async_async HTTP/1.1" 200 OK

결론

async + non-blocking I/O는 Main Thread에서 실행이 되고 await를 사용하여 I/O 요청의 응답을 받는 작업을 일시 중지(pause)하고 다른 작업을 실행할 수 있다.

크롬 브라우저를 2개 띄우는 테스트하는데 동일한 url로 접속하니 sync로 동작했다. 그래서 한쪽은 localhost, 한쪽은 IP로 접속해서 테스트 하니 정상적으로 동작했다.

  • 요청 1: 시작 -> await(일시 중단) -> 종료
  • 요청 2: 시작 -> await -> 종료
  1. /sync: 이 동기 라우트는 별도의 스레드에서 실행되어, 요청들이 동시에 처리될 수 있습니다.

    예시 흐름:

    • 요청 1과 요청 2 모두 시작 -> 대기 -> 종료가 동시에 실행된다.

3. sync + blocking I/O

@app.get("/sync")
@elapsed_time
def sync_sync(): 
    print("start")
    time.sleep(3)
    print("end")
    return "ok"

이 동기 라우트는 별도의 스레드에서 실행되어, 요청들이 동시에 처리될 수 있다.

예시 흐름: 2개의 request가 한번에 요청받으면 아래와 같은 순서로 실행이 된다.

  • [요청 1] start
  • [요청 2] start
  • [요청 1] end
  • [요청 1] Sync execution time: 3.0052816250245087
  • [요청 2] end
  • [요청 2] Sync execution time: 3.0004953750176355

실행결과

start
start
end
Sync execution time: 3.005067209014669
INFO:     127.0.0.1:53613 - "GET /sync HTTP/1.1" 200 OK
end
Sync execution time: 3.005209750001086
INFO:     127.0.0.1:53615 - "GET /sync HTTP/1.1" 200 OK

결론

sync + blocking I/O 방식은 개별 Thread에서 실행을 하고 병렬로 실행이 된다. 즉, 프로세스 개수가 여러 개 있다면 여러 쓰레드로 실행이 가능하다는 뜻이다.


정리

위 예제들을 바탕으로, FastAPI에서 sync와 async를 사용하는 권장 방법은 다음과 같다.

  1. 논블로킹 I/O 작업에서는 Async를 사용하자: 코드에 일시 중단 가능한 I/O 작업이 포함된 경우(예: 데이터베이스 쿼리, API 호출), async 함수를 사용하자. 이렇게 하면 FastAPI가 여러 요청을 동시에 처리할 수 있다.
  2. 블로킹 I/O 작업에서는 Async를 피하자: time.sleep과 같은 블로킹 작업이 있는 경우, async를 사용하지 말자. 대신, 표준 동기 함수를 사용하여 이벤트 루프가 차단되지 않도록 한다.
  3. 블로킹 I/O 작업에는 Sync를 사용하자: 블로킹 I/O 작업을 수행해야 할 때는 동기 함수를 사용하자. FastAPI는 이를 별도의 스레드에서 실행하여 다른 요청들이 계속 진행될 수 있도록 한다.
반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유