asyncio 뽀개기 1 - Coroutine과 Eventloop

Image not Found

이 시리즈의 목적은 asyncio의 컴포넌트들과 활용법을 소개하는 것입니다. 최종적으로는 실제 production에 쓰이고 있는 graceful shutdown을 구현하는 것을 목표로 하며, 그 과정에서 필요한 asyncio 지식을 여러 포스트에 걸쳐 설명할 예정입니다. Python에 국한된 내용도 있지만 코루틴, Future 등은 Asynchronous Programming에서 공통으로 사용되는 개념이기 때문에 Python을 사용해서 해당 개념을 익힌다고 생각할 수도 있습니다.


서론

Eventloop은 Python asyncio에서 핵심 컴포넌트라고 할 수 있고, 코루틴은 asyncio에서 기본 실행 단위라고 할 수 있습니다. 이번 포스트에서는 코루틴이 어떤 것인지, 어떻게 하면 Eventloop에서 코루틴을 실행하는지를 가상의 요구사항을 정의하고 코드로 구현하면서 설명하겠습니다.
독자는 Python 공식 문서에서 async, await가 사용되는 기본 예제들을 이해할 수 있다고 전제하고 있습니다.

요구사항

  • 10초마다 Job이 생성되는데, 각 Job은 랜덤한 시간을 소요함
    • 이전 Job의 수행시간이 10초를 넘든 안 넘든 상관없이 10초마다 새로 생성되어야 함
  • Job들은 concurrent 하게 실행됨

요구사항 중에서도 10초를 넘든 안 넘든 상관없이 10초마다 새로 생성되어야 함이 핵심이라고 할 수 있습니다. 요구사항이 적기 때문에 아래 코드를 보기 전에 직접 한번 작성해보시고 어떤 어려움이 있는지 고민해보시길 추천해 드립니다.

구현

위의 가정을 어떻게 하면 외부 라이브러리 없이 asyncio 모듈만 사용하여 구현할 수 있을까요? 전체 코드 이후에 하나씩 설명하겠습니다.

import asyncio
from datetime import datetime
from random import randint

async def run_job() -> None:
    delay = randint(5, 15)
    print(f'{datetime.now()} sleep for {delay} seconds')
    await asyncio.sleep(delay)  # 5~15초 동안 잠자기
    print(f'{datetime.now()} finished ({delay} sec)')

async def main() -> None:
    while True:
        asyncio.create_task(run_job())
        await asyncio.sleep(10)

asyncio.run(main())

# 2022-03-27 00:55:59.585462 sleep for 13 seconds
# 2022-03-27 00:56:09.590352 sleep for 14 seconds
# 2022-03-27 00:56:12.588084 finished (13 sec)
# 2022-03-27 00:56:19.598264 sleep for 13 seconds
# 2022-03-27 00:56:23.593074 finished (14 sec)
# 2022-03-27 00:56:29.605253 sleep for 6 seconds
# 2022-03-27 00:56:32.602354 finished (13 sec)
# 2022-03-27 00:56:35.608464 finished (6 sec)

출력 예시를 보면 13초 sleep이 끝나기 전에 14초 sleep이 먼저 실행된 것을 볼 수 있습니다. while loop에서 첫 번째 run_job()이 13초 동안 수행되는 것을 기다리지 않고 다음 iteration을 수행했기 때문에 이런 결과가 나왔다고 볼 수 있고, 이것의 핵심은 asyncio.create_task입니다.

asyncio.create_task는 왜 쓰는 걸까? await run_job()을 대신 쓰면 안될까?

간단한 await run_job()이 안되는 이유부터 설명하면, await을 붙이는 순간 run_job이 return 할 때까지 main()이 실행권을 갖지 못하기 때문입니다. 쉽게 말해서 run_job()이 8초 걸리는 job이었다면 8초가 지나서야 await asyncio.sleep(10)을 실행하게 되는 것이죠. 이 때문에 해당 iteration은 총 18초가 걸리며, “10초마다 Job을 생성함” 이라는 규칙을 어기게 됩니다.

각 Job의 수행 시간과 상관없이 10초마다 계속 생성하려면 await과 Eventloop에 대해서 좀 더 자세히 알 필요가 있습니다. await은 사실 내부적으로 두 가지 동작을 한다고 볼 수 있습니다. 첫 번째로 await 뒤에 있는 코루틴을 (좀 더 정확히는 Awaitable 객체) Eventloop에 실행해달라고 등록하고, 두 번째로 등록한 코루틴이 끝날 때 돌려받길 기대하며 실행권을 Eventloop에게 반환합니다. Eventloop은 등록한 코루틴이 종료되거나 에러가 발생한 이후에 실행권을 돌려줍니다. Eventloop은 이런 식으로 여러 코루틴 사이에 실행권을 주고받으며 Cooperative multitasking을 달성하는 것이죠.
저희의 경우 전자의 동작은 필요하지만, 후자의 동작은 필요가 없습니다. 이때 사용할 수 있는 게 바로 asyncio.create_task()입니다. 해당 함수의 역할은, 파라미터로 들어오는 코루틴을 Eventloop에 등록하고 코루틴이 끝났을 때 결과를 받아볼 수 있는 Future 객체를 반환합니다. 반환되는 Future 또한 Awaitable 객체이기 때문에 await을 앞에 붙이면 Eventloop에 실행권을 넘기면서 코루틴의 종료까지 기다릴 수 있지만, 지금은 필요하지 않기 때문에 의도적으로 await 없이 넘어갔습니다. 그 때문에 실행권을 뺏기지 않은 채로 다음 while iteration을 위해 10초간 기다릴 수 있습니다.

사실 asyncio.create_task()가 반환하는 것은 Task 객체이고, 이 객체는 Future를 상속받기 때문에 동일하게 Awaitable합니다.

await 없이 run_job()만 쓰면 안될까?

await이 기다리는 동작을 내포하기 때문에 asyncio.create_task(run_job()) 대신 run_job()으로 변경해도 괜찮을까요?
실제로 테스트해보면 RuntimeWarning: coroutine 'run_job' was never awaited이라는 warning과 함께 아무것도 출력되지 않는 것을 확인할 수 있습니다. 이유는 간단한데, run_job은 일반 함수가 아니라 “코루틴 함수” 이며, run_job()은 “코루틴 객체” 를 생성해서 반환할 뿐 실제로 코루틴을 실행하지 않기 때문입니다. 특별한 동작 같아 보일 수 있지만, 사실 이미 Python에는 비슷한 동작이 아주 흔하게 쓰이고 있습니다. 당장 쉘을 열어서 (print(n) for n in [1, 2, 3])을 실행하면 <generator object <genexpr> at ...> 같은 출력만 보일 뿐, 아무 숫자도 출력되지 않습니다. list(print(n) for n in [1, 2, 3]) 혹은 next(print(n) for n in [1, 2, 3]) 같은 방법으로 순회해야 비로소 출력된 숫자를 볼 수 있습니다.
다시 예제로 돌아오면, run_job()은 코루틴 객체를 반환하는데, 이 객체는 generator 객체와 동일하게 지금 실행하는 게 아니라 추후에 실행 가능한 객체입니다. 또한, 앞서 설명한 것처럼 코루틴을 실행하기 위해서는 Eventloop에 등록해야 하므로 run_job()으로 코루틴 객체를 생성한다고 해서 코루틴을 실행하는 게 아닙니다. 즉 코루틴을 실행하려면 asyncio.create_taskawait 등을 통해 Eventloop에 등록하는 것이 필수입니다.

참고로 숫자 출력 예시 같은 표현식을 generator expression이라고 하며, 해당 표현식이 반환한 객체를 generator iterator 라고 합니다. 또한, generator iterator를 반환하는 함수를 generator이라고 부르며, 함수 내부에서 yield 키워드를 사용하면 자동으로 generator를 정의하는 것이 됩니다.

용어 정리 - 코루틴과 Future

Awaitable, 코루틴, Future, Task 여러 키워드가 등장해서 한번 정리하고 넘어가는 게 좋을 것 같습니다. 우선 계층구조를 확인해보면 아래와 같습니다.

             ┌──Coroutine
             │
Awaitable◄───┤
             │
             └──Future◄────────Task

await 키워드를 붙일 수 있는 최소 조건이 Awaitable입니다. 그래서 3가지 모두 await으로 실행이 끝나길 기다릴 수 있습니다. 하지만 코루틴은 Eventloop에 등록되지 않으면 실행되지 않기 때문에, await을 붙이거나 Future나 Task로 감싸야 합니다.

동시 실행

위의 코드는 요구사항 중 하나인 동시 실행을 만족합니다. 그렇다면 어디서 어떻게 동시 실행되고 있는 걸까요?
어디는 당연하게도 Eventloop에서 돌아가겠죠? 문제는 Eventloop이 어떻게 동시 실행을 구현하느냐입니다. 이것을 이해하려면 Eventloop이 Cooperative multitasking을 하는 방식을 이해해야 합니다.
asyncio에 공리가 하나 있는데, “스레드당 실행 중인 Eventloop은 하나” 라는 제약조건입니다. 즉, 아무리 많은 코루틴을 하나의 Eventloop에서 동시 실행해도 결국 single thread로 동작한다는 의미입니다. (사실 예외가 있는데 그건 아래에서 설명하겠습니다.) Eventloop의 구현체마다 다를 수 있지만, cpython의 경우 내부 queue에 등록된 코루틴들을 기억하면서 한 번에 하나씩 번갈아가며 실행하고 있습니다. 어떻게 번갈아가면서 실행하길래 동시에 실행 중이라는 착각을 만들 수 있을까요? 답은 await 키워드와 코루틴이라는 단어에 있습니다.

코루틴 vs 일반 함수

우선 코루틴과 일반 함수(루틴 혹은 서브루틴)의 차이점을 실행권의 흐름을 기준으로 보겠습니다.

     Caller            Function                 Caller            Coroutine
┌─────────────┐    ┌─────────────┐         ┌─────────────┐     ┌─────────────┐
│             │    │             │         │             │     │             │
│      │      │    │             │         │      │      │     │             │
│      │      │    │             │         │      │      │     │             │
│      ▼      │    │             │         │      ▼      │     │             │
│      │      │    │             │         │      │      │     │             │
│ call └──────┼────┼───────┐     │         │ call └──────┼─────┼──────┐      │
│             │    │       │     │         │             │     │      │      │
│             │    │       │     │         │             │     │      ▼      │
│             │    │       │     │         │             │     │      │      │
│             │    │       │     │         │      ┌──────┼─────┼──────┘      │
│             │    │       │     │         │      │      │     │      suspend│
│             │    │       │     │         │      ▼      │     │             │
│             │    │       │     │         │      │      │     │             │
│             │    │       │     │         │      └──────┼─────┼──────┐      │
│             │    │       │     │         │ resume      │     │      │      │
│             │    │       │     │         │             │     │      ▼      │
│             │    │       │     │         │             │     │      │      │
│             │    │       │     │         │      ┌──────┼─────┼──────┘      │
│             │    │       │     │         │      │      │     │      suspend│
│             │    │       │     │         │      ▼      │     │             │
│             │    │       │     │         │      │      │     │             │
│             │    │       │     │         │      └──────┼─────┼──────┐      │
│             │    │       │     │         │ resume      │     │      │      │
│             │    │       │     │         │             │     │      ▼      │
│             │    │       │     │         │             │     │      │      │
│      ┌──────┼────┼───────┘     │         │      ┌──────┼─────┼──────┘      │
│      │      │    │      return │         │      │      │     │      return │
│      │      │    │             │         │      │      │     │             │
│      ▼      │    │             │         │      ▼      │     │             │
│             │    │             │         │             │     │             │
└─────────────┘    └─────────────┘         └─────────────┘     └─────────────┘

일반 함수는 호출될 때 stack에 올라간 후 실행권을 가지고 body를 수행합니다. 수행이 끝나면 실행권을 호출한 곳으로 돌려주고 stack에서 사라집니다. 하지만 코루틴은 언제든 실행권을 반환할 수 있습니다. 즉 일반 함수는 시작점과 반환점이 항상 처음과 끝으로 고정된 코루틴의 일종이라고 볼 수 있습니다.

Eventloop이 코루틴을 실행하는 방식

               Eventloop
                   │
  Coroutine 1      │
                   │
┌─────────────┐    │
│             │    │
│             │    │
│await        │    │      Coroutine 2
└─────────────┘    │    ┌─────────────┐
       :           │    │             │
       :           │    │             │
       :           │    │await        │
┌─────────────┐    │    └─────────────┘
│             │    │           :
│             │    │           :
│await        │    │           :
└─────────────┘    │           :
       :           │    ┌─────────────┐
       :           │    │             │
       :           │    │             │
       :           │    │return       │
┌─────────────┐    │    └─────────────┘
│             │    │
│             │    │
│return       │    │
└─────────────┘    │
                   │
                   ▼

Eventloop이 2개의 코루틴을 번갈아가며 실행

내부에 await을 각각 2번, 1번씩 사용하는 코루틴 2개가 Eventloop에 등록되어있을 때 어떤 방식으로 번갈아서 실행하는지를 예로 들었습니다. 앞서서 await 키워드가 실행될 때 실행권을 Eventloop에 넘긴다고 했는데, 이때 해당 코루틴이 Suspend 되면서 다른 코루틴을 마저 실행합니다. OS에서 사용되는 프로세스 스케줄러와 비슷해 보이는데, 가장 큰 다른 점은 선점 여부입니다. 대부분의 프로세스 스케줄러는 선점형이기 때문에 프로세스의 실행권을 뺏을 수 있지만, Eventloop은 비선점형이기 때문에 실행 중인 코루틴이 await을 사용하거나 return 해서 실행권을 Eventloop에 돌려주지 않는다면 실행권을 뺏어서 다른 코루틴을 실행할 방법이 없습니다. 바로 이 이유 때문에 requests 같은 blocking 코드를 코루틴 내부에서 실행하지 못하도록 가이드 하는 것입니다.

구현의 예제 출력을 도식으로 재구성

Score

main() 코루틴은 asyncio.sleep 이외에는 await 하는 곳이 없으므로 10초마다 실행됩니다. run_job() 코루틴들은 main()코루틴에서 asyncio.create_task로 Eventloop에 등록했기 때문에, main() 코루틴이 멈춘 직후에 실행을 시작합니다. 또한, 내부에서 await으로 실행권을 반납하며 sleep 하기 때문에 delay가 아무리 크더라도 다른 코루틴의 실행이 미뤄지지 않습니다.

또 다른 동시 실행 방법

Python 공식 문서를 보면 gatherwait 같은 함수들을 사용할 수 있습니다. 하지만 이 함수들은 기다릴 코루틴(혹은 Future)을 파라미터로 넣어줘야 하기 때문에 개수를 사전에 알고 있어야 한다는 제약이 있습니다. MySQL을 읽으면서 동시에 Redis를 읽을 때 같은 용례가 있을 것 같습니다. 각각을 코루틴으로 만들고, gatherwait으로 모든 코루틴 종료를 기다리는 것이죠. 또 as_completed()를 사용하면 여러 코루틴을 동시 실행하면서 끝나는 순서대로 결과를 받아볼 수도 있습니다. 이번 포스트의 경우, 무한루프는 프로그램 수행시간을 알 수 없으므로 세 가지 함수 모두 사용할 수 없습니다.

Eventloop와 multi thread

사실 항상 모든 코루틴이 하나의 Eventloop에서 실행되진 않습니다. “스레드당 실행 중인 Eventloop은 하나” 라는 제약이 있다는 말은, Eventloop을 하나 더 만들고 싶으면 스레드를 하나 더 만들면 된다는 의미이기도 합니다. 그래서 run_coroutine_threadsafe를 사용하면 코루틴을 어떤 스레드에서 실행할지 선택할 수도 있고, run_in_executor를 사용하면 Eventloop을 하나 더 만들지 않더라도 다른 스레드에서 함수를 실행할 수 있습니다. 다른 스레드가 개입되고, 공유변수를 스레드에 걸쳐 사용한다면 접근제한을 고민해야 합니다. asyncio 모듈에서 제공하는 Synchronization primitive 들은 thread-safe 하지 않기 때문입니다.

마치며

이번 포스트에서는 asyncio에서 가장 기본이 되는 Eventloop과 코루틴에대해서 알아봤습니다. 다음 포스트에서는 Future에 대한 조금 더 자세한 설명과 활용법을 설명하겠습니다.


버즈빌 개발자 지원하기 (클릭)

버즈빌 테크 리크루터와 Coffee Chat하기 (클릭)

You May Also Like

버즈빌, 아마도 당신이 원하던 회사!

지원하기