2020/10/04

c++20 Coroutine 배우기


올해 말까지는 c++20 표준이 공표될거라 해서 Big 4라 불리는 놈 중 하나인 Coroutine에 대해 배워 보기로 했다. 이 놈에게 관심을 가진 이유는 비동기 처리를 쉽게 할 수 있어 보여서다. 이를 테면 Signal & Slots를 callback을 사용하지 않고 구현할 수도 있을까 하는 궁금증... 

결론적으로 모든 것을 대체할 수는 없다는 생각에 이르렀지만 활용 범위는 꽤 늘어날 듯 싶다. 당장 게임 같은 곳에서는 UI와 로직을 분리할 수 있어서 꽤나 유용할 듯 싶다. 이외에도 이벤트 처리, generator, lazy-evaluation 알고리즘, 네트워크 등의 비동기 I/O에 활용될 수 있다. Asio 네트워크 라이브러리에서는 10년 전부터 매크로를 이용해서 c++20의 코루틴 구현 방식인 stackless 코루틴을 사용해왔단다. 세상엔 대단한 넘들이 많다. c++20의 코루틴은 pointer를 처음 배우는 것만큼 배우기도 쉽지 않다. 뭐, 알고 나면 별거 아닌게 되지만...

코루틴에 대해 제대로 정리하려면 너무 길어지므로, 이 글은 간단한 예 하나 만들어 본 것을 정리하는데 그친다. c++20의 코루틴이 복잡한 이유는 컴파일러가 생성해 주는 일종의 coroutine framework에 맞추어서 코딩해야 하기 때문이다. 숨겨진 로직이 많다는 것이다. 나중에 cppcoro와 같은 라이브러리가 표준으로 채택되면 사용자들은 동작 방식보다는 활용 방법만 배우면 될 것이다.   

코루틴 이해

Coroutine은 원래 main 루틴이 아닌  모든 subroutine을 포함하는 개념이란다. 좁은 의미로는 진입 지점을 여러 개 가질 수 있는 함수가 코루틴이다. 기존 함수는 시작(by-call)과 끝(return)만 있는데, 중단(suspend)했다가 다시 재개(resume)할 수 있는 기능이 추가된 함수로 이해하면 된다. suspend/resume이 필요한 이유는 실행 흐름을 비동기(asynchronous) 방식으로 제어하기 위해서이다. 결국, 코루틴은 실행 흐름을 논리적으로 제어하기 위한 함수이다. 좀 깊이 들어 가면 선점형(pre-emptive) 스케쥴링과 협력적(cooperative) 스케쥴링에 의한 multi-tasking 개념을 이해해야 한다. Thread와는 독립적인 개념인데, 기본적으로는 하나의 thread로 멀티태스킹을 할수 있다고 보면 된다. 비동기 네트워크 서버가 단일 쓰레드로 다수의 클라이언트를 처리하는 것을 떠올리면 된다.

3가지 키워드인 co_await, co_yield, co_return 중 하나라도 사용된 함수는 코루틴이 된다. main() 함수는 코루틴이 아니므로 3가지 키워드를 직접 사용할 수 없다. 코루틴 framework은 Promise Interface, Awaitable Interface, Coroutine Handle의 3가지 API를 이해해야 한다.  앞의 두 인터페이스는 사용자가 작성해야 한다. Handle은 컴파일러 내장 함수이고 <coroutine> 표준헤더를 참고하면 된다. Promise Interface는 코루틴 객체 class 내에 정의하여 코루틴의 동작 방식을 제어한다. Awaitable Interface는 co_await 키워드가 어떻게 동작할 지 정의하기 위한 것이다.  Handle은 코루틴의 재개(resume)와 소멸(destroy), 현재 상태(done), 메모리상의 위치(address) 등에 대한 인터페이스이다. 3가지 키워드는 3가지 API를 적절하게 사용하기 위해 필요한 것이다.

코루틴 사용 예

class SimpleTask
{
public:
  struct promise_type;
  using coro_handle = coroutine_handle<promise_type>;
  struct promise_type {
    auto get_return_object() { return SimpleTask{coro_handle::from_promise(*this)}; }
    auto initial_suspend() { return suspend_never{}; }
    auto return_void() { return suspend_never{}; }
    auto final_suspend() { return suspend_always{}; }
    void unhandled_exception() { std::terminate(); }
  };

  SimpleTask(SimpleTask&& r) = default; // ensure copy elision
  ~SimpleTask() { if(m_handle) m_handle.destroy(); }
  bool resume() { if(!m_handle.done()) m_handle.resume(); return !m_handle.done(); }

private:
  explicit SimpleTask(coro_handle handle) : m_handle(handle) {}
  coro_handle m_handle;
};

// For AsyncTimer, the total time required for coro_simple() should be 3 seconds???
// If you think so, try to make it~!!!
SimpleTask coro_simple()
{
  std::cout << "Hello\n";
  co_await 3s;
  std::cout << "  after 3 sec ...\n";
  co_await 2s;
  std::cout << "  after 5 sec ...\n";
  co_await 1s;
  std::cout << "  after 6 sec ...\n";
  std::cout << "Coroutine~!\n";
}

int main()
{
  std::cout << "+++ Start\n";
  SimpleTask task = coro_simple();
  while(task.resume());
  std::cout << "--- End\n";
}
위의 소스에서 coro_simple() 함수에 co_await 키워드가 사용됐으므로 코루틴이다. 코루틴 반환 객체(간단히, 코루틴 객체)인 SimpleTask class내에 정의된 promise_type이 Promise Interface이다. 일반 함수와 달리 SimpleTask를 return하는 부분이 안보이지만 컴파일이 잘 된다. 실제로 SimpleTask객체를 promise.get_return_object() 인터페이스 함수를 통해 코루틴 최초 중단 시와 완료 시 내부에서 반환하기 때문이다. co_await 키워드만 사용했지만 함수 맨 끝에 co_return이 있는 것과 같다. 일반 void 함수 끝에 return을 사용하지 않아도 되는 것과 유사하다. 사실, SimpleTask class는 미래엔 라이브러리로 제공될 부분이다. 사용자는 그것들을 이용하는 방법만 알면 된다.
c++은 너무 유연해서 코루틴도 일반 함수처럼 동작할 수 있지만, 기본적으로 코루틴은 한 번 이상 중단/재개가 일어난다고 보면 된다.  코루틴이 중단 된다고 프로그램이 중단되는 것이 아니다. 중단하는 이유는 원하는 결과를 기다리기 위한 것이다. 코루틴이 비동기적이라고 하는 이유이다. 가령 위의 co_await 3s; 부분은 코루틴을 중단하고 3초간 기다리라는 것이다. 중단되면 실행흐름이 caller인 main() 함수로 돌아간다. while 문에서 코루틴을 재개해서 중단된 지점에서 다시 시작하도록 한다.

Promise Interface

Promise Interface는 promise_type을 통해 코루틴 실행시 중단 및 재개 지점에서 호출되는 interface method 들을 정의하고 코루틴 자체의 동작 방식을 제어한다. std::future<>와 함께 쓰이는 std::promise<>와 유사하지만 좀더 확장된 개념으로 보면 된다. promise_type 객체는 "약속"이라는 이름과 같이 비동기 함수 호출시 어떤 상태 값을 넘기기로 사전에 약속한 객체라고 이해하면 될듯하다.
즉, 컴파일러가 코루틴 키워드 하나를 만나면 Promise Interface를 통해 아래의 pseudo-code와 같은 전체 코루틴 골격을 만들어 준다. 사용자가 작성한 코루틴 코드 전후로 뭔가를 co_await 하고 있다.
코루틴 함수가 호출되면 필요시 Coroutine Frame을 heap에 allocation 하고 promise_type 객체를 생성하는 등 컴파일러가 해주는 일이 많은데 아래 소스의 링크를 참고하자. 참고로 Coroutine Handle은 Coroutine Frame에 대한 pointer이다. 아래의 로직에서 initial/final suspend() 함수 내에서 코루틴이 중단/재개 될 수 있다고 보면 된다.
// Psuedo-code for Coroutine
// - source: https://lewissbaker.github.io/2018/09/05/understanding-the-promise-type

{
  co_await promise.initial_suspend();  // 사용자 코드 실행 전 뭔가 기다린다.
  try
  {
    <body-statements>                  // 사용자 코드(coro_simple() body) 실행
  }
  catch (...)
  {
    promise.unhandled_exception();     // 예외 처리
  }
  
FinalSuspend:
  co_await promise.final_suspend();    // 사용자 코드 실행 후에도 뭔가 기다린다. 
}

Awaitable Interface

이제 Awaitable Interface도 마저 예를 통해 보자. 3가지 키워드 중 co_await는 overloading이 가능한 단항 연산자(unary operator)이다. 위의 코루틴 pseudo-code에서도 co_await가 뭔가 중요한 일을 하고 있다. Awaitable은 co_await 연산을 적용할 수 있는 c++20 Concept(특정 조건들을 충족하는) type이다. Awaiter type은 Awaitable type의 3가지 Interface 함수를 구현한 Concept type이다. 아래 소스에 3가지 함수가 나타나 있다. 이들은 코루틴이 중단 후 caller에게 돌아갈 지 아니면 코루틴을 재개(resume)할지 제어하기 위한 것이다.

참고로, 아래의 소스는 co_await 연산자 overloading을 통해 시간이 입력값인 경우 AsyncTimer로 동작하도록 하기 위한 것이다. 생각보다 AsyncTimer가 코루틴에서 제대로 동작하도록 만들기 어렵다는 것을 알았다. 그래도 나름 간단하게 사용할 수 있어서 편한 점도 있다. 보통은 코루틴 객체 class 내부에서 co_await 연산자를 오버로딩함으로써 해당 객체로 부터 Awaiter 객체를 얻어내면 되는데 시간은 코루틴 객체가 아니지만 co_await 할 수 있어야 하기 때문에 전역 co_await 연산자를 오버로딩한 것이다.
// Overloaded co_await operator for time duration - AsyncTimer. (e.g. co_await 3s;)
// Do you have a better way for AsyncTimer to be used along with coroutine?
// A thread for timer can be used, but if the coroutine is resumed all the left code resumed
//   before the timer expires - how to suspend again and return to the caller?
template <typename TR, typename TP>
inline auto operator co_await(const std::chrono::duration<TR, TP>& duration) noexcept
{
  struct TAwaiter {
    using TClock = std::chrono::high_resolution_clock;
    using TTime = decltype(TClock::now());
    std::chrono::duration<TR, TP> duration;
    TTime start;
    TAwaiter(const std::chrono::duration<TR, TP>& d) : duration(d), start(TClock::now()) {}
    bool await_ready() noexcept { return duration.count() < 1; } // for 0 or negative time
    auto await_suspend(coroutine_handle<>) noexcept {} // just suspend and return to caller
    auto await_resume() noexcept {                     // sleep for left time when resumed
      if(duration.count() < 1) return;
      auto te = std::chrono::duration_cast<std::chrono::microseconds>(TClock::now() - start);
      std::this_thread::sleep_for(duration - te);
    }
  };
  return TAwaiter{duration};
}
co_await <expr> 구문은 <expr>을 evaluation 한 후, Awaiter 객체를 얻어 아래의 pseudo-code에 보인 Awaitable Interface 로직에 따라 제어 흐름이 동작하도록 한다. 가령, 아래는 promise.initial_suspend()를 실행한 결과 값에 co_await 연산을 적용해서 Awaiter 객체를 얻은 후, 아래의 pusedo-code 로직에 따라 결과 값을 얻는다. 결과 값/type은 await_resume()이 반환하는 값/type이다.
co_await promise.initial_suspend(); 
앞의 SimpleTask class에서 이 함수는 suspend_never 객체를 return하고 있다. 이 놈은 suspend_always와 함께 <coroutine> 헤더에 정의된 가장 기본적인 Awaitable type이다. 아래의 pseudo-code 로직을 적용하면, 
  • co_await suspend_never{};   => 코루틴을 중단시키지 않고 즉시 재개
  • co_await suspend_always{}; => 코루틴을 중단시키고 caller에게 제어 흐름을 넘김
이라는 뜻이 된다.

마지막 키워드인 co_yield <expr>의 의미는 아래와 똑같다.
co_await promise.yield_value(<expr>);
yield_value(<expr>) 함수는 co_return <expr> 키워드가 사용하는  return_value(<expr>) 또는 return_void() 대신 사용해야 하는 Promise Interface 함수이다.
// Psuedo-code for "co_await <expr>;" statement.
// - source: https://blog.panicsoftware.com/co_awaiting-coroutines

Awaiter&& awaiter = get_awaiter(promise, <expr>);
if (!awaiter.await_ready()) {
  <suspend-coroutine>              // suspend 상태에서만 코루틴 resume() 및 destroy() 가능
  try { // for a <result type> of awaiter.await_suspend(coroutine_handle)
    if constexpr <void type> 
      awaiter.await_suspend(coroutine_handle);
    if constexpr <bool type> {
      bool await_suspend_result = awaiter.await_suspend(coroutine_handle);
      if(!await_suspend_result) goto <resume-point>;
    }
    if constexpr <coroutine_handle type> { // tail-call optimization 적용됨
      another_coro_handle = awaiter.await_suspend(coroutine_handle<promise_type> h);
      another_coro_handle.resume();
    }
    <return-to-caller-or-resumer>  // 여기에 닿지 않고 코루틴이 완료되면 handle이 자동 소멸됨
  } catch (...) {
    std::exception_ptr exception = std::current_exception();
    if(exception) std::rethrow_exception(exception);
  }
}

<resume-point> :
return awaiter.await_resume();     // co_await type을 여기서 결정
위의 로직을 컴파일러가 노래로 알려 주었다.

< 함께 기다려 (co_await something) > - c++20 컴파일러가 인간들에게.

무언가 함께 기다리는 것은              무언가 함께 기다리는 것은
그것을 핑계로                                 가슴이 아파도
잠깐 쉬게 하려는 것.                        홀로 보내야만 하는 것.

준비된 이에게는                              기약없이 떠나지만
선물 주어                                        언젠가는 함께
기꺼이 다시 시작하도록,                  기꺼이 다시 시작하도록,

아니면                                            시련은
잠시 그 자리에 멈춰 세워,                거세지만 내 의지로 맞서,

마음을 비우거나 진실한 이는            어둡고 거친 길에 지치고 외로워도  
사랑하는 이에 돌아 가도록,              잠시나마 쉴 수 있도록,
새 꿈을 가진 이엔                             끝없이 펼쳐진 갈래길을 헤매어도  
희망 찾아 떠나 가도록,                    가다 보면 만날 수 있도록,
거짓된 이도                                     또 다시는
선물 주어 다시 시작하도록.              그 누구도 기다리지 않도록.