60 / 163 · C++20 · 15 min
Coroutines: Suspend, Resume, and Coroutine-Frame Ownership
C++20 coroutines let a function suspend and later resume, but they do not automatically create a thread, event loop, or background task. Even a minimal usable abstraction must explicitly manage the coroutine frame, completion state, and exceptions. The synchronous generator below uses exclusive RAII ownership so resources are still released if it ends early.
In this lesson
A language mechanism is not a scheduler
When a function contains coroutine syntax such as co_await, co_yield, or co_return, the compiler organizes execution according to the promise_type protocol of the return type. State that must be preserved across suspend points goes into the coroutine frame; the frame usually needs dynamic storage, though allocation can be optimized away under specific conditions. It is not a full thread stack for every function.
A coroutine only provides the suspend and resume mechanism; when to resume and on which thread is decided by an external abstraction. In the example, main actively calls next, so all code runs synchronously on the calling thread; co_yield hands over an integer and suspends. It does not start background computation, still less automatically run in parallel.
A handle does not own the frame; the wrapper must
coroutine_handle is a copyable non-owning handle; copying it does not copy the coroutine frame. The example hides the handle inside Generator, forbids copies, allows moves, and destroys the frame in the destructor. initial_suspend uses suspend_always so the function body runs only on the first next; final_suspend likewise suspends so the owner is uniformly responsible for destruction.
next first checks for a null handle and the done state, avoiding resuming an already finished coroutine. Each yield stores the integer in the promise; next then returns an optional by value, so the caller does not hold a reference to an element inside the frame. Move assignment first releases its own old frame, then takes over the new one; leaving the scope early also follows the same RAII release path.
Exceptions and borrows must be reviewed across suspend points
Unhandled exceptions from the function body enter unhandled_exception; the example stores an exception_ptr and rethrows on the resuming side, avoiding disguising failure as ordinary sequence end. The frame is still managed by Generator; exception propagation does not require the caller to destroy by hand. Calling next again after it returned an empty optional also safely stays in the finished state.
A reference parameter does not become an owned copy just because it entered the coroutine frame; the this pointer and objects captured by a lambda can likewise be destroyed too early. This example's generator function has no external borrows; a local Guard uses assertions to show it is alive across suspend and released on early destroy. This is a synchronous, single-consumer teaching generator; it does not promise concurrent-resume safety.
Pitfalls
- You must not resume a coroutine that has already reached final_suspend, nor let two copied handles each destroy; exclusive ownership and the completion check must both hold.
- When a coroutine takes references or depends on this, returning from the call does not mean related objects may be destroyed; they must cover the lifetime of all future resume operations.
Run an example
Minimum C++20 · complete program · Download .cpp
#include <cassert>
#include <coroutine>
#include <exception>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <utility>
class Generator {
public:
struct promise_type {
int current = 0;
std::exception_ptr error;
Generator get_return_object();
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(int n) noexcept {
current = n;
return {};
}
void return_void() noexcept {}
void unhandled_exception() noexcept { error = std::current_exception(); }
};
private:
using Handle = std::coroutine_handle<promise_type>;
Handle handle_;
explicit Generator(Handle h) noexcept : handle_(h) {}
public:
Generator(const Generator&) = delete;
Generator& operator=(const Generator&) = delete;
Generator(Generator&& other) noexcept
: handle_(std::exchange(other.handle_, {})) {}
Generator& operator=(Generator&& other) noexcept {
if (this != &other) {
if (handle_) handle_.destroy();
handle_ = std::exchange(other.handle_, {});
}
return *this;
}
~Generator() { if (handle_) handle_.destroy(); }
std::optional<int> next() {
if (!handle_ || handle_.done()) return std::nullopt;
handle_.resume();
if (handle_.promise().error)
std::rethrow_exception(handle_.promise().error);
if (handle_.done()) return std::nullopt;
return handle_.promise().current;
}
};
Generator Generator::promise_type::get_return_object() {
return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
struct Guard {
inline static int live = 0;
Guard() { ++live; }
~Guard() { --live; }
};
Generator numbers() {
Guard guard;
for (int n = 1; n <= 3; ++n) co_yield n;
}
Generator failure() {
throw std::runtime_error("failed");
co_return;
}
int main() {
{
auto early = numbers();
assert(Guard::live == 0);
auto first = early.next();
assert(first && *first == 1 && Guard::live == 1);
auto moved = std::move(early);
assert(!early.next());
}
assert(Guard::live == 0);
auto all = numbers();
int total = 0;
while (auto n = all.next()) total += *n;
assert(total == 6 && Guard::live == 0);
assert(!all.next());
bool caught = false;
try { auto bad = failure(); bad.next(); }
catch (const std::runtime_error&) { caught = true; }
assert(caught);
std::cout << total << ' ' << Guard::live << '\n';
}
Compile locally
g++ -std=c++20 -Wall -Wextra -Wpedantic -pthread modern-coroutines.cpp -o example && ./exampleExpected result
6 0
CHECK YOUR UNDERSTANDING
Close the answer. Explain it.
If final_suspend is changed to suspend_never but Generator still destroys the frame in its destructor, what ownership problem occurs?
Show a reference answer
After normal completion the frame may already have been destroyed automatically, yet Generator still holds the original handle, so a later check or destroy may access an invalid frame. This wrapper's contract depends on final suspend keeping the frame; you cannot change only one return type. To adopt an auto-destroy strategy you must redesign handle-invalidation notification and the ownership protocol, not keep reusing the existing destructor logic.
Check the sources
Drafts and official chapters change. The version mark is only the example’s minimum.