[C++ 20] Coroutines #2 - 표준 요약
https://en.cppreference.com/w/cpp/language/coroutines
Coroutines (C++20) - cppreference.com
A coroutine is a function that can suspend execution to be resumed later. Coroutines are stackless: they suspend execution by returning to the caller, and the data that is required to resume execution is stored separately from the stack. This allows for se
en.cppreference.com
중간중간에 등장하는 예제들을 잘 파악하도록 하자.
'※Tips : ' 표시가 붙은 내용은 개인적으로 이해를 돕기위해 추가한 내용이다.
'※ Sample : ' 표시가 붙은 코드는 개인적으로 이해를 돕기위해 추가한 예제 코드이다.
1. C++ 컴파일러가 코루틴으로 인식하는 조건
다음 예약어 중 한 가지라도 포함되어 있으면 코루틴으로 인식한다.(Stackless 구성을 위함)
- co_await
- co_yield
- co_return
2. 코루틴에서 사용할 수 없는 것들
C++ 코루틴에서 다음을 사용할 수 없다.
- Variadic arguments
- plain return statements
- placeholder return type( auto or Concept )
단, Trailing Rreturn Type은 가능
※ Sample :
// 1. Variadic arguments을 사용한 코루틴은 컴파일 되지 않는다
template<typename... Args>
std::generator<int> variadic_coroutine(Args... args) {
// 코루틴 내용
co_yield 1;
}
// 2. Plain return statements을 사용한 코루틴은 컴파일 되지 않는다
std::generator<int> plain_return_coroutine() {
co_yield 1;
co_yield 2;
return 3; // 컴파일 에러: 코루틴에서 일반 return 문을 사용할 수 없다
}
// 3. Placeholder return type (auto 또는 Concept)는 코루틴에서 사용할 수 없다
auto placeholder_return_coroutine() { // 컴파일 에러: 코루틴에서 auto 반환 타입을 사용할 수 없다
co_yield 1;
co_yield 2;
}
template<typename T>
concept Yieldable = std::is_integral_v<T>;
Yieldable placeholder_concept_coroutine() { // 컴파일 에러: 코루틴에서 concept를 반환 타입으로 사용할 수 없다
co_yield 1;
co_yield 2;
}
3. 코루틴으로 사용될 수 없는 것들
- Consteval functions
( ※Tips : 코루틴은 런타임에 실행되며, 실행 중에 상태를 일시 중단하거나 재개하는 특성을 가진다.
반면 consteval 함수는 반드시 컴파일 타임에 평가되어야 하므로,
런타임에서 중단이나 재개가 불가능하기 때문에 코루틴으로 정의할 수 없다.) - Constexpr functions
( ※Tips : Consteval functinos과 마찬가지 이유) - Constructors
( ※Tips : 코루틴은 실행 중에 중단될 수 있는 함수이므로,
코루틴은 도중에 중단될 수도 있고, 중단 후 재개를 해주지 않으면
영영 코루틴 함수 본문을 완료하지 못할 수도 있기 때문이다.
생성자를 코루틴으로 만들었다고 가정했을 때
객체가 완전히 초기화되기 전에 중단될 경우
객체의 초기화가 불완전한 상태로 끝날 수도 있다.
이를 방지하기 위해 코루틴은 생성자 내에서 사용할 수 없다.
단, 생성자 내에서 코루틴을 호출하는 것은 가능하니 혼동하지 말자.) - Destructors
( ※Tips : Constructors와 마찬가지 이유) - main function
( ※Tips : main함수는 프로그램의 진입점으로 프로그램의 실행 흐름을 제어하는 중요한 함수이다.
따라서 main함수가 코루틴이라면 중단 후 다시 재개할 수가 없게 된다.)
※ Sample :
// 1. Consteval functions:
consteval std::generator<int> consteval_coroutine() { // 컴파일 에러
co_yield 1;
co_yield 2;
}
// 2. Constexpr functions:
constexpr std::generator<int> constexpr_coroutine() { // 컴파일 에러
co_yield 1;
co_yield 2;
}
// 3. Constructors:
class MyClass {
public:
MyClass() : std::generator<int> { // 컴파일 에러
co_yield 1;
co_yield 2;
}
};
// 4. Destructors:
class MyClass {
public:
~MyClass() std::generator<void> { // 컴파일 에러
co_yield;
}
};
// 5. main fuctions:
std::generator<int> main() { // 컴파일 에러
co_await awaitable_func();
co_yield 1;
co_yield 2;
co_return 0;
}
4. 코루틴 실행
1) 코루틴과 연관된 것들
- 코루틴 내부에서 조작되는 promise_type 객체 :
코루틴은 promise_type 객체를 통해 결과 또는 예외를 제출한다.
여기서 말하는 promis_type 객체는 std::promise와는 관련이 없다.
( ※Tips : 코루틴이 결과를 반환하려면 promise_type내에
return_value() 또는 return_void()메서드를 정의해야 한다.
또한 코루틴에서 발생한 예외에 대한 커스텀 동작을 정의하고 싶은 경우
unhandled_exception()메서드를 정의하면 된다) - 코루틴 외부에서 조작되는 coroutine handle:
코루틴의 중단을 재개(Resume)하거나
코루틴 프레임을 파괴(destroy)하는 데 사용되는 *소유권이 없는 핸들*이다.
( ※Tips : *소유권이 없는 핸들*이란?
std::coroutine_handle<T>을 의미하며 이 핸들의 역할은 코루틴을 조작(재개/파괴)할 수는 있지만
코루틴 프레임의 수명을 관리하지는 못한다.
코루틴 핸들은 코루틴 객체의 멤버로써 명시적으로 정의할 수도 있지만,
그렇지 않은 경우에도 컴파일러가 코루틴 프레임(코루틴 객체) 내부에 암시적으로 코루틴 핸들을 멤버로 생성한다.
또한 promise_type의 await_suspend()메서드는 항상 코루틴 핸들을 인자로 넘겨받기 때문에
중단 점을 만났을 때 await_suspend()메서드를 통해 코루틴 핸들을 조작하여 다음 동작을 직접 정의할 수도 있다) - 코루틴 상태(coroutine state) :
내부적으로 동적 할당된 스토리지(할당이 최적화되지 않는 한)와 객체는 다음을 포함한다.
- promise_type 객체
- 매개 변수(모두 값으로 복사됨)
- 어디서 재개(resume) 해야 할지 알기 위한 현재 중단점(suspension point)
- 소멸(destory)시킬 Scope 내의 로컬 변수
- 로컬 변수 및 현재 일시 중단 지점까지의 수명을 가진 임시적인 것들
( ※Tips : 내부적으로 동적 할당된 스토리지란?
코루틴이 중단&재개 시 코루틴 상태를 저장하기 위해 할당된 공간인 '코루틴 프레임'을 말한다.
'할당이 최적화되지 않은 경우'란 HALO에 국한된 것이 아니다)
2) 코루틴 실행 시작 과정
- new 연산자를 사용하여 코루틴 상태 객체 할당
( ※Tips : new 연산자를 코루틴 객체 내부에 명시적으로 정의할 수도 있음) - 코루틴 함수의 모든 매개변수를 coroutine 객체에 복사 :
by-value 매개변수들은 이동 또는 복사되며,
by-reference 매개변수들은 그대로 참조형식을 유지함.
따라서 참조된 객체의 수명이 끝났음에도 coroutine이 재개되면 해당 참조 객체는 댕글링이 될 수 있으니 주의.
( ※Tips : 특히, 람다 캡쳐로 코루틴 함수에 by-reference 매개변수를 전달한 경우
람다가 소멸하면 해당 참조도 소멸하므로 웬만하면 복사하여 넘길 것. 5번 예제 참고) - promise_type 객체에 대한 생성자를 호출한다.
promise_type에 모든 코루틴 매개변수를 받는 생성자가 있는 경우,
복사 후 코루틴 인자와 함께 해당 생성자가 호출된다.
그렇지 않으면 기본 생성자가 호출된다.
( ※Tips : promise_type이 명시적 생성자가 없는 aggregate type인 경우 aggregate initialization을 사용할 수 있음) - promise_type.get_return_object()를 호출하고 결과를 로컬 변수에 보관한다.
해당 호출의 결과는 코루틴이 처음 일시 중단될 때 호출자에게 반환된다.
이 단계까지 발생한 모든 예외는 promise에 배치되지 않고 호출자에게 다시 전파된다.
( ※Tips : 코루틴 함수의 최초 호출 시점에
promise_type.get_return_object() 메서드에 의해 코루틴 객체가 생성되는데
이 단계는 아직 코루틴 함수가 실행된 것이 아니기 제어권을 가진 caller측에 예외가 발생되는 것이다) - promise_type.initial_suspend()를 호출하고 그 결과를 co_await 한다.
일반적인 promise_type은lazily-started coroutines의 경우 std::suspend_always를 반환하며,
eagerly-started coroutines의 경우 std::suspend_never를 반환한다.
( ※Tips : initial_suspend()의 반환 타입을 std::suspend_always로 한 경우
코루틴이 생성 되고 나서 일단 중단된다는 말이다.
반환 타입이 std::suspend_never이면 코루틴은 생성되자마자 실행된다. 즉, await_resume()이 호출된다.) - co_await promise_type.initial_suspend() 호출 후 coroutine이 재개되면 코루틴 본문의 실행을 시작한다.
( ※Tips : promise_type.initial_suspend()의 반환 타입을 std::suspend_always로 정의한 경우
코루틴 객체 생성 후 명시적으로 코루틴을 재개하는 방법은
코루틴 객체의 핸들에 resume()메서드를 호출하는 것이다.)
5. Coroutine 인자가 댕글링(dangling)이 되는 과정 예제
#include <coroutine>
#include <iostream>
// 코루틴 객체(promise) 전방 선언
struct promise; // 코루틴 상태 객체(이름이 promise일 필요는 없음)
// 코루틴 객체 promise의 제어를 위한 핸들러std::coroutine_handle<promise>를 상속을 이용하여 구현함
struct coroutine : std::coroutine_handle<promise>
{
using promise_type = ::promise;
};
// 코루틴 객체로 사용하기 위한 필수 Interface 정의
struct promise
{
// get_return : 코루틴 객체를 생성하는 역할
coroutine get_return_object() { return {coroutine::from_promise(*this)}; }
// initial_suspend : 코루틴 함수 실행 시작시 중단 여부 결정
(반환 타입이 std::suspend_always이면 무조건 중단하며, std::suspend_never이면 중단하지 않음)
std::suspend_always initial_suspend() noexcept { return {}; }
// final_suspend : 코루틴 함수 종료 시 중단 여부 결정
(반환 타입이 std::suspend_always이면 무조건 중단하며, std::suspend_never이면 중단하지 않음)
std::suspend_always final_suspend() noexcept { return {}; }
// return_void : 값을 반환하지 않는 코루틴을 위한 함수
void return_void() {}
// unhandled_exception : 예외 처리를 위한 함수
void unhandled_exception() {}
};
struct S
{
int i;
coroutine f()
{
std::cout << i;
co_return; // 코루틴 함수 f는 중단 없이 한번에 끝나는 로직으로 구성되어있음
}
};
void bad1()
{
coroutine h = S{0}.f(); // 코루틴 생성 후 즉시 실행
// S{0}는 생성되자마자 실행되고 이미 종료되었음
h.resume(); // 이미 종료된 코루틴을 재개하면서 S::i에 접근하여 uses 'S::i' after free오류 발생
h.destroy();
}
coroutine bad2()
{
S s{0};
return s.f(); // 이미 반환된 코루틴은 use-after-free오류를 저지르지 않고서는 resume할 수 없음
// S는 bad2함수가 반환하는 순간 소멸해버리는데 반환하겠다는 것은 외부에서 resume하겠다는 의도로 해석한 것인듯?
}
void bad3()
{
coroutine h = [i = 0]() -> coroutine
{
std::cout << i;
co_return;
}(); // immediately invoked
// lambda destroyed
h.resume(); // uses (anonymous lambda type)::i after free
h.destroy();
}
void good()
{
coroutine h = [](int i) -> coroutine // make i a coroutine parameter
{
std::cout << i;
co_return;
}(0);
// lambda destroyed
h.resume(); // no problem, i has been copied to the coroutine
// frame as a by-value parameter
h.destroy();
}
※Tips : 예제 코드 설명
위 예시에서 bad3()과 good()은 헷갈릴 수 있으므로 설명해 보겠다.
위 예시의 promise_type.initial_suspend() 함수의 반환타입을 std::suspend_always으로 정의하였으므로
coroutine 객체를 생성하고 initial_suspend()가 호출되면 coroutine이 중단된다.
따라서 코루틴 본문을 실행하기 위해 resume()을 호출하여 코루틴을 재개시켜주어야 한다.
그런데 bad3()에서는 coroutine 객체 생성 시
coroutine객체 내부에서 참조하는 i를 람다 캡처로 가져온다.
그런데 람다로 코루틴을 생성하고 즉시 실행(immediately invoke)하지만
initial_suspend() 함수의 반환 타입이 std::suspend_always이기 때문에
코루틴은 일단 중단된다.
그리고 prvalue인 람다는 소멸한다.
따라서 람다 객체가 캡처해서 참조로 가지고 있던 i는
람다 객체가 소멸되면서 dangle 상태가 된다.
반면 good에서는 람다 캡처가 아니라 매개변수 i로 value copy 해서 넘겨서
코루틴이 정상적으로 소유하고 있기 때문에 정상 동작하는 것이다.
만약 initial_suspend가 std::suspend_never이었다면?
bad3()은 즉시 실행이기 때문에 cout<< i;를 수행하고 아랫줄의 resume()에서 문제가 발생할 것이다.
왜냐하면 이미 completed 된코루틴에 resume을 시도하는 것은 정의되지 않은 동작(UB : Undefined Behavior)기 때문이다.
그리고 good함수에서 resume을 시도하는 시점에서는
이미 completed 된 코루틴에 resume을 시도하므로 UB가 된다.
※Tips : 코루틴 사용 시 주의 사항
Coroutine 객체 생성 시 lambda capture 기능으로 인자를 넘기지 않도록 한다.
람다로 전달 시 전달되는 instance의 생명 주기를 보장할 수 없기 때문이다.)
6. Coroutine이 중단 지점에 도달하면
앞서 얻은 반환 객체가 Caller/Resumer에게 제어권을 반환한다(필요하다면 코루틴의 반환 타입으로 암시적 변환 함).
7. 코루틴이 co_return 문에 도달했을 때
- co_return; 또는 co_return void; 인 경우 promise_type.return_void()를 호출한다.
( ※Tips : 'co_return'과 'co_return void'는 똑같다) - co_return expr;에서 expr이 void 타입 아닌 경우 promise_type.return_value(expr)을 호출한다.
- 모든 automatic storage duration 변수를 생성된 역순으로 소멸시킨다.
- promise_type.final_suspend()를 호출하고 결과를 co_await 한다.
코루틴 함수 본문의 끝에 도달하는 것(모든 문장을 실행한 후)은
co_return;을 명시적으로 사용하는 것과 동일하다.
( ※Tips : 반환 타입이 void이면 co_return; 또는 co_return void;을 생략해도 됨)
단, promise_type.return_void()를 정의하지 않으면 정의되지 않은 동작(Uudefined Behavior)이 발생한다.
( ※Tips : 코루틴이 값을 반환하지 않고 끝나도 promise_type.return_void()를 호출할 수 있어야 한다는 말)
함수 본문에 코루틴 키워드가 없는 함수는 반환 유형에 관계없이 코루틴으로 인식하지 않으며,
반환 유형이 (possibly cv-qualified) void가 아닌 경우 끝에 떨어지면 정의되지 않은 동작이 발생한다.
( ※Tips : 코루틴 함수가 아무것도 반환하지 않는 경우 본문의 끝에 co_return을 명시하지 않아도 된다.
단, promise_type은 return_void()메서드를 정의하고 있어야 하며, 그렇지 않으면 UB가 발생한다.
또한 코루틴 함수의 반환 타입이 void가 아닌 경우에는 promise_type은 return_value() 메서드를 정의하고 있어야 하며,
그렇지 않으면 UB가 발생한다. )
// assuming that task is some coroutine task type
task<void> f()
{
// 코루틴 키워드가 없으므로 코루틴으로 취급하지 않아서 undefined behavior
}
task<void> g()
{
co_return; // OK
}
task<void> h()
{
co_await g();
// OK, implicit co_return;
}
8. uncaught exception으로 종료된 경우, 다음을 실행 :
- 예외를 잡아서 catch 블록 내에서 promise_type.unhandled_exception()을 호출
( ※Tips : 잡히지 않는 예외 발생 시 promise_type.unhandled_exception()가 호출되며,
caller측으로 예외가 전파됨. 만약 이 메서드가 정의되지 않은 경우에는 바로 caller측으로 예외가 전파됨) - promise_type.final_suspend()를 호출하고 결과(예: 계속해서 재개하거나 결과를 게시하는 등)를 co_await 한다.
이 시점에서 코루틴을 재개하는 것은 정의되지 않은 동작이다.
( ※Tips : final_suspend()가 호출되고나면 코루틴은 completed상태가 되는데 이때 resume()을 하면 UB)
만약 코루틴 상태가 소멸되는 원인이
co_return 또는 잡히지 않는 예외(uncaught exception)를 통해 종료되었기 때문이거나
코루틴 핸들을 통해 소멸되었기 때문인 경우 다음을 수행 :
- promise 객체의 소멸자를 호출한다.
- 함수 파라미터 복사본의 소멸자를 호출한다.
- delete 연산자를 호출하여 코루틴 상태를 위해 사용된 메모리를 해제한다.(참고 : 코루틴 프레임 해제)
- Caller/Resumer에게 실행을 다시 전송한다.(참고 : 호출측에 제어권을 넘김)
9. 동적 할당
코루틴 상태는 non-array operator new를 통해 동적으로 할당된다.
( ※Tips : C++에는 두 가지 주요 형태의 operator new가 있다:
단일 객체를 위한 operator new와 배열을 위한 operator new[]이다.
그리고 코루틴은 단일 객체를 위한 operator new를 사용한다는 말이다.
이게 왜 중요하냐하면 코루틴 프레임 할당에 사용되는 operator new를 직접정의할 수 있기 때문이다)
- promise_type이 class-level replacement(클래스 수준의 사용자 정의 operator new)를 정의하였으면 그것을 사용하고,
그렇지 않으면 전역 operator new가 사용된다. - 만약 promise_type이 placement new 연산자를 정의하였고,
그 정의가 특정 매개변수 리스트를 받는다면,
코루틴의 인자들이 그 메모리 할당 함수로 전달 될 수 있다.
이때 매개변수 리스트는 첫 번째 인자로 할당할 크기(std::size_t 타입)가 오고,
나머지가 인자들은 코루틴 함수의 매개변수들과 일치한다면,
해당 인자는 클래스 수준의 사용자 정의 operator new로 전달된다.
(이를 통해 코루틴에 대한 leading-allocator-convetion을 사용할 수 있음)
( ※Tips : 코루틴 객체 생성 시 전달된 인자와
코루틴 객체에 사용자 정의 operator new의 시그니처가 일치하면 해당 operator new가 호출된다는 말 같다.)
※Sample Code
struct MyPromise {
// 코루틴 함수 인자를 받는 사용자 정의 operator new
static void* operator new(std::size_t size, int some_arg) {
std::cout << "Custom operator new called with size: " << size
<< " and some_arg: " << some_arg << "\n";
return ::operator new(size); // 기본 할당 메커니즘 사용
}
// 다른 필수 함수들
auto get_return_object() { return std::coroutine_handle<MyPromise>::from_promise(*this); }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() {}
};
struct MyTask {
using promise_type = MyPromise;
};
MyTask my_coroutine(int x) {
co_return; // 코루틴 본문
}
int main() {
auto task = my_coroutine(42); // 코루틴 함수에 전달된 인자 42가 operator new로 전달됨
}
다음 조건이 충족되면 opeartor new 호출이 최적화(생략)될 수 있다.(custom allocator를 사용할지라도 최적화함)
- 코루틴 객체의 수명이 Caller의 수명 내에 완전히 포함되는 경우
- 코루틴 프레임의 크기를 호출 시점에 알수 있을 때
이 경우 코루틴 상태는 Caller의 스택 프레임(일반 함수인 경우) 또는
Caller가 코루틴인 경우 코루틴 상태에 포함된다.
할당 실패 처리 :
- 기본적으로 할당 실패 시 std::bad_alloc 예외를 던진다.
단, promise_type에 get_return_object_on_allocation_failure() 메서드를 정의하면 다르게 동작한다.
이 경우 nothrow 형태의 operator new를 사용한다. - 할당 실패 시,
코루틴은 즉시 promise_type:: get_return_object_on_allocation_failure()로부터 얻은 객체를 호출자에게 반환한다.
예시
struct Coroutine::promise_type
{
/* ... */
// ensure the use of non-throwing operator-new
static Coroutine get_return_object_on_allocation_failure()
{
std::cerr << __func__ << '\n';
throw std::bad_alloc(); // or, return Coroutine(nullptr);
}
// custom non-throwing overload of new
void* operator new(std::size_t n) noexcept
{
if (void* mem = std::malloc(n))
return mem;
return nullptr; // allocation failure
}
};
10. Promise(★★★★★)
1) Promise 타입의 결정
- 코루틴의 promise 타입은 컴파일러가 코루틴의 반환 타입을 기반으로
std::coroutine_traits를 사용하여 결정한다.
2) 타입 결정 과정
- R : 코루틴의 반환 타입
- Args... : 코루틴의 매개변수 타입 목록
- Class T : 코루틴이 non-static 멤버 함수로 정의된 경우의 클래스 타입
- cv : non-static 멤버 함수로 정의된 경우 cv-qualification(const, volatile)
3) promise 타입 결정 규칙
- 일반 함수나 static 멤버 함수인 경우 :
std::coroutine_traits <R, Args...>::promise_type - non-static 멤버 함수이며 rvalue 참조가 아닌 경우 :
std::coroutine_traits<R, cv ClassT&, Args...>::promise_type - non-static 멤버 함수이며 rvalue 참조인 경우 :
std::coroutine_traits<R, cv Classt&&, Args...>::promise_type
promise_type 결정 예시
코루틴이 다음과 같이 정의된 경우 | promise_type은 다음과 같음 |
task<void> foo(int x); | std::coroutine_traits<task<void>, int>::promise_type |
task<void> Bar::foo(int x) const; | std::coroutine_traits<task<void>, const Bar&, int>::promise_type |
task<void> Bar::foo(int x) &&; | std::coroutine_traits<task<void> Bar&&, int>::promise_type |
11. co_await
co_await은 단항 연산자이며,
코루틴을 일시 중단하고 제어권을 Caller 측에 반환하는 역할을 한다.
1) co_await의 피연산자가 될 수 있는 것들은 :
- 멤버 연산자 co_await을 정의하는 클래스 타입이거나,
- 비멤버 연산자 co_await으로 전달될 수 있거나 (혹은 그런 타입이거나),
- 혹은 현재 코루틴의 promise_type::await_transform() 메서드를 통해
그러한 클래스 유형으로 변환할 수 있는 표현식
2) 표현식 'co_await expr'
'co_await expr'표현식에서 co_await의 피연산자 expr은
일반 함수 본문 내의 잠재적으로 평가되는 표현식에만 나타날 수 있으며(람다 표현식의 함수 본문 포함),
다음 위치에서는 나타날 수 없다 :
- handler 내부
- 선언문 내부(해당 선언문의 initializer에 나타나는 경우 제외)
- init-문의 단순 선언 내부(if, switch, for, range-for 참조) (해당 init-문의 initializer에 나타나는 경우 제외)
- default parameter 내부
- static storage duration 또는 thread storage duration을 가진 블록 범위 변수의 initializer 내부
( ※Tips : co_await은 함수의 실행 흐름 내에서 비동기 처리를 위해 사용되어야 한다.
즉, co_await의 피연산자는 비동기 함수 or 코루틴이어야 한다는 뜻이다.)
먼저 'expr'은 다음과 같이 awaitable로 변환된다.
- 'expr'이 초기 중단점, 최종 중단점 또는 yield 표현식에 의해 생성된 경우
awaitable은 그대로 'expr'이다. - 그렇지 않고, 현재 코루틴이 promise_type.await_transform() 메서드를 정의하고 있다면,
awaitable은 promise.await_transform(expr)이 된다.
( ※Tips : 연산자 co_await의 피연산자 expr에 대해서 커스텀 동작을 구현할 수 있다는 뜻 )
struct MyAwaiter {
bool await_ready() { return false; }
void await_suspend(std::coroutine_handle<>) {}
void await_resume() {}
};
struct Promise {
struct promise_type {
auto get_return_object() { return Promise{}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void unhandled_exception() {}
void return_value()
{
std::cout<<"Promise::return_value"<<std::endl;
}
// await_transform 함수
MyAwaiter await_transform(int value) {
std::cout << "Promise::await_transform(" << value<<")" << std::endl;
return MyAwaiter{};
}
};
};
Promise coroutine() {
co_await 42; // 이 표현식이 어떻게 변환되는지 주목
}
int main() {
coroutine(); // 출력 : Promise::await_transform(42)
return 0;
}
- 그 외의 경우, awaitable은 그대로 'expr'이다
awaiter object는 다음과 같이 얻을 수 있다 :
- 연산자 'co_await'에 대한 오버로드 결정(overload resolution)이 단일 최선의 오버로드를 제공하면,
그 awaiter는 그 호출의 결과이다 :
- 멤버 오버로드의 경우 'awaitable.operator co_await()'
- 비멤버 오버로드의 경우 'operator co_await( static_cast<Awaitable&&>( awaitable ) )' - 오버로드 결정이 'operator co_await'을 찾지 못하면, awaiter는 그대로 awaitable이다.
※Tips : 다음 예제를 실행해보고 어떤식으로 동작하는지 파악해보자.
#include <coroutine>
#include <iostream>
struct Promise
{
struct promise_type
{
Promise get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() { }
void unhandled_exception() { }
};
};
// Case 1: Awaitable with member operator co_await
struct MemberAwaitable
{
bool await_ready()
{
std::cout << "MemberAwaitable::await_ready()" << std::endl;
return true;
}
void await_suspend(std::coroutine_handle<>) { }
void await_resume() { std::cout << "MemberAwaitable::await_resume()" << std::endl; }
auto operator co_await()
{
std::cout << "Member operator co_await()" << std::endl;
return *this;
}
};
// Case 2: Awaitable with non-member operator co_await
struct NonMemberAwaitable{ };
auto operator co_await(NonMemberAwaitable&&)
{
std::cout << "Non-member operator co_await(NonMemberAwaitable&&)" << std::endl;
struct Awaiter
{
bool await_ready()
{
std::cout << "Non-member co_await::Awaiter::await_ready()" << std::endl;
return true;
}
void await_suspend(std::coroutine_handle<>) { }
void await_resume() { std::cout << "Non-member co_await::await_resume()" << std::endl; }
};
return Awaiter{};
}
// Case 3: Awaitable without operator co_await
struct SimpleAwaitable
{
bool await_ready()
{
std::cout << "SimpleAwaitable::await_ready()" << std::endl;
return true;
}
void await_suspend(std::coroutine_handle<>) { }
void await_resume() { std::cout << "SimpleAwaitable::await_resume()" << std::endl; }
};
Promise coroutine()
{
co_await MemberAwaitable{};
std::cout << std::endl;
co_await NonMemberAwaitable{};
std::cout << std::endl;
co_await SimpleAwaitable{};
}
int main()
{
coroutine();
return 0;
}
/* 출력 :
Member operator co_await()
MemberAwaitable::await_ready()
MemberAwaitable::await_resume()
Non-member operator co_await(NonMemberAwaitable&&)
Non-member co_await::Awaiter::await_ready()
Non-member co_await::await_resume()
SimpleAwaitable::await_ready()
SimpleAwaitable::await_resume()
*/
- 오버로드 결정이 모호하면, 프로그램은 ill-formed(잘못된 형식)다.
위의 'expr'이 prvalue인 경우, awaiter object는 이로부터 임시로 구체화된 객체다.
그렇지 않고 위의 표현식이 glvalue(Generalized Lvalue : 메모리 주소를 가리킬 수 있는 값)인 경우,
awaiter 객체는 이 표현식이 참조하는 객체다.
( ※Tips : co_await 뒷부분이 glvalue이면 'co_await awaiter(glvalue)가 된다는 뜻)
그다음, `awaiter.await_ready()`가 호출된다.
(결과가 준비되었거나 동기적으로 완료될 수 있다는 것을 알고 있는 경우
suspension 비용을 회피하기 위한 short-cut이다).
(참고 : await_ready() 메서드에서 반환 값이 준비 되었는지 검증하는 로직을 정의하며,
true를 반환하게 될 경우 즉시 await_resume()이 호출되며 코루틴이 재게되고,
false를 반환하게 될 경우 await_suspend()가 호출된다.)
만약 그 결과를 문맥상 bool로 변환할 수 있을 때 false라면(await_ready()가 false를 반환하면):
- 코루틴이 중단된다 (코루틴 상태는 지역 변수와 현재 일시 중단 지점으로 채워진다).
- 현재 코루틴에 해당하는 코루틴 핸들인 경우 `awaiter.await_suspend(handle)`이 호출된다.
해당 함수 내부에서 일시 중단된 코루틴 상태는 해당 핸들을 통해 관찰할 수 있으며,
특정 executor에서 재개되도록 schedule 하거나,
소멸(destroy)되도록 schedule 하는 것은 이 함수의 책임이다(false를 반환하는 것은 scheduling으로 간주한다).
여기서 `handle`은 현재 코루틴을 나타내는 코루틴 핸들 std::coroutine_handle<T>를 말한다.
이 함수 내에서, 일시 중단된 코루틴 상태는 해당 핸들을 통해 관찰 가능하며,
이 함수의 책임은 그것을 어떤 실행자(executor)에서 재개하도록 스케줄링하거나 파괴되도록 하는 것이다(false를 반환하는 것은 스케줄링으로 간주됩니다).
- `await_suspend()`가 void를 반환하면,
└ 제어권이 즉시 현재 코루틴의 Caller/Resumer에게 반환된다.(이 코루틴은 일시 중단된 상태로 유지된다).
- `await_suspend()`가 bool을 반환하면 :
└ true 값은 제어권을 현재 코루틴의 Caller/Resumer에게 반환한다.
└ false 값은 현재 코루틴을 재개한다.
- `await_suspend()`가 다른 코루틴에 대한 코루틴 핸들을 반환하면, 해당 핸들이 재개된다(`handle.resume()` 호출을 통해)
이는 결국 현재 코루틴을 재개하는 연쇄 효과를 가질 수 있다.
- `await_suspend()`가 예외를 던지면, 예외가 포착되고, 코루틴이 재개되며, 예외가 즉시 다시 던져집니다.
( ※Tips : 예시를 살펴보면서 이런것도 가능하다는 것을 기억하자.
await_suspend()가 다른 코루틴의 핸들을 반환하면, 그 핸들에 대해 resume()이 호출된다.
이 것은 체인 효과를 일으킬 수 있는데 최종적으로 현재 코루틴이 다시 실행될 수도 있다.
예를 들어 1번 코루틴이 중단되면서 2번 코루틴을 반환하고,
2번 코루틴이 실행 완료되면 다시 1번 코루틴을 이어서 실행하는 식으로 말이다)
#include <coroutine>
#include <iostream>
#include <exception>
struct Task
{
struct promise_type
{
Task get_return_object() { return Task(std::coroutine_handle<promise_type>::from_promise(*this)); }
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void()
{
}
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> handle;
Task(std::coroutine_handle<promise_type> h) : handle(h) { }
~Task() { if (handle) handle.destroy(); }
};
struct SwitchToAwaiter
{
std::coroutine_handle<> target_handle;
SwitchToAwaiter(std::coroutine_handle<> h) : target_handle(h)
{
}
bool await_ready() { return false; } // await_suspend호출을 위해 항상 false반환
std::coroutine_handle<> await_suspend(std::coroutine_handle<> current)
{
std::cout << "SwitchToAwaiter::await_suspend() Switching to other coroutine" << std::endl;
return target_handle; // 다른 코루틴의 핸들을 반환
}
void await_resume()
{
std::cout<< "SwitchToAwaiter::await_resume()" << std::endl;
}
};
Task coroutine1()
{
std::cout << "Coroutine1: Start" << std::endl;
co_await std::suspend_always{};
std::cout << "Coroutine1: Resumed" << std::endl;
co_await std::suspend_always{};
std::cout << "Coroutine1: End" << std::endl;
}
// coroutine2은 매개변수로 coroutine1의 핸들을 받음
Task coroutine2(std::coroutine_handle<> other)
{
std::cout << "Coroutine2: Start" << std::endl;
co_await SwitchToAwaiter{other}; // 여기서 coroutine1의 핸들로 바꿔치기하면 무슨일이 일어날까? other.resume()을 하게된다. 즉, 2번 코루틴을 재개하게 된다.
std::cout << "Coroutine2: Resumed" << std::endl;
co_await std::suspend_always{};
std::cout << "Coroutine2: End" << std::endl;
}
int main()
{
auto t1 = coroutine1();
auto t2 = coroutine2(t1.handle);
std::cout << "Main: Resuming coroutine2" << std::endl;
t2.handle.resume();
std::cout << "Main: Resuming coroutine2 again" << std::endl;
t2.handle.resume();
std::cout << "Main: Resuming coroutine1" << std::endl;
t1.handle.resume();
std::cout << "Main: End" << std::endl;
return 0;
}
/* 출력 :
Main: Resuming coroutine2
Coroutine2: Start
SwitchToAwaiter::await_suspend() Switching to other coroutine
Coroutine1: Start
Main: Resuming coroutine2 again
SwitchToAwaiter::await_resume()
Coroutine2: Resumed
Main: Resuming coroutine1
Coroutine1: Resumed
Main: End
*/
마지막으로, `awaiter.await_resume()`이 호출되며(코루틴이 일시 중단되었든 아니든),
그 결과가 전체 `co_await expr` 표현식의 결과가 된다.
코루틴이 `co_await` 표현식에서 중단되었다가 나중에 재개되면,
재개 지점은 `awaiter.await_resume()` 호출 직전이 된다.
주의할 점은 코루틴이 `awaiter.await_suspend()`에 들어가기 전에 완전히 중단된다는 것이다.
그 핸들은 다른 스레드와 공유될 수 있으며 `await_suspend()` 함수가 반환되기 전에 재개될 수 있다.
(기본 메모리 안전 규칙은 여전히 적용되므로, 코루틴 핸들이 락 없이 스레드 간에 공유되는 경우 awaiter는 최소한 release 의미론을, 재개자는 최소한 acquire 의미론을 사용해야 한다.)
예를 들어, 코루틴 핸들은 비동기 I/O 작업이 완료될 때 스레드 풀에서 실행되도록 예약된 콜백 내에 넣을 수 있다.
이 경우, 현재 코루틴이 재개되어 awaiter 객체의 소멸자를 실행했을 수 있으므로,
`await_suspend()`가 현재 스레드에서 계속 실행되는 동안 동시에,
`await_suspend()`는 `*this`를 파괴된 것으로 취급하고 핸들이 다른 스레드에 공개된 후에는 접근하지 않아야 한다.
예제
#include <coroutine>
#include <iostream>
#include <stdexcept>
#include <thread>
auto switch_to_new_thread(std::jthread& out)
{
std::cout<<"Switching_to_new_thread()"<<std::endl;
struct MyAwaitable
{
std::jthread* p_out;
bool await_ready()
{
std::cout<<"MyAwaitable::await_ready()"<<std::endl;
return false; // false 반환으로 항상 await_suspend()를 호출하겠다는 의도
}
void await_suspend(std::coroutine_handle<> h)
{
std::cout<<"MyAwaitable::await_suspend()"<<std::endl;
std::jthread& out = *p_out;
if (out.joinable())
throw std::runtime_error("Output jthread parameter not empty");
out = std::jthread([h] { h.resume(); }); // 새로운 스레드를 생성하여 코루틴의 제어권을 넘기고 재개함
// Potential undefined behavior: accessing potentially destroyed *this
// std::cout << "New thread ID: " << p_out->get_id() << '\n';
std::cout << "New thread ID: " << out.get_id() << '\n'; // this is OK
}
void await_resume()
{
std::cout<<"MyAwaitable::await_resume()"<<std::endl;
}
};
return MyAwaitable{&out}; // 다른 스레드의 참조를 awaitable 객체에 넘김
}
struct task
{
struct promise_type
{
task get_return_object()
{
std::cout<<"task::get_return_object()"<<std::endl;
return {};
}
std::suspend_never initial_suspend()
{
std::cout<<"task::initial_suspend()"<<std::endl;
return {};
}
std::suspend_never final_suspend() noexcept
{
std::cout<<"task::final_suspend()"<<std::endl;
return {};
}
void return_void() // 반환 타입 void라는 뜻
{
std::cout<<"task::return_void()"<<std::endl;
}
void unhandled_exception()
{
std::cout<<"task::unhandled_exception()"<<std::endl;
}
};
};
task resuming_on_new_thread(std::jthread& out)
{
std::cout << "Coroutine started on thread: " << std::this_thread::get_id() << '\n';
co_await switch_to_new_thread(out); // switch_to_new_thread함수는 MyAwaitable을 반환하는데 co_await의 피연사자가 되어 myAwaitable::await_ready를 호출한다.
// awaiter destroyed here
std::cout << "Coroutine resumed on thread: " << std::this_thread::get_id() << '\n';
}
int main()
{
std::jthread out;
resuming_on_new_thread(out);
}
/* 출력 :
task::get_return_object()
task::initial_suspend()
Coroutine started on thread: 4760
Switching_to_new_thread()
awaitable::await_ready()
awaitable::await_suspend()
New thread ID: 2388
awaitable::await_resume()
Coroutine resumed on thread: 2388
task::return_void()
task::final_suspend()
*/
※Tips :
위 코드의 의도는 코루틴을 새로운 스레드에서 재개하는 방식을 보여주는 것이다.
위 예시는 매우 중요한 부분을 보여주고 있으니 잘 파악해 놓도록 하자.
task 타입은 co_await switch_to_new_thread(out); 코드에서
코루틴 객체 생성 부분을 위해 정의한 타입이다.
awaitable 타입은 co_await을 수행하기 위해 정의한 awaitable타입이며
await_ready(), await_suspend(), await_resume() 메서드를 정의하고 있다.
12. co_yield
co_yield 표현식은 호출자에게 값을 반환하고 현재 코루틴을 일시 중단하며,
재개 가능한 제너레이터 함수의 공통 구성 요소(common building block of resumable generator functions)이다.
'co_yield expr'
'co_yield braced-init-list'
위 표현식은 아래와 같다.
co_await promise.yield_value(expr)
일반적인 제너레이터의 'yield_value()'는 인자를 제너레이터 객체에 저장(복사/이동하거나 인자의 수명이 co_await 내부의 일시 중단 지점을 넘기 때문에 주소만 저장)하고
std::suspend_always를 반환하여 호출자/재시도자에게 제어권을 넘긴다.
#include <coroutine>
#include <cstdint>
#include <exception>
#include <iostream>
template <typename T>
struct Generator
{
/* 클래스 명을 Generator라고 명명할 필요 없음.
* 컴파일러가 코루틴이라고 인지하는 것은 'co_yield'키워드의 존재 여부임.
* 클래스 명을 'MyGenerator'라고 네이밍한다면
* MyGenerator get_return_object()메서드만 정의해놓으면 됨
* (Note: 이름을 변경할 때 생성자 및 소멸자의 선언을 조정해야 한다.)*/
struct promise_type;
using handle_type = std::coroutine_handle<promise_type>;
struct promise_type // required
{
T value_;
std::exception_ptr exception_;
Generator get_return_object()
{
std::cout << "get_return_object()" << std::endl;
return Generator(handle_type::from_promise(*this));
}
std::suspend_always initial_suspend()
{
std::cout << "initial_suspend()" << std::endl;
return {};
}
std::suspend_always final_suspend() noexcept
{
std::cout << "final_suspend()" << std::endl;
return {};
}
void unhandled_exception()
{
std::cout << "Unhandled exception()" << std::endl;
exception_ = std::current_exception();
} // saving
// exception
template <std::convertible_to<T> From> // C++20 concept
std::suspend_always yield_value(From&& from)
{
std::cout << "yield_value()" << std::endl;
value_ = std::forward<From>(from); // caching the result in promise
return {};
}
void return_void()
{
std::cout << "return_void()" << std::endl;
}
};
handle_type h_;
Generator(handle_type h) : h_(h)
{
std::cout<<"Generator constructed"<<std::endl;
}
~Generator()
{
std::cout<<"Generator destroyed"<<std::endl;
h_.destroy();
}
/* 코루틴 객체의 사용을 위해 명시적으로 정의해야하는
* promise타입의 operator bool() 메서드가 반환하는 bool값은
* 컴파일러에 의해 아래 2가지 목적으로 암시적으로 사용된다.
1. 'co_yield' 혹은 operator()()를 만났을 때
현재 Generator가 아직 유효한 값을 생성할 수 있는지 확인하는 목적
2. 코루틴이 완전히 종료되었는지 확인
즉, 코루틴을 완료했는지 여부, 코루틴에서 다음 값이 생성될지 여부(co_yield)를
C++ getter(아래 연산자()를 통해 안정적으로 확인할 수 있는 유일한 방법은
다음 co_yield 지점까지 코루틴을 실행/재시작하는 것이다(또는 종료되도록 놔두는 것일 수 있다).
그런 다음 결과를 promise에 저장/캐싱하여 코루틴을 실행하지 않고도 getter(아래 연산자()가 결과를 가져올 수 있도록 한다.)
*/
explicit operator bool()
{
std::cout << "operator bool()" << std::endl;
fill();
return !h_.done();
}
T operator()()
{
std::cout<<"operator()()"<<std::endl;
fill();
full_ = false; // 이전에 캐싱된 결과 값을 이동시켜 promise를 다시 empty 상태로 만드는 의도
return std::move(h_.promise().value_);
}
private:
bool full_ = false;
void fill()
{
std::cout << "fill()" << std::endl;
if (!full_)
{
h_();
if (h_.promise().exception_)
std::rethrow_exception(h_.promise().exception_); // propagate coroutine exception in called context
full_ = true;
}
}
};
Generator<std::uint64_t>
fibonacci_sequence(unsigned n)
{
std::cout << "fibonacci_sequence()" << std::endl;
if (n == 0)
co_return;
if (n > 94)
throw std::runtime_error("Too big Fibonacci sequence. Elements would overflow.");
co_yield 0;
if (n == 1)
co_return;
co_yield 1;
if (n == 2)
co_return;
std::uint64_t a = 0;
std::uint64_t b = 1;
for (unsigned i = 2; i < n; ++i)
{
std::uint64_t s = a + b;
co_yield s;
a = b;
b = s;
}
}
Generator<int> range(int start, int end)
{
for (int i = start; i < end; ++i)
{
co_yield i; // 각 숫자를 생성하고 일시 중단
}
}
int main()
{
try
{
std::cout << "Generate coroutine handle" << std::endl;
auto gen = fibonacci_sequence(5); // 여기서 get_return_object()호출 후 initial_suspend()호출 됨
std::cout << "================================================" << std::endl;
for (int j = 0; gen; ++j)
{
std::cout << "fib(" << j << ")=" << gen() << std::endl;
std::cout << "================================================" << std::endl;
}
}
catch (const std::exception& ex)
{
std::cerr << "Exception: " << ex.what() << '\n';
}
catch (...)
{
std::cerr << "Unknown exception.\n";
}
}
/* 출력 :
Generate coroutine handle
get_return_object()
Generator constructed
initial_suspend()
================================================
operator bool()
fill()
fibonacci_sequence()
yield_value()
fib(0)=operator()()
fill()
0
================================================
operator bool()
fill()
yield_value()
fib(1)=operator()()
fill()
1
================================================
operator bool()
fill()
yield_value()
fib(2)=operator()()
fill()
1
================================================
operator bool()
fill()
yield_value()
fib(3)=operator()()
fill()
2
================================================
operator bool()
fill()
yield_value()
fib(4)=operator()()
fill()
3
================================================
operator bool()
fill()
return_void()
final_suspend()
Generator destroyed
*/
위 코드를 실행시켜 보고 코루틴에서 co_yield가 대충 어떻게 동작하는지 파악해 놓도록 하자.
※Tips :
아직 컴파일러 및 IDE에서 C++ coroutines을 완벽하게 지원하지 못하기 때문에
구문 분석 시 IDE상에서 정상 작동하는 코드에 대해서도 빨간 밑줄로 컴파일 에러를 표시하기도 한다.
그래서 코루틴이 들어간 코드인데 빨간 밑줄이 뜬다면 일단 컴파일을 시도해보자.
또한 코루틴은 UB가 발생하기 쉬운데 UB 발생 원인을 파악하기가 힘들다.
따라서 코루틴을 사용하려면 상기 내용들을 잘 익혀두어야 한다.