C++ - C++20 Coroutines and Asynchronous Programming
C++20 introduced coroutines as a language feature that allows a function to pause its execution and resume it later. Unlike a normal function, which generally runs from beginning to end once called, a coroutine can suspend at specific points, return control to its caller, and continue from exactly where it stopped. Coroutines are particularly useful for asynchronous operations, event-driven applications, lazy data generation, and non-blocking I/O. (Cppreference)
1. What Is a Coroutine?
A coroutine is a special type of function whose execution can be suspended and resumed.
In a normal function:
void process()
{
cout << "Step 1";
cout << "Step 2";
cout << "Step 3";
}
When process() is called, it normally executes all three statements sequentially before returning.
A coroutine can behave differently:
Task process()
{
cout << "Step 1";
co_await some_operation();
cout << "Step 2";
}
When execution reaches co_await, the coroutine may suspend. Control can return to the caller while the awaited operation continues. Later, the coroutine can resume and execute "Step 2".
The important point is that the coroutine remembers the state required to continue execution.
C++ coroutines are described as stackless coroutines. Their state is stored separately from the ordinary call stack so that the coroutine can be suspended and resumed later. (Cppreference)
2. Why Are Coroutines Needed?
Traditional asynchronous programming can become difficult when many operations depend on one another.
Consider a program that needs to:
-
Send a request.
-
Wait for the response.
-
Process the response.
-
Send another request.
-
Wait again.
-
Process the second response.
A traditional approach might involve callbacks:
request1([](Response r1) {
request2(r1, [](Response r2) {
process(r2);
});
});
As the number of asynchronous operations increases, nested callbacks can become difficult to read and maintain.
Coroutines allow the code to be written in a more sequential-looking form:
auto process()
{
auto response1 = co_await request1();
auto response2 = co_await request2(response1);
process(response2);
}
The program still performs asynchronous operations, but the source code is easier to follow.
Coroutines therefore provide a mechanism for expressing asynchronous control flow without requiring the programmer to manually divide the logic into many callbacks.
3. The Three Important Coroutine Keywords
C++ provides three special keywords associated with coroutines:
co_await
co_yield
co_return
A function becomes a coroutine when its body contains one of these constructs. (Cppreference)
Each keyword has a different purpose.
co_await
co_await is used to suspend a coroutine until an asynchronous or awaitable operation reaches a point where execution can continue.
Example:
Task download()
{
auto data = co_await download_file();
process(data);
}
The coroutine can suspend at:
co_await download_file();
and resume later.
The co_await expression is the main mechanism used for asynchronous suspension and resumption. (Cppreference)
co_yield
co_yield is used when a coroutine needs to produce a sequence of values one at a time.
For example:
Generator<int> numbers()
{
for (int i = 1; i <= 5; ++i)
co_yield i;
}
Instead of producing all five numbers immediately, the coroutine can produce one number, suspend, and later resume to produce the next number.
This is particularly useful for generators, lazy sequences, streams, and large datasets. (Cppreference)
co_return
co_return indicates that the coroutine has completed.
Example:
Task<int> calculate()
{
int result = 10 + 20;
co_return result;
}
In coroutine machinery, the returned value is communicated through the coroutine's promise object rather than through an ordinary function return mechanism. (Cppreference)
4. Understanding Suspension
Suspension is the central idea behind coroutines.
Suppose we have:
Task example()
{
cout << "A";
co_await operation();
cout << "B";
}
The conceptual execution can be understood as:
Start coroutine
|
v
Print A
|
v
co_await operation()
|
v
Suspend
|
v
Caller continues doing other work
|
v
Operation completes
|
v
Coroutine resumes
|
v
Print B
|
v
Coroutine finishes
The coroutine does not necessarily block the thread while waiting. Instead, it can give control back to another part of the program.
This is one of the major reasons coroutines are useful for asynchronous programming.
5. How co_await Works
The internal mechanism behind co_await is more sophisticated than an ordinary function call.
An object used with co_await is treated as an awaitable. The coroutine obtains an awaiter, which provides operations controlling whether and how suspension occurs.
Conceptually, an awaiter provides three important operations:
await_ready()
await_suspend()
await_resume()
For example:
struct Awaiter
{
bool await_ready()
{
return false;
}
void await_suspend(std::coroutine_handle<> handle)
{
// Arrange for the coroutine to be resumed later
}
int await_resume()
{
return 42;
}
};
Their roles are broadly:
await_ready()
Determines whether the coroutine needs to suspend.
If it returns true, the operation is considered ready and suspension can be avoided.
await_suspend()
Called when suspension is required.
This function can arrange for the coroutine to be resumed later, perhaps after an I/O operation completes or an event occurs.
await_resume()
Called when the coroutine resumes.
It provides the result of the awaited operation.
The standard coroutine mechanism defines how an awaitable is converted to an awaiter and how these operations participate in suspension and resumption. (Cppreference)
6. Coroutine State
A normal function uses the call stack for its local variables and execution state.
A suspended coroutine needs its state to survive after the function temporarily returns control to its caller.
C++ therefore maintains a coroutine state, which can contain information such as:
-
Local variables that must remain alive.
-
Function parameters.
-
The current suspension point.
-
The promise object.
-
Information required to resume execution.
This allows a coroutine to continue from the correct location after suspension.
For example:
Task process()
{
int value = 100;
co_await operation();
cout << value;
}
When the coroutine suspends, value needs to remain available because the coroutine may use it after resumption.
7. The Promise Object
One of the more advanced concepts in C++ coroutines is the promise object.
The coroutine's promise object is responsible for communicating with the coroutine's caller and managing important aspects of its result and lifecycle.
It is important not to confuse the coroutine promise object with std::promise. They are different concepts. (Cppreference)
A custom coroutine return type generally contains a nested promise_type.
A simplified example looks like:
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()
{
std::terminate();
}
};
};
The compiler uses this promise_type to determine how the coroutine is created, suspended, completed, and how its result is handled.
The promise object participates in operations such as:
get_return_object()
initial_suspend()
return_value() / return_void()
unhandled_exception()
final_suspend()
These functions form part of the coroutine customization mechanism. (Cppreference)
8. Coroutine Handle
Another important concept is:
std::coroutine_handle
A coroutine handle provides a way to refer to a coroutine's execution state.
It can be used to perform operations such as:
handle.resume();
to resume a suspended coroutine.
It can also be used to destroy the coroutine state:
handle.destroy();
The coroutine support library defines std::coroutine_handle specifically for referring to suspended or executing coroutines. (Cppreference)
A simplified conceptual example is:
std::coroutine_handle<> handle;
if (!handle.done())
{
handle.resume();
}
In real applications, developers generally use higher-level coroutine abstractions such as task or generator types rather than manually manipulating handles everywhere.
9. std::suspend_always and std::suspend_never
C++ provides two useful awaitable types:
std::suspend_always
std::suspend_never
They are available through the coroutine support library. (Cppreference)
std::suspend_always means that execution should suspend at that point.
std::suspend_never means that execution should not suspend at that point.
For example:
std::suspend_always initial_suspend() noexcept
{
return {};
}
This can make a coroutine initially suspended.
On the other hand:
std::suspend_never initial_suspend() noexcept
{
return {};
}
allows the coroutine to start executing immediately.
The choice depends on the design of the coroutine abstraction.
10. Generators and co_yield
Coroutines are not limited to asynchronous operations.
They are also useful for generating values lazily.
Consider generating numbers from 1 to 5:
Generator<int> numbers()
{
for (int i = 1; i <= 5; ++i)
{
co_yield i;
}
}
Conceptually:
Generate 1
|
suspend
|
resume
|
Generate 2
|
suspend
|
resume
|
Generate 3
|
...
Only the value currently requested needs to be produced.
This can be advantageous when dealing with large or potentially infinite sequences because the program does not necessarily have to construct the entire sequence in memory.
C++23 additionally provides std::generator for synchronous coroutine-based generation. (Cppreference)
11. Asynchronous Programming
The most common reason to learn coroutines is asynchronous programming.
Imagine an application downloading a large file.
A blocking approach might look conceptually like:
Start download
|
v
Wait
|
v
Download completes
|
v
Continue
During the waiting period, the thread may be unable to perform useful work.
An asynchronous coroutine can instead behave like:
Start download
|
v
Suspend coroutine
|
+----> Thread performs other work
|
v
Download completes
|
v
Resume coroutine
|
v
Process downloaded data
This can make applications more responsive and can allow system resources to be used more efficiently.
However, coroutines themselves do not automatically make an operation asynchronous. The underlying operation or coroutine framework must provide a mechanism for scheduling, waiting, and resuming. A coroutine is primarily a language mechanism for expressing suspend/resume control flow.
12. Coroutines Versus Threads
Coroutines and threads are related to concurrency but are not the same thing.
A thread represents an execution context managed by the operating system or runtime environment.
A coroutine is a suspendable unit of execution whose state can be resumed later.
For example:
Threads
Thread A ---------------------------->
Thread B ---------------------------->
Coroutines
Coroutine A --suspend--resume--suspend--resume-->
Coroutine B -------resume--suspend-------------->
Multiple coroutines can potentially execute on a single thread.
This means coroutines can be much lighter than creating a separate operating-system thread for every logical asynchronous operation.
However, whether coroutines actually improve performance depends on the application, scheduler, workload, and underlying asynchronous operations.
13. Coroutines and Multithreading Are Different Concepts
It is important not to assume:
Coroutine = Thread
They are not equivalent.
A coroutine can suspend without creating another thread.
For example:
Task operation()
{
auto result = co_await async_operation();
process(result);
}
The coroutine might suspend while an external operation is in progress and later resume on the same thread or another thread, depending on the asynchronous framework.
Therefore:
-
Coroutines control suspension and resumption.
-
Threads provide execution contexts.
-
A scheduler or asynchronous framework can determine where and when coroutines resume.
This distinction is particularly important when designing concurrent applications.
14. Exception Handling in Coroutines
Coroutines can also deal with exceptions.
If an uncaught exception escapes a coroutine, the coroutine machinery invokes the promise's unhandled_exception() function and then proceeds toward final suspension. (Cppreference)
A simplified promise might contain:
void unhandled_exception()
{
exception = std::current_exception();
}
The coroutine framework can then make the exception available to the caller.
This allows asynchronous operations to communicate errors without requiring deeply nested callback-based error handling.
15. Lifecycle of a Coroutine
A simplified coroutine lifecycle can be represented as:
Coroutine created
|
v
Initial suspension
|
v
Coroutine starts
|
v
Execute statements
|
v
Encounter co_await / co_yield
|
v
Suspend
|
v
Resume later
|
v
Continue execution
|
v
co_return
|
v
Final suspension
|
v
Coroutine destroyed
The exact behavior depends on the coroutine's promise_type, including its initial and final suspension policies.
Understanding this lifecycle is essential when working with custom coroutine types.
16. A Simple Conceptual Example
Consider a coroutine that generates numbers:
Generator<int> generate()
{
for (int i = 1; i <= 3; ++i)
{
co_yield i;
}
}
A caller might conceptually consume it like this:
auto values = generate();
for (auto value : values)
{
cout << value << '\n';
}
The sequence works conceptually as follows:
generate()
|
v
co_yield 1
|
suspend
|
caller receives 1
|
resume
|
co_yield 2
|
suspend
|
caller receives 2
|
resume
|
co_yield 3
|
suspend
|
caller receives 3
|
resume
|
coroutine completes
This illustrates the fundamental suspend-resume model without introducing complicated asynchronous networking code.
17. Advantages of Coroutines
Coroutines offer several important benefits.
Improved readability
Asynchronous operations can often be written in a sequential style.
Reduced callback nesting
Complex asynchronous workflows can become easier to understand than deeply nested callbacks.
Efficient lazy computation
co_yield allows values to be generated only when needed.
Better asynchronous control flow
Operations involving I/O, timers, events, and other asynchronous activities can be represented using suspension points.
Potentially lower overhead than thread-per-operation designs
Many coroutines can be managed without creating one operating-system thread for each operation, depending on the framework and scheduler.
Easier state management
The coroutine state preserves the information necessary to continue after suspension.
18. Limitations and Challenges
Coroutines are powerful, but they are not automatically the best solution for every problem.
The underlying coroutine machinery can be complicated.
Concepts such as:
promise_type
coroutine_handle
awaiter
awaitable
initial_suspend
final_suspend
can be difficult for beginners.
There can also be lifetime problems if references, pointers, or objects are used after suspension without ensuring that they remain valid.
For example, a coroutine may suspend for a long time, so developers must carefully consider the lifetime of objects referenced by the coroutine.
There are also synchronization concerns when coroutine handles are transferred between threads. The standard documentation notes that sharing and resuming coroutine handles across threads requires appropriate synchronization and memory-ordering considerations. (Cppreference)
19. Common Applications
C++ coroutines are useful in areas such as:
Asynchronous networking
Network requests can suspend while waiting for data.
File and disk operations
A coroutine can suspend while an asynchronous file operation is being completed.
Game development
Coroutines can represent timed actions, animations, events, and scripted sequences.
Servers
High numbers of asynchronous requests can be represented using coroutine-based control flow.
Lazy data processing
Large sequences can be generated and processed incrementally.
Event-driven applications
Coroutines can wait for events and continue when those events occur.
Streaming systems
Data can be processed progressively instead of loading everything at once.
20. C++20 and C++23 Support
Coroutines were standardized in C++20. The core language features include:
co_await
co_yield
co_return
and the coroutine support library includes facilities such as:
std::coroutine_handle
std::coroutine_traits
std::suspend_always
std::suspend_never
C++23 further added std::generator, providing a standard synchronous generator abstraction based on coroutines. (Cppreference)
21. Difference Between co_await, co_yield, and co_return
| Keyword | Main purpose | Effect |
|---|---|---|
co_await |
Wait for an awaitable operation | Suspends and later resumes |
co_yield |
Produce a value | Returns a value and suspends |
co_return |
Finish the coroutine | Completes coroutine execution |
A simple way to remember them is:
co_await -> wait
co_yield -> produce
co_return -> finish
22. Important Points to Remember
When learning C++ coroutines, remember these fundamental ideas:
-
A coroutine is a function that can suspend and resume.
-
Coroutines were standardized in C++20.
-
co_awaitis primarily used for suspension around awaitable operations. -
co_yieldis commonly used for generators and lazy sequences. -
co_returncompletes a coroutine. -
A coroutine has an associated promise object.
-
std::coroutine_handlecan be used to refer to coroutine state. -
std::suspend_alwaysandstd::suspend_nevercontrol suspension behavior. -
Coroutines are not the same as threads.
-
Coroutines do not automatically make blocking operations asynchronous; the underlying operation must support asynchronous execution.
-
Coroutine lifetime and object lifetime must be handled carefully.
-
C++23 provides
std::generatorfor standard coroutine-based synchronous generators. (Cppreference)
Conclusion
C++20 coroutines provide a modern way to write functions that can pause their execution, return control to the caller, and resume later from the same point. The three fundamental keywords are co_await, co_yield, and co_return. While co_await is particularly important for asynchronous programming, co_yield makes coroutines useful for lazy generators and incremental data processing.
The most important conceptual distinction is that a coroutine is not a thread. A coroutine represents suspendable execution, while threads provide execution contexts. When combined with an appropriate asynchronous framework or scheduler, coroutines can make complex asynchronous programs considerably easier to read and maintain. (Cppreference)