(Python) Awaitable에 대해

await 키워드를 통해서 실행이 완료되기 전에 다른 작업으로 전환이 가능한 동작을 모두 대기가능(awaitable)하다고 한다. 대기 가능한 객체 타입에는 코루틴(asycio.corutine), Task, Future가 있다.

코루틴

이 글에서 말하는 코루틴은 yield를 사용하는 전통적 의미의 코루틴이 아닌 asycio 라이브러리 내에 정의된 비동기 코루틴을 의미하며, 이는 async def 키워드를 사용하여 정의한 함수(코루틴 함수)가 리턴하는 객체이다. 아래 예제에서 #1의 코드는 실질적으로 아무일도 하지 않는데, nested()를 실행만 하면 코루틴 객체를 생성만 하고 실행(스케줄링)을 하지 않기 때문이다.

코루틴을 실행하기 위해서는 코루틴 함수를 실행하여 생성된 코루틴 객체에 await를 붙여 해당 작업을 실행하고 결과를 기다리는 동작을 명시해야 한다. 따라서 #2의 코드에서는 nested()로부터 값 42을 얻어오고 이를 출력할 수 있다. 또한 await는 일반 코드 수준에서는 실행할 수 없으며, 반드시 코루틴 함수 내에서만 사용할 수 있다.

import asyncio

async def nested():
  return 42

async def main():
  nested() # 1 - 아무일도 일어나지 않는다.
  print(await nested())  # 2

asyncio.run(main())

Task

Task는 코루틴을 실행하기 위해 사용되는 객체이다. asyncio.create_task() 함수에 코루틴 객체를 전달해서 Task로 만들 수 있다. Task로 만들어진 코루틴은 자동으로 스케줄되고 가능한 시점에 곧 실행된다.

async def doSomething():
  print(1) # 1
  await asyncio.sleep(0.5) # 2
  print(2) # 3

async def main():
  t = asyncio.create_task(doSomething()) # 4
  print(3)

asyncio.run(main())

위 코드는 사실 정상적인 코드는 아니지만, Task가 어떻게 시작되는지를 살펴볼 수 있는 예로 들었다. #4에서 doSomething()을 태스크로 생성하면 이 작업은 언제 실행될까? main() 내의 코드는 선형이기 때문에 곧바로 작업이 시작될 수는 없지만, asyncio는 런루프에 doSomething()의 실행을 예약해 놓는다. 그리고 현재 진행 중인 코드가 잠시 쉬는 시점 (어딘선가 await를 만나는 시점)에 실행될 기회를 얻게 된다.

위 코드에서는 3이 출력되고 난 후에 이벤트 루프가 닫히기 전까지 짧은 간격이 생기는데 이 시점에 t의 코드 일부가 실행되어 1이 출력된다. 하지만 #2에서 대기하는 0.5의 시간 동안 런루프가 닫히면서 실행이 중단되고 만다.

만약 main()내부에서 await t라고 써 준다면 doSomething()의 코드가 끝까지 실행될 수 있을 것이다.

Task는 아래에 소개하는 Future 객체의 서브클래스이다. 따라서 Future의 속성이나 동작을 거의 그대로 하게 된다.

Future

Future는 어떤 비동기 동작의 결과를 내포하는 저수준의 대기가능 객체이다. Future를 await하는 것은 어떤 코루틴이 아직 실행 중에 있고, 그 결과가 다른 어디에서 만들어지고 있다는 것을 의미한다. Future는 주로 콜백에 기반한 디자인에 사용되며, 애플리케이션 레벨에서 Future 객체를 직접 만들 이유는 거의 없다. asyncio API에서 일부 노출되기도 하지만(asyncio.gather()의 리턴 값등) 간단히 await 할 수 있다. (예전에는 블럭킹 메소드인 result()를 썼던 것 같다.)

Awaitable을 만들고 사용하기

생성 – create_task() 함수

코루틴을 Task로 만들어 실행을 위해 스케줄링한다. 파이썬 3.6 이전 버전에서는 ensure_future()로 쓰이던 함수를 새로 만든 것이다. (파이썬 3.8에서부터는 name= 파라미터가 추가되어서 Task의 이름을 설정해줄 수 있다.) 이 함수에 의해 비동기 코루틴은 Task 객체가 된다. 생성된 객체는 다음과 같이 사용할 수 있다.

  1. 기본적으로 태스크가 생성되면, 내부의 코루틴은 적절한 시점에 시작되도록 바로 스케줄링 된다.
  2. 해당 태스크의 리턴값을 얻고 싶다면 result = await task로 얻으면 된다.
  3. 그외에 Future를 인자로 받는 함수에 전달 가능하다. (e.g. await asyncio.wait_for(task, timeout) )

태스크는 생성되는 즉시 런루프에 스케줄되려 하므로, 비동기 코루틴 외부의 문맥에서는 생성될 수 없다. 보통 def로만 시작하는 블럭킹 함수 내부에서는 사용하면 안된다. 또한 시작 자체가 자동으로 수행되므로 그 결과를 언제 어떻게 받을 것이냐에 대해서만 신경 쓰면 되겠다.

결과 받기

기본적으로 대기가능 객체는 await 구문을 통해서 결과값을 받아낼 수 있다. 대기 가능 객체에 대해서 await가 걸리게 되면 이 구문은 해당 대기가능 객체가 결과값을 리턴할 때 까지 기다리게 된다. 코루틴 객체의 리턴 시점은 해당 코드의 return 지점이므로 어떻게 끝날지를 대략 알 수 있다. Task, Future의 경우에는 어떨까? 코루틴을 기반으로 Task나 Future를 생성했다면, 내부의 코루틴 실행이 완료되면 해당 Task, Future 객체에 결과값이 세팅된다. 결과값이 결정된 대기 가능 객체는 실행을 완료한 것으로 간주되고, 그 시점에 await 구문의 평가가 완료된다.

이벤트 루프의 create_future() 메소드는 빈 Future 객체를 생성할 수 있다. 비어있는 Future 객체는 스케줄링 될 수는 있으나 결과값이 결정되기 전까지는 계속해서 대기상태가 된다. 이 경우 코드의 다른 곳에서 해당 객체에 set_result()를 호출하여 결과를 주입해줄 수 있다.

다음 예는 빈 Future 객체를 만들고, 다른 작업에서 해당 객체의 결과를 결정해주는 예이다.

import asyncio
from typing import Any


async def set_after(fut: asyncio.Future, delay: float, value: Any):
  # 1: 지정된 시간이 지잔 후에 future 객체에 결과값을 주입
  await asyncio.sleep(delay)
  fut.set_result(value)


async def main():
  loop = asyncio.get_running_loop()
  fut = loop.create_future() # 2: 빈 future 생성
  # 3: create_task를 통해서 작업을 생성하고, 바로 스케줄링한다.
  loop.create_task(set_after(fut, 1, '... world'))
  print('hello ...')
  print(await fut) # 4: fut의 결과가 결정될 때까지 기다린 후 결과를 받아서 출력

set_result()가 대기가능 객체가 아니라 블럭킹 함수라는 점에 주목하자. 마찬가지로 result()를 이용해서 Future로 부터 결과를 얻는 것도 가능하다. 하지만 주의할 점은 스레드의 join()과 달리 result()는 결과를 얻을 때 까지 기다리지 않고, 결과가 준비되지 않았다면 InvalidStateError예외를 일으킨다.

# 위 코드의 main()에서...
  print(fut.result())
  # ==> asycnio.base_futures.InvalidStateError : Result is not set.

결론

코루틴과 Task(Future)는 외관상 ‘대기 가능’ 타입으로 간주할 수 있고, 대기 가능 타입은 await 구문과 함께 쓰여서 결과를 받아낼 수 있는 객체를 말한다. asyncio에는 wait() 라든지 as_completed() 등의 여러 함수가 있는데, 이들은 각각 특별한 상황 (수행에 대해 타임아웃을 설정, 순서에 상관없이 빨리 끝나는 것부터 결과를 얻음)에서 흐름을 제어하는 용도에 관한 것이지 본질적으로 Future 류의 객체로부터 결과를 얻는 것과는 다른 용도로 이해하는 것이 맞겠다.