코루틴
코루틴 (Coroutines)
실행을 중단했다가 나중에 다시 재개할 수 있는 함수가 있다면, 비동기 코드를 콜백 없이 순차적으로 쓸 수 있어요. C++20에서 도입된 코루틴(coroutine)이 바로 그런 기능이에요.
출처: cppreference
본문
코루틴은 실행을 중단했다가 나중에 다시 재개할 수 있는 함수예요. 코루틴은 스택리스(stackless)예요: 호출자에게 반환함으로써 실행을 중단하고, 재개에 필요한 데이터는 스택과 별도로 저장돼요. 이 덕분에 비동기로 실행되는 순차 코드를 쓸 수 있고(예: 명시적 콜백 없이 비블로킹 I/O 처리), 지연 계산되는 무한 수열에 대한 알고리즘과 다른 용도도 지원해요.
함수의 정의에 다음 중 하나가 포함되어 있으면 그 함수는 코루틴이에요:
co_await표현식 — 재개될 때까지 실행을 중단
task<> tcp_echo_server()
{
char data[1024];
while (true)
{
std::size_t n = co_await socket.async_read_some(buffer(data));
co_await async_write(socket, buffer(data, n));
}
}
co_yield표현식 — 값을 반환하며 실행을 중단
generator<unsigned int> iota(unsigned int n = 0)
{
while (true)
co_yield n++;
}
co_return문 — 값을 반환하며 실행을 완료
lazy<int> f()
{
co_return 7;
}
모든 코루틴은 아래에 언급된 여러 요구 사항을 충족하는 반환 타입을 가져야 해요.
제약 (Restrictions)
코루틴은 가변 인자(variadic arguments), 일반 return 문, 자리표시자 반환 타입(auto 또는 Concept)을 사용할 수 없어요.
consteval 함수, constexpr 함수, 생성자, 소멸자, main 함수는 코루틴이 될 수 없어요.
실행 (Execution)
각 코루틴은 다음과 연관돼요:
- promise 객체 — 코루틴 안에서 조작됨. 코루틴은 이 객체를 통해 결과나 예외를 제출해요. promise 객체는 std::promise와는 전혀 관련이 없어요.
- 코루틴 핸들(coroutine handle) — 코루틴 밖에서 조작됨. 코루틴의 실행을 재개하거나 코루틴 상태를 파괴하는 데 쓰이는 비소유 핸들이에요.
- 코루틴 상태(coroutine state) — 내부적이고 동적으로 할당되는 저장 공간(할당이 최적화되지 않았다면)이에요. 여기에는
- promise 객체
- 매개변수 (모두 값으로 복사됨)
- 재개가 어디서 계속될지, 파괴가 어떤 지역 변수가 스코프에 있었는지 알 수 있도록 하는 현재 중단 지점의 표현
- 수명이 현재 중단 지점을 걸치는 지역 변수와 임시들이 포함돼요.
코루틴이 실행을 시작하면 다음을 수행해요:
operator new로 코루틴 상태 객체를 할당해요.- 모든 함수 매개변수를 코루틴 상태로 복사해요: 값 매개변수는 이동되거나 복사되고, 참조 매개변수는 참조로 유지돼요(따라서 참조된 객체의 수명이 끝난 뒤 코루틴이 재개되면 댕글링이 될 수 있음 — 아래 예시 참고).
- promise 객체의 생성자를 호출해요. promise 타입에 모든 코루틴 매개변수를 받는 생성자가 있으면 그 생성자를 복사 이후의 코루틴 인자로 호출해요. 그렇지 않으면 기본 생성자를 호출해요.
promise.get_return_object()를 호출하고 결과를 지역 변수에 보관해요. 그 호출의 결과는 코루틴이 처음 중단될 때 호출자에게 반환돼요. 이 단계까지 포함해 던져진 예외는 promise에 넣지 않고 호출자에게 다시 전파돼요.promise.initial_suspend()를 호출하고 그 결과를co_await해요. 일반적인Promise타입은 지연 시작(lazily-started) 코루틴에는std::suspend_always를, 조기 시작(eagerly-started) 코루틴에는std::suspend_never를 반환해요.co_await promise.initial_suspend()가 재개되면 코루틴 본문 실행을 시작해요.
매개변수가 댕글링이 되는 몇 가지 예시:
#include <coroutine>
#include <iostream>
struct promise;
struct coroutine : std::coroutine_handle<promise>
{
using promise_type = ::promise;
};
struct promise
{
coroutine get_return_object() { return {coroutine::from_promise(*this)}; }
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() {}
};
struct S
{
int i;
coroutine f()
{
std::cout << i;
co_return;
}
};
void bad1()
{
coroutine h = S{0}.f();
// S{0} destroyed
h.resume(); // resumed coroutine executes std::cout << i, uses S::i after free
h.destroy();
}
coroutine bad2()
{
S s{0};
return s.f(); // returned coroutine can't be resumed without committing use after free
}
void bad3()
{
coroutine h = [i = 0]() -> coroutine // a lambda that's also a 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
// state as a by-value parameter
h.destroy();
}
여기서 bad1~bad3는 멤버 변수나 캡처가 코루틴 상태에 복사되지 않고 파괴된 객체를 가리켜 use-after-free가 생겨요. good처럼 값을 코루틴 매개변수로 넘기면 그 값이 코루틴 상태에 값으로 복사되어 안전해요.
코루틴이 중단 지점에 도달하면:
- 앞서 얻은 반환 객체가 필요하면 코루틴의 반환 타입으로의 암시적 변환을 거쳐 호출자/재개자에게 반환돼요.
코루틴이 co_return 문에 도달하면 다음을 수행해요:
co_return;이거나co_return expr;에서expr이void타입이면promise.return_void()를 호출해요.co_return expr;에서expr이 비-void타입이면promise.return_value(expr)를 호출해요.- 생성된 역순으로 모든 자동 저장 기간 변수를 파괴해요.
promise.final_suspend()를 호출하고 그 결과를co_await해요.
코루틴 끝에서 떨어지는 것은 co_return;과 동등해요. 단, Promise 스코프에서 return_void 선언을 찾을 수 없으면 동작이 undefined예요. 함수 본문에 정의 키워드가 하나도 없는 함수는 반환 타입과 무관하게 코루틴이 아니고, 반환 타입이 (가능한 cv 한정) void가 아니면 끝에서 떨어지는 것이 undefined behavior를 낳아요.
// assuming that task is some coroutine task type
task<void> f()
{
// not a coroutine, undefined behavior
}
task<void> g()
{
co_return; // OK
}
task<void> h()
{
co_await g();
// OK, implicit co_return;
}
코루틴이 잡히지 않은 예외로 끝나면 다음을 수행해요:
- 예외를 잡고 catch 블록 안에서
promise.unhandled_exception()을 호출해요. promise.final_suspend()를 호출하고 그 결과를co_await해요(예: 연속(continuation)을 재개하거나 결과를 발행하기 위해). 이 지점부터 코루틴을 재개하는 것은 undefined behavior예요.
코루틴 상태가 co_return이나 잡히지 않은 예외로 종료되거나 핸들로 파괴되어 없어질 때 다음을 수행해요:
- promise 객체의 소멸자를 호출해요.
- 함수 매개변수 사본의 소멸자를 호출해요.
operator delete를 호출해 코루틴 상태가 쓰는 메모리를 해제해요.- 실행을 호출자/재개자에게 되돌려요.
동적 할당 (Dynamic allocation)
코루틴 상태는 비배열 operator new를 통해 동적으로 할당돼요.
Promise 타입이 클래스 수준 대체자(replacement)를 정의하면 그것이 쓰이고, 그렇지 않으면 전역 operator new가 쓰여요.
Promise 타입이 추가 매개변수를 취하는 배치(placement) 형태의 operator new를 정의하고, 그것이 첫 번째 인자가 요청된 크기(std::size_t 타입)이고 나머지가 코루틴 함수 인자인 인자 목록과 일치하면, 그 인자들이 operator new에 전달돼요(그래서 코루틴에 선두 할당자 관례(leading-allocator-convention)를 사용할 수 있어요).
다음 경우에 operator new 호출은 (사용자 정의 할당자를 써도) 최적화로 제거될 수 있어요:
- 코루틴 상태의 수명이 호출자의 수명 안에 엄격히 중첩되고,
- 코루틴 상태의 크기가 호출 지점에서 알려진 경우
이 경우, 코루틴 상태는 호출자(일반 함수라면)의 스택 프레임이나 (호출자가 코루틴이라면) 코루틴 상태에 내장돼요.
할당이 실패하면, Promise 타입이 멤버 함수 Promise::get_return_object_on_allocation_failure()를 정의하지 않는 한 코루틴은 std::bad_alloc을 던져요. 그 멤버 함수가 정의되어 있으면 할당은 operator new의 nothrow 형태를 쓰고, 할당 실패 시 코루틴은 Promise::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
}
};
Promise
Promise 타입은 컴파일러가 std::coroutine_traits를 사용해 코루틴의 반환 타입으로부터 결정해요.
형식적으로, 다음을
R과Args...가 각각 코루틴의 반환 타입과 매개변수 타입 목록을,ClassT가 코루틴이 비정적 멤버 함수로 정의된 경우 그것이 속한 클래스 타입을,cv가 코루틴이 비정적 멤버 함수로 정의된 경우 함수 선언에 선언된 cv 한정을 나타낼 때,
그 Promise 타입은 다음으로 결정돼요:
- 코루틴이 암시적 객체 멤버 함수로 정의되지 않았으면
std::coroutine_traits<R, Args...>::promise_type - 코루틴이 rvalue-참조 한정이 아닌 암시적 객체 멤버 함수로 정의되었으면
std::coroutine_traits<R, cv ClassT&, Args...>::promise_type - 코루틴이 rvalue-참조 한정인 암시적 객체 멤버 함수로 정의되었으면
std::coroutine_traits<R, cv ClassT&&, Args...>::promise_type
예를 들어:
| 코루틴이 ...로 정의되면 | 그 Promise 타입은 ... |
|---|---|
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 |
co_await
단항 연산자 co_await는 코루틴을 중단하고 제어를 호출자에게 돌려줘요.
co_await expr
co_await 표현식은 일반 함수 본문(람다 표현식의 함수 본문 포함) 내의 잠재적으로 평가되는 표현식에만 나타날 수 있고, 다음에는 나타날 수 없어요:
- 처리기(handler) 안,
- 그 선언 문의 초기화식에 나타나는 경우가 아니면 선언 문 안,
- init 문의 단순 선언 안(if, switch, for, range-
for참고), 그 init 문의 초기화식에 나타나는 경우가 아니면, - 기본 인자 안,
- 또는 정적 또는 스레드 저장 기간을 가진 블록 스코프 변수의 초기화식 안.
| 참고 | (since) |
|---|---|
co_await 표현식은 계약 단언의 술어의 잠재적으로 평가되는 부분 표현식이 될 수 없다. |
(since C++26) |
먼저 expr은 다음과 같이 awaitable로 변환돼요:
expr이 초기 중단 지점, 최종 중단 지점, 또는 yield 표현식에 의해 만들어진 것이라면 awaitable은expr그 자체예요.- 그 외에, 현재 코루틴의
Promise타입이 멤버 함수await_transform을 가지면 awaitable은promise.await_transform(expr)이에요. - 그 외에 awaitable은
expr그 자체예요.
그런 다음 awaiter 객체를 다음과 같이 얻어요:
operator co_await에 대한 오버로드 해석이 단일 최적 오버로드를 주면, awaiter는 그 호출 결과예요:- 멤버 오버로드의 경우
awaitable.operator co_await() - 비멤버 오버로드의 경우
operator co_await(static_cast<Awaitable&&>(awaitable))
- 멤버 오버로드의 경우
- 그 외에 오버로드 해석이
operator co_await를 찾지 못하면 awaiter는 awaitable 그 자체예요. - 그 외에 오버로드 해석이 모호하면 프로그램은 ill-formed예요.
위 표현식이 prvalue이면 awaiter 객체는 그것에서 실체화된 임시예요. glvalue이면 awaiter 객체는 그것이 가리키는 객체예요.
그런 다음 awaiter.await_ready()를 호출해요 (결과가 준비되었거나 동기적으로 완료될 수 있음이 알려진 경우 중단 비용을 피하기 위한 지름길). 그 결과를 문맥 변환한 bool이 false이면:
- 코루틴이 중단돼요 (코루틴 상태에 지역 변수와 현재 중단 지점이 채워짐).
awaiter.await_suspend(handle)가 호출되는데, 여기서handle은 현재 코루틴을 나타내는 코루틴 핸들이에요. 그 함수 안에서 중단된 코루틴 상태는 그 핸들을 통해 관찰 가능하고, 어떤 실행기(executor)에서 재개되도록 예약하거나 파괴되도록 하는 것은 이 함수의 책임이에요 (false를 반환하는 것도 예약으로 간주됨).await_suspend가void를 반환하면, 제어는 즉시 현재 코루틴의 호출자/재개자에게 반환돼요 (이 코루틴은 중단 상태로 남음). 그렇지 않으면await_suspend가bool을 반환하면,- 값
true는 현재 코루틴의 호출자/재개자에게 제어를 반환해요 - 값
false는 현재 코루틴을 재개해요
- 값
await_suspend가 어떤 다른 코루틴의 코루틴 핸들을 반환하면, 그 핸들이 (handle.resume()호출로) 재개돼요 (이것이 연쇄되어 결국 현재 코루틴을 재개시킬 수 있음).await_suspend가 예외를 던지면, 예외가 잡히고 코루틴이 재개된 다음 예외가 즉시 다시 던져져요.
마지막으로 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를 파괴된 것으로 취급하고 접근하지 않아야 해요.
예제 (Example)
#include <coroutine>
#include <iostream>
#include <stdexcept>
#include <thread>
auto switch_to_new_thread(std::jthread& out)
{
struct awaitable
{
std::jthread* p_out;
bool await_ready() { return false; }
void await_suspend(std::coroutine_handle<> h)
{
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() {}
};
return awaitable{&out};
}
struct task
{
struct promise_type
{
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 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);
// 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);
}
가능한 출력 (Possible output):
Coroutine started on thread: 139972277602112
New thread ID: 139972267284224
Coroutine resumed on thread: 139972267284224
참고: awaiter 객체는 코루틴 상태의 일부(중단 지점을 걸치는 수명을 가진 임시로)이고 co_await 표현식이 끝나기 전에 파괴돼요. 일부 비동기 I/O API가 요구하는 대로 추가 동적 할당에 의존하지 않고 연산별 상태를 유지하는 데 쓰일 수 있어요.
표준 라이브러리는 두 개의 trivial awaitable인 std::suspend_always와 std::suspend_never를 정의해요.
co_yield
co_yield 표현식은 값을 호출자에게 반환하고 현재 코루틴을 중단해요: 재개 가능한 제너레이터 함수의 일반적인 구성 요소예요.
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
{
// The class name 'Generator' is our choice and it is not required for coroutine
// magic. Compiler recognizes coroutine by the presence of 'co_yield' keyword.
// You can use name 'MyGenerator' (or any other name) instead as long as you include
// nested struct promise_type with 'MyGenerator get_return_object()' method.
// (Note: It is necessary to adjust the declarations of constructors and destructors
// when renaming.)
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()
{
return Generator(handle_type::from_promise(*this));
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void unhandled_exception() { exception_ = std::current_exception(); } // saving
// exception
template<std::convertible_to<T> From> // C++20 concept
std::suspend_always yield_value(From&& from)
{
value_ = std::forward<From>(from); // caching the result in promise
return {};
}
void return_void() {}
};
handle_type h_;
Generator(handle_type h) : h_(h) {}
~Generator() { h_.destroy(); }
explicit operator bool()
{
fill(); // The only way to reliably find out whether or not we finished coroutine,
// whether or not there is going to be a next value generated (co_yield)
// in coroutine via C++ getter (operator () below) is to execute/resume
// coroutine until the next co_yield point (or let it fall off end).
// Then we store/cache result in promise to allow getter (operator() below
// to grab it without executing coroutine).
return !h_.done();
}
T operator()()
{
fill();
full_ = false; // we are going to move out previously cached
// result to make promise empty again
return std::move(h_.promise().value_);
}
private:
bool full_ = false;
void fill()
{
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)
{
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;
}
}
int main()
{
try
{
auto gen = fibonacci_sequence(10); // max 94 before uint64_t overflows
for (int j = 0; gen; ++j)
std::cout << "fib(" << j << ")=" << gen() << '\n';
}
catch (const std::exception& ex)
{
std::cerr << "Exception: " << ex.what() << '\n';
}
catch (...)
{
std::cerr << "Unknown exception.\n";
}
}
출력 (Output):
fib(0)=0
fib(1)=1
fib(2)=1
fib(3)=2
fib(4)=3
fib(5)=5
fib(6)=8
fib(7)=13
fib(8)=21
fib(9)=34
Generator는 promise에 값을 캐시해 두고, operator bool()과 operator()()가 필요할 때만 코루틴을 재개해 다음 값을 꺼내요. co_yield가 나올 때마다 제어가 호출자에게 돌아가고, 다음 호출 때 이어서 재개되는 구조예요.
참고 (Notes)
| Feature-test 매크로 | 값 | 표준 | 기능 |
|---|---|---|---|
__cpp_impl_coroutine |
201902L | (C++20) | Coroutines (compiler support) |
| 202606L | (C++26) | Removing mutual exclusivity for coroutine promise return functions | |
__cpp_lib_coroutine |
201902L | (C++20) | Coroutines (library support) |
__cpp_lib_generator |
202207L | (C++23) | std::generator: synchronous coroutine generator for ranges |
키워드 (Keywords)
co_await, co_return, co_yield
라이브러리 지원 (Library support)
코루틴 지원 라이브러리는 코루틴에 컴파일 타임 및 런타임 지원을 제공하는 여러 타입을 정의해요.
더 알아보기 (Learn more)
std::execution::task(C++26) — 코루틴 함수의 반환 타입으로 쓰일 수 있는 sender를 나타내요.std::generator(C++23) — 동기 코루틴 제너레이터를 나타내는 view.std::coroutine_traits(C++20) — 코루틴 promise 타입을 알아내기 위한 trait 타입.