Task, Future, Coroutine

코루틴과 Task에 대한 내용을 발행했었는데 이 부분은 사실 asyncio에 대한 총정리 글에 포함되는 내용이었던 관계로, asyncio 에서 사용되는 세 가지 대기 가능 객체인 Task, Future, Coroutine 의 차이에 대해서 설명하는 내용으로 수정합니다.


asyncio는 비동기 처리를 위해 비동기 코루틴을 만들고 이를 스케줄링하여 실행하는 기능을 중심으로 구성되어 있다. 그런데 관련 함수를 찾아보면 어떤 것은 코루틴, 또 어떤 것은 Task나 Future로 표현하며 섞어 쓰는 것 같기도 해서 혼란스러운 점이 있다. 이 글에서는 코루틴과 asyncio.Task, asyncio.Future 가 각각 어떻게 다른지 살펴보도록 하겠다.

Awaitable로 대동단결

일단 결론부터 말하자면 코루틴, Task, Future 객체는 모두 Awaitable(대기가능) 객체이다. 대기 가능 객체는 await 다음에 올 수 있는 것으로 이해하면 된다. 이들은 실행흐름이 주 루틴과 선형으로 연결되지 않지만, 최종적으로 결과가 필요한 시점에 await와 결합하여 실행의 종료를 기다리게 된다.

코루틴

asyncio와 관련하여 코루틴은 비동기 코루틴을 의미하는데, 코루틴은 코루틴 함수에 의해서 생성되는 객체를 말한다. 코루틴 함수란 async def 를 사용하여 작성되는 함수이다. 이들 함수는 호출하면 내부 코드가 실행되는 대신, 내부 코드를 실행할 예정인 코루틴 객체를 만들게 된다. 코루틴 객체 내부의 코드가 만들어내는 리턴값은 await 를 만나는 시점에 얻을 수 있다.

그런데 주의해야 하는 점은 await 구문 자체를 코루틴 함수 내부에서만 쓸 수 있다는 것이다. 일반적인 함수나 함수 외부의 최상위 문맥에서는 await를 쓸 수 없다. 즉 어떤 식으로든 코루틴을 생성했으면 최소한 한 번은 코루틴을 실행하는 작업을 거쳐야 한다. 코루틴의 시작과 실행은 런루프에 의존하고 있으며, 이를 위한 여러 API들이 있으나, 파이썬 3.7부터는 asyncio.run() 함수를 통해서 실행할 수 있다. 이 함수는 코루틴만을 인자로 받는다. (코루틴 함수가 아니다.) 따라서 보통asyncio.run(doSomethingAsync()) 와 같은 꼴이 된다.

Task

Task 객체는 asyncio.Task 클래스의 인스턴스이며, 코루틴을 간단한 래퍼로 감싼 객체이다. 앞서도 말했지만 코루틴 함수를 호출하여 생성한 코루틴 객체는 그 스스로는 자동으로 실행되지 않는다고 했다. 코루틴을 실행하여 그로부터 결과를 얻으려면 await 구문을 사용해야 한다.

async def foo():
  co = doSomethingAsync() # 1
  result = await co       # 2
  print(result)

위 예제에서보면 1번에서 doSomethingAsync()라는 코루틴 함수를 호출하여 코루틴 객체 co를 만들었다. 하지만 이 시점에서 co는 실행되지 않는다. 다음 라인에서는 await co를 통해서 co를 실행하고 그 결과를 result에 바인딩했다. 그런데 await는 해당 코루틴이 종료될 때까지 블럭한다. 이 코루틴 함수 내부의 실행 흐름만 봤을 때에는 마치 co가 블럭킹 함수인것과 마찬가지인 것으로 보인다. 물론 이 foo 외부에서 스케줄링한 코루틴이 있다면 co를 기다리는 동안에 그것이 실행될 수 있을 것이다.

그런데 코루틴 함수 내부에서 2개 이상의 코루틴이 동시에 진행되어야 한다면 어떨까?

async def bar():
  c1 = doSomethingAsync()
  c2 = doSomethingAnother()
  print(await c2)
  await c1

위 예에서는 2개의 코루틴을 만들었는데 먼저 c2의 종료가 끝난 후에 c1을 시작하게 된다. 만약 c1 내부에서 IO 를 기다리는 상황이 되더라도 c2를 시작할 수 없다. 코루틴 만으로는 이 문제를 해결하기 어렵기 때문에 Task를 도입할 수 있다. 코루틴은 간단히 asyncio.create_task() (Python 3.6 이전은 asyncio.ensure_future()) 함수를 사용해서 Task로 만들 수 있다. Task로 만들어지는 과정에서 코루틴은 런루프에 등록되어 스케줄링되고, (언젠지는 모르지만) 실행 가능한 시점이 오면 실행을 시작할 수 있다.

다음 예제는 코루틴을 Task로 만들어서 별도로 스케줄링하면서 다른 코루틴을 하나의 블럭에서 같이 실행한다.

async def bar2():
  t = asyncio.create_task(doSomethingAsync())
  co = doSomethingAnother()
  print(await co)
  await t

두 개의 코루틴이 모두 내부에 await를 쓰고 있어서 두 작업의 전환이 가능하다고 하면, t와 co가 번갈아가면서 작업을 처리하는 것을 확인할 수 있다. 세 번째 라인에서 co의 결과를 얻게 된 후에 코루틴 bar2가 t의 실행이 끝날 때까지 기다리게 하기 위해서 await t를 여기서 쓰고 있다. 즉 t는 작업으로 만들어서 먼저 시작되며, asyncio.Task 역시 awaitable 이기 때문에 await으로 실행의 종료를 기다릴 수 있다.

Task에 대해서 좀 더 이야기할 것이 있는데, 이는 다음 순서인 Future와 함께 이야기하겠다.

Future

Future는 비동기 실행을 캡슐화한 객체이며, 사실상 Task는 asyncio.Future의 서브클래스이다. 이 이름은 concurrent.futures.Future에서 따 온 것이며, 실제 API도 concurrent.futures의 것과 거의 비슷하게 생겼다.

싱글스레드의 비동기 IO나 멀티스레드/멀티 프로세스 패러다임에서 중요한 것은 비동기 실행이고, 비동기 실행의 특징은 실제 처리 완료와 상관없이 디스패치를 담당하는 함수는 바로 리턴하는 non-blocking 함수라는 점이다.

실질적으로 Future는 이러한 블록킹하지 않는 함수들의 리턴의 형태이다. 단일 스레드의 선형 실행 모델에서는 어떤 함수의 처리 결과를 얻기 위해서는 그 함수가 처리를 종료할 때까지 기다려야 한다. 하지만 비동기 처리에서는 실질적인 처리는 다른 곳에서 수행되며, 현재의 실행 흐름은 그 작업의 진행 상황과 관계없이 돌아가게 된다. 저수준의 스레드 혹은 서브 프로세스 모델에서는 다른 곳에서 수행한 작업의 결과를 얻기 위해선 별도의 동기화 수단이 필요했다. 예를 들어 threading.Thread를 사용하는 경우, 다른 스레드에서 연산한 결과를 얻기 위해서는 queue.Queue 같은 객체를 넘겨주어 다른 스레드에서 큐에 데이터를 넣고, 현재 스레드에서 필요한 시점에 큐에서 데이터를 꺼내는 방식으로 코드를 작성했다.


Future의 의미

Future는 논블럭 함수가 리턴하는 결과물로, ‘언제인지는 모르지만 나중에 결과가 resolove 될 것이다’라는 약속으로 이해할 수 있다. (그래서 자바스크립트에서는 비동기 모델에서 Promise라는 표현을 쓴다.) 그리고 다시 실제 리턴값이 필요한 시점에 Future 객체에 대해서 결과값을 요구할 수 있다. 만약 비동기 작업이 그 시점에 완료되었다면 Future는 그 결과를 즉시 돌려주겠지만, 그렇지 않았다면 완료될때까지 요청한 쪽의 흐름을 블럭할 수 있다. 이것이 Future가 동작하는 방식이다. 즉 함수의 처리를 기다리는 블럭 시점을 호출 시점에서 ‘값이 필요한 시점’으로 뒤로 미루는 효과를 내는 것이다. 운이 좋다면 다른 작업을 처리한 후, 맡겨놓은 결과를 얻을 때 지연 없이 얻을 것이고, 운이 나쁘다 하더라도 다른 작업들을 먼저 처리해놓고, 또 그만큼 지연되는 시간이 줄어들 수 있는 것이다.

다음은 asyncio.Future 클래스가 제공하는 몇 가지 속성이다.

  • result() – 결과를 반환한다. 코루틴이 아니므로 await를 사용하지 않는다. 만약 중간에 취소된 작업이라면 예외(CancelledError가 일어난다.)
  • done() – 작업이 완료되었는지를 확인한다. 취소된 경우도 True를 리턴한다.
  • cancelled() – 취소되었는지를 확인한다.
  • add_done_callback() – 완료될 때 콜백을 추가한다. 만약 이미 완료되었다면 추가되자마자 바로 콜백이 실행된다.
  • remove_done_callback() – 등록된 콜백을 제거한다.
  • cancel() – 작업을 취소한다. 만약 이미 완료/취소되었다면 False를 리턴한다.
  • get_loop() – 연결된 이벤트 루프를 반환한다.

concurrent.futures.Future와 같은 기조 아래에서 디자인되었기 때문에 해당 클래스에서도 거의 비슷한 메소드들을 쓸 수 있다. 하지만 이 둘은 디자인적으로 비슷할 뿐이지 내부적인 작동방식은 완전히 다르므로 asyncio.Future 객체를 concurrent.futures 의 API와 섞어서 쓸 수는 없다.

asyncio.Future는 awaitable이기 때문에 결과값을 얻기 위해서는 x = f.result() 라고 쓰는 대신에 x = await f 라고도 할 수 있다.

asyncio.Task 는 이 클래스를 상속했기 때문에 같은 메소드를 쓸 수 있다. 따라서 asyncio 내의 어떤 함수가 코루틴을 리턴하는지, Task를 리턴하는지, Future를 리턴하는지는 많은 경우에 크게 신경 쓸 필요가 없다. 공식 문서에도 대부분 awaitable을 리턴한다고만 한다. 즉 거의 대부분의 경우 await만 쓰는 것으로 충분하며, 대부분 Task 혹은 Future가 리턴될 것으로 보면 된다.

예외적으로 asyncio.run_corutine_threadsafe(co, loop) 함수가 있다. 이는 asyncio의 함수이면서, 주어진 코루틴을 다른 스레드에서 실행할 수 있는 스레드 안전한 방법인데, ‘다른 OS 스레드’를 사용하기 때문에 concurrent.futures.Future 객체를 리턴한다.