Coroutines were introduced in C++20, and let me tell you, they are amazing for any and all asynchronous applications. A coroutine is a function that you can suspend and resume later, and C++20 gives you complete control over how, when, where suspending/resuming happens. I’ve written a simple coroutine library which you can use to follow along with this post, and all code will be linked at the bottom of the article for you to experiment with!
C++20 introduces 3 new keywords which control the flow of execution. These are co_await
, co_yield
, and co_return
, and each one has an effect on control flow. They’re used in much the same way as return
and throw
:
// Return a value
return x;
// Throw a value
throw x;
// Await on a value
co_await x;
// Return a value from a coroutine
co_return x;
// Yield a value from a coroutine
co_yield x;
The use of any of these keywords quite literally makes a function a coroutine. From section 9.5.4.1 of the C++20 standard:
A function is a coroutine if its function-body encloses a coroutine-return-statement (8.7.4), an await-expression (7.6.2.3), or a yield-expression (7.6.17). The parameter-declaration-clause of the coroutine shall not terminate with an ellipsis that is not part of a parameter-declaration.
This statement is rather dense, but in essence, it means that any function which uses either a co_await
, a co_yield
, or a co_return
is a coroutine, and the compiler should treat it accordingly. It also means that a coroutine is a function, and you can use it as you would any other function.
In this post, I use three types that I wrote as part of the conduit library. These types represent different kinds of coroutines. They are:
coroutine
, which represests a simple coroutine that doesn’t return or yield valuesgenerator<T>
, which represests a coroutine that generates values asynchronouslyfuture<T>
, which represents a coroutine that produces a value asynchronously
In future posts, we’ll discuss in depth what these keywords do, as well as how to use these and other types, although for now here’s a brief summary.
co_await
co_await x;
can be used to temporarily suspend a coroutine and return control somewhere else. The specifics of what happens depend on the type of x
, but the behavior is fully and easily customizable to fit a wide range of needs: control can be returned to the calling function, to a different coroutine, to a task scheduler, or to whatever else you create.
The key thing here is that for the expression co_await x;
to be valid, x
must be awaitable. Being awaitable is nothing fancy: it just means that you provide a function named await_ready
, a function named await_suspend
, and a function named await_resume
:
// Objects of this type are awaitable
class my_awaitable {
public:
bool await_ready() {
std::cout << "Calling await_ready()\n";
return false;
}
void await_suspend(std::coroutine_handle<>) {
std::cout << "Calling await_suspend()\n";
}
void await_resume() {
std::cout << "Calling await_resume()\n";
}
};
Because await_ready()
returns false, awaiting on an object of my_awaitable
will cause the coroutine to suspend:
coroutine test_my_awaitable() {
std::cout << "Began test_my_awaitable\n";
co_await my_awaitable{}; /* Suspends here & returns to caller */
// Once the caller resumes the coroutine, it prints this and exits:
std::cout << "Finished test_my_awaitable\n";
}
Note that await_resume()
doesn’t have to return void. If await_resume()
returns a value, then that value will be returned from the co_await
expression! We’ll see an example of this later.
co_yield
co_yield x;
can be used to yield a value from a coroutine. This allows you to write generator coroutines, which produce a series of values one at a time. Each time a generator produces a value, it suspends, and control is returned to the caller:
generator<int> count_to_10() {
for(int i = 1; i <= 10; i++) {
co_yield i; /* Suspends here & returns to caller */
}
}
We can treat a generator
as though it were a container or a list of numbers:
int main() {
generator<int> g = count_to_10();
for(int num : g) {
std::cout << num << ' ';
}
std::cout << "\nDone\n";
}
Every time execution reaches co_yield
, the coroutine will suspend, and control gets transferred back to main
. The output will be:
1 2 3 4 5 6 7 8 9 10
Done
co_return
co_return x;
can be used to return a value from a coroutine. Unlike co_yield
, a coroutine is finished after a co_return
statement, and won’t produce any additional values.
In the simplest example, we don’t have to do anything else:
future<int> get_value() {
co_return 42; // I like the number 42
}
However we an also write our future
class so we can await on it, and then things get more interesting:
future<int> add_async(future<int> a, future<int> b) {
// Run a and b on other threads
std::thread t1(a);
std::thread t2(b);
// Get the values from a and b once they complete
int value1 = co_await a;
int value2 = co_await b;
// Return the sum of the values
co_return value1 + value2;
}
Here, awaiting on a future
will suspend the coroutine until the future produces a value, and co_await
returns that value.
So where does the return value come from?
We’ve established that a coroutine is a function, and like any other function, when you invoke a coroutine, it returns a value. One of the things that makes a coroutine special is that the compiler will automatically generate the return value for us, and this return value provides a way to communicate with the coroutine.
Let’s take a second look at our generator example:
generator<int> count_to_10() {
for(int i = 1; i <= 10; i++) {
co_yield i; /* Suspends here & returns to caller */
}
}
When you invoke count_to_10()
, the compiler performs the following steps:
- Allocate memory for the coroutine frame
- Create a promise object inside the memory allocated for the coroutine frame (this object has type
generator<int>::promise_type
) - Invoke the member function
get_return_object
on the promise object, and store the result as the return value (this result will be ourgenerator<int>
) - Invoke the member function
initial_suspend
on the promise object, andco_await
on the resulting value. - If
initial_suspend().await_ready()
is false, suspend and return thegenerator<int>
.
Otherwise, begin executing the coroutine until the first time it suspends, and then return thegenerator<int>
. Forcount_to_10()
, this will occur at the firstco_yield
.
If avoiding dynamic allocation is necessary, you can write custom allocators for coroutines, and in many cases (such as generators), the compiler will be able to inline the entirety of the coroutine into the body of the calling function, thereby eliminating the overhead all together!
There’s a lot of other stuff to cover about coroutines, but these are the basics! If you found it interesting, this article was part of a series on coroutines in C++20, and I hope you find the series helpful and informative.
– Alecto Irene Perez