동시성과 async/await

path 기능 함수를 위한 async def 구문과 비동기 코드, 동시성 및 병렬처리에 대해 설명한다.

In a hurry?

await과 함께 호출할 수 있는 3rd party 라이브러리를 사용한다면, 다음과 같이 사용한다.

results = await some_library()

그 다음, async defpath 기능 함수를 선언한다.

@app.get("/")
async def read_results():
    results = await some_library()
    return results

참고: async def와 함께 생성한 함수 안에서만 await를 사용 할 수 있다.

무언가(데이터베이스, API, 파일 시스템 등)와 통신하고, await 사용을 지원하지 않는 3rd party 라이브러리를 사용 한다면(현재 대부분 데이터베이스 라이브러리 경우), path 기능 함수를 정상적으로 선언한다.

@app.get("/")
def results():
    results = some_library()
    return results

당신의 어플리케이션이 다른 것과 통신하고 응답할 때 까지 기다릴 필요가 없다면, async def를 사용한다. 모르겠다면, def를 사용한다.

참고: path 기능 함수에 필요한 만큼 defasync def를 혼합할 수 있고, 최상의 옵션을 사용해 각각을 정의할 수 있다. FastAPI는 제대로 동작 할 것이다.

어쨌든, 위의 모든 경우에 FastAPI는 여전히 비동기적으로 작동하고 매우 빠릅니다.

하지만 위의 단계를 따르면, 성능 최적화를 할 수 있다.

Technical Details

최신 버전의 Python은 async, await 구문과 함께 “코루틴”이라고 불리는 것을 사용해 “비동기 코드”를 지원한다.

아래 섹션에서 각 부문별로 살펴보자.

  • Asynchronous Code(비동기 코드)
  • async and await
  • Coroutines

Asynchronous Code(비동기 코드)

비동기 코드는 코드의 특정 지점에서 컴퓨터/프로그램에게 다른 작업이 어딘가에서 끝날 때 까지 기다려야 한다는 것을 알리는 방법을 가진 언어를 의미할 뿐이다. 다른 작업을 “느린 파일”이라고 부르자.

그 시간 동안 컴퓨터는 “느린 파일”이 끝나는 동안 다른 작업을 수행할 수 있다.

그러면 컴퓨터/프로그램은 다시 기다려야 하기 때문에 기회가 있을 때마다 돌아올 것이다. 혹은 컴퓨터/프로그램이 해당 시점에 가졌던 모든 작업을 끝낼 때마다 돌아올 것이다. 그리고 컴퓨터/프로그램은 대기하던 작업들이 이미 끝났는지 확인하고, 해야 할 일을 수행합니다.

다음으로 컴퓨터/프로그램은 첫 번째 작업(“느린 파일”)을 수행하고, 관련된 모든 작업을 계속한다.

“다른 것을 기다리다”라는 것은 일반적으로 (RAM 메모리와 프로세서의 속도에 비해) 비교적 “느린” I/O 작업들을 나타낸다.

  • 네트워크를 통해 보낼 클라이언트의 데이터
  • 네트워크를 통해 클라이언트가 받을 (당신의 프로그램에서 보낸) 데이터
  • 시스템이 읽고 당신의 프로그램에 제공할 디스크의 파일 내용
  • 당신의 프로그램이 디스크에 쓰기 위해 시스템에 제공한 내용
  • 원격 API 작업
  • 완료할 데이터베이스 작업
  • 결과를 반환하는 데이터베이스 쿼리
  • 그리고 등등

실행 시간은 대부분 I/O 작업을 기다리는데 소비하므로, 그것들을 “I/O bound” 작업이라고 부른다.

작업 결과를 가져올 수 있고, 일을 계속할 수 있는 것을 “비동기”라고 한다. 컴퓨터/프로그램이 (아무 일도 하지 않으면서 작업이 끝나는 정확한 순간을 기다리며) 느린 작업과 “동기화”할 필요가 없기 때문이다.

“비동기” 시스템이 되는 대신에, 작업은 컴퓨터/프로그램이 하기 위한 것은 무엇이든 끝낼 동안 줄을 서서 약간(몇 마이크로초) 기다릴 수 있다. 그리고 돌아와 작업 결과를 가져올 수 있고, 함께 일을 계속한다.

“동기화(synchronous)”(“비동기”의 반대)는 일반적으로 “순차적(sequential)”이라는 용어로 사용한다. 컴퓨터/프로그램은 다른 작업으로 전환하기 전에 모든 단계들(대기가 포함되어 있더라도)을 순서대로 따라야 하기 때문이다.

동시성(Concurrency)과 햄버거들

위에 설명한 비동기 코드의 개념을 종종 “동시성”이라고 한다. “병렬 처리(parallelism)”와는 다르다.

동시성병렬 처리 모두 “동시에 일어나는 다른 일”과 관련이 있다.

그러나 동시성병렬 처리의 세부 사항은 꽤 다르다.

차이를 알아보기 위해, 햄버거들에 관한 다음 이야기를 상상해보자.

asynchronous(비동기) = concurrency(동시성) != parallelism(병렬처리)

synchronous(동기) = sequential(순차적인)

동시 발생의 햄버거들

좋아하는 사람과 함께 패스트 푸드를 먹으러 갔고, 계산원이 앞에 있는 사람들의 주문을 받는 동안 줄을 섰다.

다음은 당신의 차례이다. 당신과 좋아하는 사람을 위해 2개의 매우 멋진 햄버거를 주문한다.

돈을 지불한다.

계산원이 주방에 있는 요리사에게 무언가 말하면, 햄버거들을 준비해야 하는 것을 알게 된다. (현재 이전 고객들을 위한 것들을 준비하고 있음에도)

계산원은 번호를 알려줍니다.

기다리는 동안 좋아하는 사람과 함께 테이블에 앉아 대화를 한다. (햄버거가 매우 멋진 만큼 준비하는데 시간이 걸림)

좋아하는 사람과 함께 테이블에 앉아 햄버거를 기다리는 동안, 좋아하는 사람이 얼마나 멋지고 귀엽고 똑똑한지 감탄하며 시간을 보낼 수 있다.

좋아하는 사람과 이야기하고 기다리는 동안, 가끔 당신의 차례가 됐는지 카운터에 표시된 번호를 확인한다.

그러다 어느 순간, 드디어 당신 차례다. 카운터로 가서 햄버거를 테이블로 가지고 간다.

좋아하는 사람과 함께 햄버거를 먹고 즐거운 시간을 보낸다.


컴퓨터/프로그램 이야기라고 상상해보자.

당신이 줄에 있는 동안, 당신은 그다지 “생산적인” 일을 하지 않고 차례를 기다리며 놀고(idle) 있다. 그러나 계산원은 (준비는 안 하고) 주문만 받기 때문에 줄은 빠르다. 괜찮다.

그런 다음 당신의 차례가 되면 실제 “생산적인” 작업을 수행한다. 메뉴를 처리하고, 원하는 것을 결정하고, 좋아하는 사람이 고른 것을 받고, 결제하고, 제대로 카드를 줬는지 확인하고, 올바르게 지불됐는지 확인하고, 주문이 제대로 됐는지 확인한다.

하지만 아직 햄버거가 없더라도, 계산원과 함께 하는 일은 “일시 중지(on pause)”이다. 당신은 버거가 준비될 때까지 기다려야 하기 때문이다.

그러나 번호를 가지고 카운터를 떠나 테이블에 앉으면, 당신의 관심을 좋아하는 사람에게 전환(switch)해 “일(work)”을 할 수 있다. 그런 다음 좋아하는 사람에게 시시덕거리는 매우 “생산적인” 일을 다시 한다.

그 때 계산원이 카운터 디스플레이에 번호를 표시해 “햄버거가 다 했어”라고 말한다. 하지만 표시된 번호가 당신의 차례로 바뀌어도, 당신은 바로 미친 듯이 뛰지 않는다. 당신은 당신의 차례가 있고, 그들도 그들의 차례가 있기 때문에 아무도 당신의 햄버거를 훔쳐가지 않을 것이라는 것을 안다.

그래서 당신은 좋아하는 사람이 이야기를 끝낼 때까지 기다리고(현재 작업 완료/처리 중인 작업), 상냥하게 미소지으며 햄버거를 가지러 가야한다고 말한다. (일시 중지)

그런 다음 카운터로 가서(switch), 이제 완료된 초기 작업인 햄버거 가져오고 감사 인사를 전한다. 테이블로 돌아온다. 카운터와 상호작용하는 단계/작업은 끝났다(⏹). 그러면 “햄버거 먹기”라는 새로운 작업이 생성됐지만, 이전 작업인 “햄버거 획득”은 끝났다.(⏹)

병렬의 햄버거들

이제 “동시 햄버거”가 아니라 “병렬 햄버거”라고 상상해보자.

좋아하는 사람과 병렬 패스트 푸드를 먹으러 간다.

여러 명(예: 8명)의 요리사인 계산원이 앞에 있는 사람들의 주문을 받는 동안 줄을 선다.

8명의 계산원들은 다음 주문을 받기 전에 바로 햄버거를 준비하러 가야하기 때문에, 앞에 있는 모두가 버거가 준비되기를 기다리고 있다.

그리고 마침내 당신 차례다. 당신과 당신이 좋아하는 사람을 위해 아주 화려한 햄버거를 2개 주문한다.

지불한다.

계산원이 부엌으로 간다.

카운터 앞에 서서 기다린다.

번호가 없으므로 다른 누군가 햄버거를 가져가지 않도록 카운터 앞에 서서 기다린다.

당신과 당신이 좋아하는 사람은 아무도 당신 앞에 서지 못하게 하느라 바쁘고, 나오자마자 햄버거를 가져가야 하므로 좋아하는 사람에게 집중할 수 없다.

이것은 “동기” 작업이며 당신은 계산원/요리사와 “동기화”된다. 계산원/요리사가 햄버거를 끝내고 당신에게 주는 정확한 순간에 있어야 하고, 기다려야 한다. 그렇지 않으면 다른 사람이 가져간다.

긴 기다림 끝에 계산원/요리사는 당신의 햄버거를 가지고 카운터로 돌아온다.

당신이 좋아하는 사람과 함께 햄버거를 가지고 테이블에 간다.

당신은 먹기만 하면 끝이다.

카운터 앞에서 기다리는 시간이 대부분이어서 대화나 시시덕거림이 거의 없었다.


병렬 햄버거의 시나리오에서 당신은 2개의 프로세서(당신과 당신이 좋아하는 사람)를 가진 컴퓨터/프로그램이다. 둘 다 오랫동안 카운터에 그들의 관심을 집중하고 기다린다.

패스트 푸드 가게는 8개의 프로세서(계산원/요리사)를 가지고 있다. 반면에 동시성 햄버거 가게는 오직 2개(1명의 계산원과 1명의 요리사)를 가지고 있다.

하지만 마지막 경험은 최고가 아니다.


이것에 대한 “실제 생활”의 예로, 은행을 상상해보자.

최근까지 대부분의 은행은 여러명의 계산원과 긴 줄이 있다.

모든 계산원들이 한 명의 고객과 모든 작업을 한 후 다른사람과 작업한다.

그리고 당신은 길게 줄을 서서 기다려야 하며, 그렇지 않으면 당신의 차례를 놓치게 된다.

은행에 볼 일을 보러 좋아하는 사람과 함께 가고 싶지 않을 것이다.

햄버거 결론

“좋아하는 사람과 함께하는 패스트 푸드 햄버거” 시나리오에서 기다리는 시간이 많기 때문에, 동시 시스템을 선택하는 것이 훨씬 더 합리적이다.

대부분의 웹 어플리케이션의 경우이다.

많은 사용자가 있지만, 당신의 서버는 그들의 좋지 않은 연결을 통해 올 요청을 기다리고 있다.

그리고 응답이 돌아올 때 까지 다시 기다린다.

기다리는 100만분의 1초(microseconds) 단위로 측정되지만, 그래도 합치면 결국 많은 기다림이다.

웹 API에 비동기 코드를 사용하는 것이 합리적인 이유이다.

대부분의 인기있는 파이썬 프레임워크들(Flast와 Django를 포함)은 파이썬의 새로운 비동기 기능이 있기 전에 만들어졌다. 따라서 배포 방법이 병렬 실행과 (새 기능만큼 강력하지 않은) 이전 비동기 실행을 지원한다.

비동기 웹 파이썬(ASGI)의 주요 사양은 웹소켓에 대한 지원을 추가하기 위해 Django에서 개발되었지만.

이런 종류의 비동기는 NodeJS를 인기있게 만들었고 (NodeJS가 병렬은 아니지만), 프로그래밍 언어로서의 Go의 강점이다.

그리고 이는 FastAPI로 얻을 수 있는 것과 동일한 수준의 성능이다.

당신은 병렬성과 비동기성을 동시에 가질 수 있으므로, 테스트된 대부분의 NodeJS 프레임워크들 보다 높은 성능을 얻을 수 있다. 그리고 C에 가까운 컴파일 언어인 GO와 동등한 성능을 얻을 수 있다. (모두 Starlette 덕분에)

동시성이 병렬 처리보다 나은가?

아니! 이야기의 교훈이 아니다.

동시성은 병렬 처리와 다르다. 많은 대기 시간이 필요한 특정 시나리오에서 더 좋다. 이 때문에 일반적으로 웹 어플리케이션 개발을 위해서 병렬 처리보다 훨씬 낫다. 하지만 모든 것을 위한 것은 아니다.

균형을 맞추기 위해, 다음과 같은 짧은 이야기를 상상해보자.

당신이 크고, 더러운 집을 청소해야 한다.

이게 이야기의 전부이다.


어디서든 기다릴 필요가 없다. 집의 여러 곳에 해야할 일이 많다.

햄버거 예 처럼 순서가 있다. 우선 거실, 그 다음 주방. 그러나 당신은 기다릴 필요가 없으므로, 그저 청소한다. 순서는 아무 영향도 미치지 않는다.

순서가 있든 없든(동시성) 끝나는데 똑같은 시간이 걸린다. 그리고 똑같은 양의 작업을 수행한다.

이 경우, 8명의 전 계산원/요리사/현 청소원을 데려간다. 그리고 각자(당신을 포함해) 청소하기 위해 집의 구역을 담당한다. 당신은 모든 작업을 병렬로 할 수 있고, 추가 도움으로 더 빨리 끝낼 수 있다.

이 시나리오에서 각 청소부(당신을 포함)는 프로세서가 되어 각자의 역할을 수행한다.

그리고 대부분의 실행 시간은 (대기 하는 대신) 실제 작업을 하고, 컴퓨터의 작업은 CPU가 수행하므로 “CPU bound” 문제라고 한다.


CPU bound 연산의 일반적인 예는 복잡한 수학 처리가 필요한 것이다.

예:

  • 오디오 또는 이미지 처리
  • 컴퓨터 비전: 이미지는 수백만 개의 픽셀로 구성되며, 각 픽셀은 3가지 값/색상을 가진다. 일반적으로 해당 픽셀에서 무언가를 동시에 계산해야 하는 처리가 필요하다.
  • 머신 러닝: 일반적으로 많은 “행렬(matrix)”와 “vector” 곱셈이 필요하다. 숫자가 있는 거대한 스프레드시트를 고려하고, 모든 숫자를 동시에 곱한다.
  • 딥 러닝: 머신 러닝의 하위 분야이므로 동일하게 적용된다. 곱해야 할 숫자가 있는 스프레드시트가 하나가 아니라 엄청난 집합으로 있다. 그리고 많은 경우, 이 모델을 만들고 사용하기 위해 특수 프로세서를 사용한다.

동시성 + 평렬 처리: 웹 + 머신러닝

FastAPI를 사용하면 웹 개발에서 매우 일반적인 동시성의 이점을 취할 수 있다. (NodeJS의 주요 장점과 같은)

또한, 기계 학습 시스템과 같은 CPU bound 작업을 위한 병렬 처리와 멀티프로세싱(병렬로 실행되는 여러 개의 프로세스를 가짐)의 이점도 활용할 수 있다.

파이썬이 데이터 과학, 머신 러닝 그리고 특히 딥 러닝을 위한 주요 언어라는 단순한 사실을 더해, FastAPI는 데이터 과학/머신 러닝 웹 API 및 애플리케이션과 잘어울린다.

프로덕션에서 병렬 처리를 하는 법은 배포에 섹션을 참조하자.

async and await

최신 파이썬 버전은 비동기 코드를 정의하기 위한 매우 직관적인 방법이 있다. 이것은 평범한 “순차적” 코드처럼 보이게 하고, 적절한 순간에 “기다림”을 한다.

결과를 제공하기 전에 기다림이 필요한 작업이 있고, 새로운 파이썬 기능을 지원할 때 다음과 같이 할 수 있다.

burgers = await get_burgers(2)

여기서 핵심은 await이다. 이것은 파이썬에게 bugers에 결과를 저장하기 전에 get_burgers(2)가 끝날 때까지 기다려야 한다고 알린다. 이를 통해, 파이썬은 그 동안 다른 작업을 수행할 수 있음을 알게 된다. (다른 요청을 받는 것과 같은)

await가 작동하기 위해, 비동기성을 지원하는 함수 안에 있어야 한다. 그렇게 하려면 async def로 선언하면 된다.

async def get_burgers(number: int):
    # 햄버거를 만들기 위한 비동기 작업 수행
    return burgers

def 대신에:

# 비동기가 아님
def get_sequential_burgers(number: int):
    # 햄버거를 만들기 위한 순차적 작업 수행
    return burgers

async def를 사용하면 해당 함수 내부에서 파이썬은 await 표현식을 인식해야만 하는 것을 알고, 해당 함수의 실행을 “일시 중지”하고 돌아오기 전에 다른 작업을 수행할 수 있다는 것을 안다.

async def 함수를 호출하려면, await을 해야만 한다. 다음 코드는 작동하지 않는다.

# get_burgers가 async def로 정의했기 때문에 동작하지 않는다.
burgers = get_burgers(2)

따라서, await을 사용해 호출할 수 있는 라이브러리를 사용한다면 다음과 같이 async def와 함께 path 기능 함수를 생성한다.

@app.get("/burgers")
async def read_burgers():
    burgers = await get_burgers(2)
    return burgers

더 많은 기술적 세부사항

awaitasync def로 정의한 함수 내에서만 사용할 수 있다.

동시에 async def로 정의한 함수들은 “대기”해야 합니다. 따라서 async def를 사용하는 함수들은 async def로 정의한 함수 내부에서만 호출할 수 있다.

그렇다면 달걀과 닭에 대해, 첫번째 async 함수를 어떻게 호출할 것인가?

FastAPI로 작업한다면, 그것에 대해 걱정할 필요가 없다. “첫번째” 함수는 path 기능 함수가 되기 때문이다. 그리고 FastAPI는 올바른 작업을 수행하는 방법을 알고 있다.

하지만 FastAPI 없이 async/await을 사용하고 싶다면, 그렇게 할 수도 있다.

자신만의 비동기 코드 작성

Starlette(및 FastAPI)는 AnyIO 기반이다. AnyIO는 파이썬 표준 라이브러리 asyncioTrio 모두와 호환된다.

특히, 당신의 코드에 더 고급 패턴이 필요한 동시성 케이스를 위해 AnyIO를 직접 사용할 수 있다.

그리고 FastAPI를 사용하지 않아도, 호환성이 높고 이점(예: 구조적 동시성)이 있는 AnyIO를 사용해 고유한 비동기 어플리케이션을 작성할 수 있다.

다른 형태의 비동기 코드

asyncawait를 사용하는 스타일은 언어에서 비교적 새롭다.

그러나 그것은 비동기 코드 작업을 쉽게 만든다.

동일한 구문은 JavaScript(브라우저 내부 및 NodeJS)의 최신 버전에도 포함되었다.

그러나 그 전에는 비동기 코드를 다루는 것은 훨씬 복잡하고 어려웠다.

이전 파이썬 버전에서 쓰레드 또는 Gevent를 사용할 수 있었다. 그러나 코드는 이해하고 디버그하고 생각하기 더욱 복잡했다.

이전 NodeJS/Browser JavaScript 버전에서 “콜백”을 사용했다. 이것은 콜백 지옥으로 이어진다.

Coroutines

코루틴은 async def 함수에 의해 반환된 것에 대한 멋진 용어이다. 시작할 수 있고, 어떤 시점에 끝날 수 있으나 await이 있을 때는 내부적으로 일시 정지 해야하는 함수와 같다는 것을 파이썬은 안다.

그러나 asyncawait와 함께 비동기 코드를 사용하는 모든 기능은 “코루틴”을 사용하는 것으로 요약된다. 이것은 Go 언어의 주요 핵심 기능인 “고루틴”과 견줄만하다.

Conclusion

다음과 같은 문구를 보자.

최신 파이썬 버전은 asyncawait 구문과 함께 “코루틴”이라는 것을 사용하는 “비동기 코드”를 지원한다.

그건 이제 더 말이 된다.

이 모든 것이 FastAPI(Starlette를 통해)의 힘을 실어주는 것이며, 인상적인 성능을 갖게 만드는 것이다.