[C++ 20] Coroutines #3 - 요약 정리
C++ 2024. 9. 22. 00:20 |
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;
}
'C++' 카테고리의 다른 글
[C++ 20] Coroutines #4 - 연습 예제(작성 중) (0) | 2024.09.22 |
---|---|
[C++ 20] Coroutines #2 - 표준 요약 (0) | 2024.09.21 |
[C++ 20] Coroutines #1 - 배경 지식 편 (0) | 2024.09.21 |
[C++] 코루틴(Coroutine) 분석(작성 중) (0) | 2024.09.15 |
[C++] 자주 발생하는 표준 컨데이너 관련 Error (0) | 2023.11.19 |