Part II: C++20 Syntax
This section covers the three keywords that make a function into a coroutine and explains how awaitables control suspension.
The Three Keywords
C++20 introduces three keywords that transform a function into a coroutine:
| Keyword | Purpose |
|---|---|
|
Suspend and wait for an operation to complete |
|
Produce a value and suspend |
|
Complete the coroutine with a final value |
The mere presence of any of these keywords causes the compiler to treat the function as a coroutine, allocating a coroutine frame and generating the suspension machinery.
co_await — Suspend and Wait
The co_await expression suspends the current coroutine until the awaited
operation completes:
task<data> fetch()
{
auto response = co_await http_get(url); // Suspend here
// Execution resumes when http_get completes
co_return parse(response);
}
When co_await executes:
-
The awaited object is queried (can we skip suspension?)
-
If suspension is needed, coroutine state is saved
-
Control returns to whoever resumed this coroutine
-
Later, when the operation completes, the coroutine resumes
-
The result of
co_awaitis the operation’s result
co_yield — Produce a Value
The co_yield expression produces a value to the caller and suspends:
generator<int> range(int start, int end)
{
for (int i = start; i < end; ++i)
co_yield i; // Produce i, suspend
}
co_yield x is shorthand for co_await promise.yield_value(x). The
promise (which we’ll cover later) decides what to do with the yielded value.
co_return — Complete the Coroutine
The co_return statement completes the coroutine with an optional final value:
task<int> compute()
{
int result = 42;
co_return result; // Complete with value 42
}
task<void> work()
{
do_something();
co_return; // Complete (void coroutine)
}
After co_return:
-
The promise stores the returned value (if any)
-
The coroutine enters its final suspension point
-
The coroutine cannot be resumed again
Your First Coroutine
Let’s build a complete, minimal coroutine. We’ll need a return type that tells the compiler how to manage the coroutine.
#include <coroutine>
#include <iostream>
struct MinimalTask
{
struct promise_type
{
MinimalTask get_return_object()
{
return MinimalTask{
std::coroutine_handle<promise_type>::from_promise(*this)
};
}
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> handle_;
};
MinimalTask hello()
{
std::cout << "Hello, ";
co_await std::suspend_never{}; // Makes this a coroutine
std::cout << "World!\n";
}
int main()
{
hello(); // Prints "Hello, World!"
}
The presence of co_await transforms hello() into a coroutine. The
compiler looks for MinimalTask::promise_type to determine how to manage it.
What the Compiler Transforms
When you write a coroutine function, the compiler transforms it roughly like:
// What you write:
task<int> compute()
{
int x = 1;
co_await something();
co_return x + 1;
}
// What the compiler generates (conceptually):
task<int> compute()
{
// Allocate coroutine frame
auto* frame = new __compute_frame();
auto& promise = frame->promise;
// Create return object immediately
auto return_object = promise.get_return_object();
try {
co_await promise.initial_suspend();
// Your code with suspension points
frame->x = 1;
co_await something();
promise.return_value(frame->x + 1);
} catch (...) {
promise.unhandled_exception();
}
co_await promise.final_suspend();
// Frame may be destroyed here or by caller
return return_object;
}
The frame persists across suspensions, holding local variables (x) and
the promise object.
The Coroutine Frame
The coroutine frame is heap-allocated (by default) and contains:
-
Local variables that span suspension points
-
The promise object
-
Bookkeeping for the current suspension point
-
Parameters (copied or moved into the frame)
+------------------------+
| __compute_frame |
+------------------------+
| int x; | ← local variables
| promise_type promise; | ← promise object
| int __suspend_point; | ← which co_await are we at?
+------------------------+
Heap Allocation eLision Optimization (HALO) can sometimes place the frame in the caller’s frame, avoiding the allocation entirely.
Awaitables and Awaiters
When you write co_await expr, the compiler needs to know:
-
Should we actually suspend?
-
What happens when we suspend?
-
What value do we produce when we resume?
These questions are answered by the awaiter object.
The Awaitable Concept
An awaitable is anything that can be used with co_await. The compiler
converts it to an awaiter using these rules (in order):
-
If the promise has
await_transform, call it:promise.await_transform(expr) -
Otherwise, if
exprhasoperator co_await, call it -
Otherwise, use
exprdirectly as the awaiter
The Awaiter Interface
An awaiter must provide three methods:
struct Awaiter
{
bool await_ready(); // Skip suspension?
??? await_suspend(std::coroutine_handle<> h); // Do the suspension
T await_resume(); // Get the result
};
await_ready()
Returns true if the result is already available and suspension should be
skipped. This is an optimization—if the operation completed synchronously,
we can avoid the suspension overhead.
bool await_ready()
{
return result_already_cached_;
}
await_suspend(handle)
Called when we actually suspend. Receives the coroutine handle, which can be stored and used later to resume the coroutine.
The return type affects behavior:
| Return type | Behavior |
|---|---|
|
Always suspend (caller continues) |
|
|
|
Suspend and immediately resume the returned handle (symmetric transfer) |
void await_suspend(std::coroutine_handle<> h)
{
// Store handle, start async operation
async_start([h]() {
h.resume(); // Resume when done
});
}
// Or: symmetric transfer to another coroutine
std::coroutine_handle<> await_suspend(std::coroutine_handle<> h)
{
store_continuation(h);
return next_coroutine_to_run();
}
Built-in Awaiters
The standard provides two simple awaiters:
struct std::suspend_always
{
bool await_ready() { return false; } // Always suspend
void await_suspend(std::coroutine_handle<>) {}
void await_resume() {}
};
struct std::suspend_never
{
bool await_ready() { return true; } // Never suspend
void await_suspend(std::coroutine_handle<>) {}
void await_resume() {}
};
These are building blocks for promise types (e.g., initial_suspend()).
Awaiter Example: Simple Timer
Here’s a complete awaiter that suspends for a duration:
#include <chrono>
#include <thread>
struct sleep_for
{
std::chrono::milliseconds duration_;
bool await_ready() const
{
return duration_.count() <= 0; // No sleep needed
}
void await_suspend(std::coroutine_handle<> h) const
{
// In real code, use a proper async timer
std::thread([=]() {
std::this_thread::sleep_for(duration_);
h.resume();
}).detach();
}
void await_resume() const {}
};
// Usage:
task<void> delayed_work()
{
std::cout << "Starting...\n";
co_await sleep_for{std::chrono::seconds(1)};
std::cout << "One second later\n";
}
| This example detaches a thread, which is rarely appropriate in production code. Real async timers integrate with event loops. |
Summary
| Element | Purpose |
|---|---|
|
Suspend until operation completes |
|
Produce value and suspend |
|
Complete coroutine with final value |
|
Can we skip suspension? |
|
Perform suspension, maybe store handle |
|
Return result when resumed |
Next Steps
You’ve learned the syntax. Now understand the machinery that makes it work:
-
Part III: Coroutine Machinery — Promise types and handles