C++ 20 Coroutines 핵심 요약 정리



C++ Coroutines을 의미있게 사용하려면

실제 비동기 작업의 결과를 받아서 처리하는 정도 수준의 예제 코드로 학습해야 한다.


그러기 위해선 코루틴을 완벽하게 이해해야 한다.

따라서 이 포스팅의 모든 예제들을 완벽히 이해하는 것을 추천한다.


앞서 기술한 내용들을 재정리 해보자면 다음과 같다.


1. 코루틴 함수로 인식되는 조건

C++ 컴파일러는 반드시 다음 3가지 코루틴 키워드 중 하나라도 함수 본문 내에 존재할 때에만 코루틴 함수로 취급한다.

  • co_await
  • co_yield
  • co_return

 

2. 코루틴 함수 호출 시 동작

  • 코루틴 함수의 반환 타입인 에 사용되는 코루틴 객체의 promise_type::get_return_object() 메서드가 호출된다.
    (보통은 get_return_object 메서드에 의해 코루틴 객체가 생성된다. 즉, 코루틴 객체의 생성자를 호출함)
  • promise_type의 initial_suspend() 메서드가 호출된다.


3. 코루틴 객체의 조건

  • 코루틴 객체는 promise_type를 가지고 있다.
    promise_type에 대한 type alias가 존재하는 방식일 수도 있고,
     nested type definition 방식으로 직접 정의했을 수도 있고,
    멤버 변수로 가지고 있을 수도 있다.

    어찌됬건 코루틴 프레임 내에 promise_type이 존재하도록 한다.
struct promise;
 
struct CoroResult : std::coroutine_handle<promise>
{
    using promise_type = ::promise; // type alias 방식
};
 
struct promise
{
    CoroResult get_return_object() { return { CoroResult::from_promise(*this) }; }
    std::suspend_always initial_suspend() noexcept { return {}; }
    std::suspend_always final_suspend() noexcept { return {}; }
    void return_void() {}
    void unhandled_exception() {}
};

// 혹은 다음과 같이 직접 정의해도 된다
struct CoroResult : std::coroutine_handle<promise>
{
	// nested type definition 방식
    struct promise
    {
        CoroResult get_return_object() { return { CoroResult::from_promise(*this) }; }
        std::suspend_always initial_suspend() noexcept { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() {}
    };
};
  • 코루틴 객체의 promise_type, 즉, CoroResult::promise_type은 다음 5가지 메서드를 필수로 정의해야 한다.
    - get_return_object(),
    - initial_suspend(),
    - final_suspend(),
    - return_value() 또는 return_void(),
    - unhandled_exception()  

    추가로
     promise_type에 await_transform() 메서드를 정의할 경우
    연산자 co_await의 피연산자인 awaitable object에 대해서 커스텀 동작을 구현할 수 있다.
    즉, co_await의 피연산자의 타입이 무엇이냐에 따라 어떤 awaitable object를 사용할지 선택할 수 있게 된다.
struct MyPromise {
    template<typename T>
    auto await_transform(T&& value) {
        if constexpr (is_awaitable_v<T>) {
            // 이미 awaitable인 경우, 로깅을 추가한 버전을 반환
            return LoggingAwaitable<T>{std::forward<T>(value)};
        } else {
            // awaitable이 아닌 경우, awaitable로 변환
            return MakeAwaitable<T>{std::forward<T>(value)};
        }
    }
};

...

co_await 54; // await_transform으로 이어져 LoggingAwaitable<int> 타입을 awaitable object로 사용한다.

 

 

※ 근데 코루틴 함수의 반환 타입에 promise_type이 없어도 동작하던데?(★)


boost asio에서 제공하는 코루틴 객체 awaitable<T>를 살펴보면 promise_type을 가지고 있지 않았다.

그런데도 문제 없이 코루틴이 잘만 동작한다.

왜 그런지는 boost asio를 살펴보던 중 수상한 코드를 발견했다.

직접 std 네임스페이스 안에 직접 coroutine_traits을 정의하고 그 안에 promise_type을 정의해 놓은 것을 발견했다.

이는 C++ 코루틴 메커니즘의 유연성 덕분에 가능한 것인데
Boost.Asio는 별도의 헤더 파일에 std::coroutine_traits를 특수화하여
awaitable 객체에 대한 promise_type을 정의하였다.

이는 C++ 표준에서 인정하고 권장하는 방식이다.(promise_type 결정 방식 참고)

<coroutine>헤더에 std::coroutine_traits이 정의되어 있는데,
이 trait의 주요 목적은 코루틴 객체가 아닌 타입들을 코루틴과 함께 사용할 수 있게 하는 것이다.

// <coroutine> 헤더의 coroutine_traits 정의 부분
template <class _Ret, class = void>
struct _Coroutine_traits {};

template <class _Ret>
struct _Coroutine_traits<_Ret, void_t<typename _Ret::promise_type>> {
    using promise_type = typename _Ret::promise_type;
};

_EXPORT_STD template <class _Ret, class...>
struct coroutine_traits : _Coroutine_traits<_Ret> {};


/* coroutine_traits의 상속 내용을 합치면 다음과 같다.
  template<class R, class... ArgTypes>
  struct coroutine_traits {
    using promise_type = typename R::promise_type;
  };
*/

위 코드를 잘 살펴보면 std::coroutine_traits의 첫번째 템플릿 인자를 promise_type으로 지정하고 있다.

그래서 기존 타입들(예: std::future)을 코루틴 반환 타입으로 사용할 수 있는 수단을 제공한다.

예를 들어, 내 프로젝트의 임의의 위치에 다음 코드를 가지고 있으면

std::future<T>도 코루틴 함수 반환 타입으로 사용가능하다는 뜻이다.

namespace std {

  template<class T, class... Args>
  struct coroutine_traits<std::future<T>, Args...> 
  {
  
    struct promise_type {
      std::promise<T> p;
      std::future<T> get_return_object() { return p.get_future(); }
      std::suspend_never initial_suspend() { return {}; }
      std::suspend_never final_suspend() noexcept { return {}; }
      void return_value(T value) { p.set_value(value); }
      void unhandled_exception() { p.set_exception(std::current_exception()); }
    };
    
  };
  
}


...
// std::future<bool>를 반환 타입으로 갖는 코루틴 함수
std::future<bool> AsyncExecute(redis::request req)
{
   // 코루틴 본문
   auto result = co_await SomeAsyncOperation();
   // 다른 작업 수행
   bool isSucceeded = ProcessResult(result);
   co_return isSucceeded;
}

C++은 사용법을 곱게 알려주지 않는다. 하루 종일 이것 저것 찾아보면서 겨우 여기까지 알아냈다 ㅠㅠ
능력이 되면 써라는 식이다. 표준 문서도 도움이 되지 않았다.
코루틴에 대해 공부하면서 동시에 이 글을 작성했는데
여기까지 작성하는데 대략 3일이 걸렸다.

C++을 사용해야만 하는 입장이라면 제대로 공부해서 사용하도록 하자.
하지만 C++을 안할 수 있다면 처음부터 안하는게 이득!!!

 

4. 연산자 co_await(앞에 awaitable object 에 대해서도 작성 필요)

1) 'co_await expr' 표현식의 평가

  • co_await연산자는 awaitable object를 피연산자로 받는다.
    즉, 'co_await expr' 표현식에서 expr은 awaitable object가 된다.

  • expr이 초기 중단점, 최종 중단점, 또는 co_yield에 의해 생성된 경우에는
    expr 자체가 이미 awaitable object이기 때문에,
    별도의 변환이나 추가적인 co_await 연사자를 호출하지 않고
    그대로 co_await의 피연산자가 될 수 있다는 의미

    만약, 현재 코루틴의 promise_type이 'await_transform()' 메서드를 가지고 있다면,
     awaitable object는 promise_type.await_transform(expr)이 된다.

    - 그 외의 경우, awaitable은 그대로 'expr'이다
struct MyCoroutine {
    struct promise_type {
        std::suspend_always initial_suspend() { return {}; } // 초기 중단점
        std::suspend_always final_suspend() noexcept { return {}; } // 최종 중단점
        void return_void() {}
        void unhandled_exception() {}
    };
};

// 코루틴 함수 정의
MyCoroutine my_coroutine() {
    co_await std::suspend_always{};  // 중단점
    co_return;  // 최종 중단점
}

위 코드에서 co_await std::suspend_always {}는 명시적으로 중단을 의미하며,
std::suspend_always 자체가 awaitable 객체이다.
따라서 'expr'은 std::suspend_always**이고, 'co_await expr'에서 expr이 그대로 awaitable 객체로 취급된다.

2) co_await 연산자의 동작 과정

  • 만약 expr이 await_transform() 메서드를 정의하고 있다면 피연산자를 변환한다.
    promise_type에 await_transform()가 정의되어 있지 않다면 이 단계는 건너뜀.

  • operator co_await()에 대한 overload resolution이 진행된다.
    즉, expr operator co_await()를 가지고 있는 경우 이 연산자를 호출하여 awaitable을 변환한다.
    참고로 operator co_await()는 awaitable object의 멤버 연산자일수도 있고, 비멤버 연산자일수도 있다.
#include <coroutine>
#include <iostream>
#include <thread>
#include <chrono>

// 1. operator co_await를 멤버로 정의하는 클래스
struct MemberCoAwait {
    bool await_ready() { return false; }
    void await_suspend(std::coroutine_handle<>) { std::cout << "MemberCoAwait: Suspended\n"; }
    int await_resume() { return 42; }

    auto operator co_await() { return *this; } // 멤버 연산자
};

// 2. 비멤버 operator co_await에 전달될 수 있는 타입
struct NonMemberCoAwait {};
auto operator co_await(NonMemberCoAwait&&) {
    struct Awaiter {
        bool await_ready() { return false; }
        void await_suspend(std::coroutine_handle<>) { std::cout << "NonMemberCoAwait: Suspended\n"; }
        int await_resume() { return 24; }
    };
    return Awaiter{};
}

// 3. Promise::await_transform을 통해 변환 가능한 타입
struct CustomAwaitable {
    int value;
};

struct Promise {
    auto await_transform(CustomAwaitable ca) {
        struct Awaiter {
            int value;
            bool await_ready() { return false; }
            void await_suspend(std::coroutine_handle<>) { std::cout << "CustomAwaitable: Suspended\n"; }
            int await_resume() { return value; }
        };
        return Awaiter{ca.value};
    }
};

// 코루틴을 반환하는 함수의 반환 타입
struct Task {
    struct promise_type : Promise {
        Task get_return_object() { return {}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() {}
    };
};

// 코루틴 함수 예제
Task example_coroutine() {
    std::cout << "Coroutine started\n";
    
    // MemberCoAwait 사용
    int result1 = co_await MemberCoAwait{};
    std::cout << "Result1: " << result1 << "\n";

    // NonMemberCoAwait 사용
    int result2 = co_await NonMemberCoAwait{};
    std::cout << "Result2: " << result2 << "\n";

    // CustomAwaitable 사용
    int result3 = co_await CustomAwaitable{100};
    std::cout << "Result3: " << result3 << "\n";

    std::cout << "Coroutine ended\n";
}

// await_suspend의 다양한 반환 타입 예제
struct VoidAwaiter {
    bool await_ready() { return false; }
    void await_suspend(std::coroutine_handle<>) {
        std::cout << "VoidAwaiter: Suspended\n";
    }
    void await_resume() {}
};

struct BoolAwaiter {
    bool await_ready() { return false; }
    bool await_suspend(std::coroutine_handle<>) {
        std::cout << "BoolAwaiter: Suspended\n";
        return false; // 즉시 재개
    }
    void await_resume() {}
};

struct HandleAwaiter {
    bool await_ready() { return false; }
    std::coroutine_handle<> await_suspend(std::coroutine_handle<> h) {
        std::cout << "HandleAwaiter: Suspended\n";
        return h; // 현재 코루틴을 재개
    }
    void await_resume() {}
};

Task await_suspend_examples() {
    co_await VoidAwaiter{};
    co_await BoolAwaiter{};
    co_await HandleAwaiter{};
}

int main() {
    example_coroutine();
    await_suspend_examples();
    return 0;
}
  • await_ready() 메서드가 호출되어 비동기 작업이 즉시 완료 가능한지 확인한다.
    반환 값이 true인 경우 다음 두 단계를 건너뛰어 즉시 await_resume()메서드를 호출하며,
    반환 값이 false인 경우 다음 단계인 await_suspend()메서드를 호출한다.

  • await_ready()메서드가 false를 반환하면 await_suspend() 메서드가 호출되는데
    코루틴을 제어하는데 사용되는 std::coroutine_handle<T>이 인자로 넘어온다.
    여기서 비동기 작업에 대한 진행 여부를 확인하고 완료될때까지 대기하다가
    완료되면 이 핸들에 std::coroutine_handle<T>::resume()메서드를 호출하여
    다음 단계인 await_resume()메서드를 호출한다.

  • await_resume()은 코루틴을 재개한다.
    이때 값을 반환할 수도 있다.

 

5. 연산자 co_yield

co_yield는 주로 제너레이터 패턴을 구현할 때 사용된다.

co_yield는 co_await처럼 비동기 작업이 결과를 반환할 때까지 기다리면서
값이 준비되었는지 확인한다거나 하는 동작은 지원하지 않는다.

  • co_yield 연산자를 사용하기 위해 yield_value()를 정의해야 한다.
    yield_value는 반드시 awaitable 객체를 반환해야 한다.
    가장 흔한 반환 타입은 std::suspend_always다.
  • co_yield expr은 내부적으로 'co_await promise_type.yield_value(expr)' 로 변환된다.

    'co_await expr'에서  expr이 awaitable object로 평가되는 경우
    이는 promise_type.await_suspend()으로 치환된다고 했었다.

    그런데  co_yield이므로 await_suspend() 대신 yield_value()가 호출된다고 생각하면 된다.
#include <coroutine>
#include <exception>
#include <iostream>
#include <chrono>
#include <thread>

template<typename T>
class generator {
public:
    struct promise_type {
        T current_value;
        bool value_ready = false;
        
        generator get_return_object() {
            return generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        
        std::suspend_always yield_value(T value) {
            std::cout<<"promise::yield_value()"<<std::endl;
            current_value = value;
            value_ready = true;
            return {};
        }
        
        void unhandled_exception() {
            std::cout<<"promise::unhandled_exception()"<<std::endl;
            std::terminate();
        }
        
        void return_void()
        {
            std::cout<<"promise::return_void()"<<std::endl;
        }
    };
    
    generator(generator&& other) noexcept : handle(other.handle) {
        std::cout<<"generator(generator&& other)"<<std::endl;
        other.handle = nullptr;
    }
    
    ~generator() {
        if (handle)
            handle.destroy();
    }
    
    class iterator {
    public:
        void operator++() {
            std::cout<<"iterator::operator++()"<<std::endl;
            if (handle) {
                handle.promise().value_ready = false;
                handle.resume();
                wait_for_value();
            }
        }
        
        T operator*() const {
            std::cout<<"iterator::operator*()"<<std::endl;
            return handle.promise().current_value;
        }
        
        bool operator==(std::default_sentinel_t) const {
            std::cout<<"iterator::operator==(std::default_sentinel_t)"<<std::endl;
            return !handle || handle.done();
        }
        
        explicit iterator(std::coroutine_handle<promise_type> h) : handle(h) {
            std::cout<<"iterator::iterator()"<<std::endl;
            wait_for_value();
        }
        
    private:
        std::coroutine_handle<promise_type> handle;

        void wait_for_value() {
            std::cout<<"iterator::wait_for_value()"<<std::endl;
            while (handle && !handle.done() && !handle.promise().value_ready) {
                std::this_thread::sleep_for(std::chrono::milliseconds(10));
            }
        }
    };
    
    iterator begin() {
        std::cout<<"generator::begin()"<<std::endl;
        if (handle) {
            handle.resume();
        }
        return iterator{handle};
    }
    
    std::default_sentinel_t end() {
        std::cout<<"generator::end()"<<std::endl;
        return {};
    }
    
private:
    std::coroutine_handle<promise_type> handle;
    
    explicit generator(std::coroutine_handle<promise_type> h) : handle(h)
    {
        std::cout<<"generator(std::coroutine_handle<promise_type> h)"<<std::endl;
    }
};

// 값을 생성하는데 시간이 걸리는 제너레이터 예시
generator<int> slow_count_to(int n) {
    for (int i = 1; i <= n; ++i) {
        co_yield i;
    }
}

int main() {
    auto gen = slow_count_to(5);
    for (int value : gen) {
        std::cout << "Generated value: " << value << std::endl;
    }
    return 0;
}





우선 co_await연산자의 피연산자는 awaitable object여야 한다.

awaitable object는 다음 메서드를 정의해야 한다.

  • await_ready()
  • await_suspend(std::coroutine_handle<>)
  • await_resume()

 

만약 코루틴의 promise 타입이  await_transform 메서드를 정의한 경우,
이 메서드가 co_await 표현식을 변환하는 데 사용된다.





6. 코루틴 핸들 std::coroutine_handle<promise_type> 관련


코루틴 핸들 std::coroutine_handle의 템플릿 인자로 전달되는 promise_type은

std::coroutine_handle::promise() 메서드를 이용하여 가져올 수 있다.








7. 코루틴 핸들 std::coroutine_handle<promise_type> 관련

 

  • 코루틴 핸들 std::coroutine_handle의 템플릿 인자로 전달되는 promise_type은

    std::coroutine_handle::promise() 메서드를 이용하여 가져올 수 있다.


  • std::coroutine_handle::done()이 true를 반환하려면 코루틴이 완전히 종료되어야 한다.

    자주하는 실수 중에 final_suspend() 메서드의 반환 타입을 std::suspend_always로 설정해두면

    코루틴 본문의 실행이 완료된 후에 코루틴이 중단된다.

다음 예시는 promise_type::final_suspend()메서드의 반환 타입이 std::suspend_always로 되어 있어서

코루틴 객체가 블록 스코프를 벗어남으로 인해 소멸자가 호출될 때

아직 종료되지 않은 코루틴 핸들을 destroy()하게되어 예외를 발생시키는 예제이다

#include <coroutine>
#include <future>
#include <functional>
#include <iostream>
using namespace std::chrono_literals;

// 코루틴 객체
template <typename T>
struct AsyncResult
{
    struct promise_type
    {
        T result;
        std::exception_ptr exception;

        AsyncResult get_return_object()
        {
            return AsyncResult(std::coroutine_handle<promise_type>::from_promise(*this));
        }

        std::suspend_never initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }

        void unhandled_exception()
        {
            exception = std::current_exception();
        }

        void return_value(T value) { result = std::move(value); }
    };

    // 코루틴 핸들의 promise()메서드를 통해 비동기 작업의 결과를 반환받기 위해 필요
    // promise()메서드가 반환하는 것은 promise_type이다. 
    std::coroutine_handle<promise_type> coro;

    AsyncResult(std::coroutine_handle<promise_type> h) : coro(h)
    {
    }

    AsyncResult(AsyncResult&& other) noexcept : coro(other.coro) { other.coro = nullptr; }

    ~AsyncResult()
    {
        if (coro)
        {
            if (!coro.done())
            {
                std::cerr << "Warning: Destroying incomplete coroutine\n";
                throw std::runtime_error("coro is undone but destroyed");
            }
            coro.destroy();
        }
    }

    T get_result()
    {
        if (coro.promise().exception)
        {
            std::rethrow_exception(coro.promise().exception);
        }
        return std::move(coro.promise().result);
    }
};

template <typename T>
struct awaitable_wrapper
{
    std::future<T> future;

    bool await_ready() const { return future.wait_for(std::chrono::seconds(0)) == std::future_status::ready; }

    void await_suspend(std::coroutine_handle<> h)
    {
        std::thread([this, h]()
        {
            future.wait();
            h.resume();
        }).detach();
    }

    T await_resume() { return future.get(); }
};

template <typename Func, typename... Args>
auto make_awaitable(Func&& func, Args&&... args)
{
    using result_type = decltype(func(std::forward<Args>(args)...));
    // 이 구조는 결국 비동기 작업이 future를 반환해야 한다.
    // 혹은 비동기 작업에 대한 핸들을 반환하는 경우에는 이 핸들로 작업이 완료되었는지를 모니터링해야 한다.
    // 만약 콜백 핸들러를 넘기는 방식의 디자인이라면 결국 co_await의 피연산자로 사용할 수 없게 된다.
    auto future = std::async(std::launch::async, std::forward<Func>(func), std::forward<Args>(args)...);
    return awaitable_wrapper<result_type>{std::move(future)};
}

template <typename Func, typename... Args>
auto coroutine_wrapper(Func&& func, Args&&... args)
    -> AsyncResult<decltype(std::declval<Func>()(std::declval<Args>()...))>
{
    using result_type = decltype(std::declval<Func>()(std::declval<Args>()...));
    result_type result = co_await make_awaitable(std::forward<Func>(func), std::forward<Args>(args)...);
    co_return result;
}

// 일반적인 비동기 함수(std::future같은 것을 지원하지 않음)
int async_function(int x, int y)
{
    std::this_thread::sleep_for(std::chrono::seconds(2));
    return x + y;
}

// 메인 함수에서의 사용
int main()
{
    auto result = coroutine_wrapper(async_function, 10, 32);
    std::cout << "Result: " << result.get_result() << std::endl;
    std::this_thread::sleep_for(3s);
    return 0;
}
Posted by Elan
: