Introduction to C++20 Coroutines - Part 1 Generator
This series is related to C++20 coroutine.
part 1: Generator
part 2: co_await/co_return
part 3: Some tips about life cycle when using coroutines
part 4: Some examples about co_await
part 5: stackful/stackless and Symmetric Transfer
Why corouties were imported in C++20? In order to explain it, we could begin with some examples about Fibonacci sequences.
Fibonacci
At the beginning, we can write down code like below.
0
1 |
|
This function receives an integer that indicates the size of the Fibonacci sequence needed.
However, there are a few disadvantages:
- It returns an vector to store the temporary result, and the vector is just for traversalling once. The occupied space of this temporary vector can’t be ignored in case the needed size of sequence is pretty large.
- It can’t support the occasion if we would like to get an infinite sequence.
1
1 |
|
As the code rewritten, it fixes the issues above with static variables. It not only uses minimum occupied space, but also supports for generating an infinite sequence.
But it still looks not enough to be considered as perfect. If we would like to get multiple independent sequences? After all, static variables can be initialized just once, and they have own states afterward.
2
1 |
|
From the thought about states, we can easily to encapsulate them in a generator class and update their states within the generator’s member function.
Bring Coroutines in
The states look easy to update because the context is simple. Trouble is coming if we have to do something with complicated context such as throw elements from two or even more vectors in a particular order like round robin. In many cases, we need to maintain the update of various states even a state machine.
Coroutines are brought in to reduce the cost of mantaining various states so that we focus on the work code itself.
Coroutines in Python
1 |
|
When finonacci()
is called, the generator is returned but the function doesn’t start to run yet. When next(fibonacci_gen)
is called every time, the execution inside the finonacci
function runs until it encounters the keyword yield
. It stores the current execution, suspends and returns to the outside execution. After next(fibonacci_gen)
is called again, the execution inside the finonacci
function will resume and continue to run until it encounters the keyword yield
again.
The execution workflow is actually controlled with the help of compiler.
Coroutines in C++
Declaration of coroutine
1 |
|
The declaration of coroutines in C++ is similar to Python, and the execution flow is also easy to understand. Isn’t it?
There are 3 keywords co_yield
co_await
co_return
are brought in in C++20. Once there are keywords among them occur in a scope, it’s actually a coroutine rather than a subroutine used in traditional C++. We only use co_yield
here.
Now we definitely have at least 2 questions:
- How to implement
fibonacci_generator
? - How to use
fibonacci()
?
I will explain them on by one.
Implementation of generators
Apart from subroutines we are familiar with, the return type of coroutines must be written in a particular standard, otherwise it won’t pass compiling. Let’s follow the complaint from compiler to fill the needed contents little by little.
The below code is compiled under gcc 11.3.0
.
So far (March 2023), some compilers don’t provide complete support about coroutines. For instance, in clang, header file about coroutine is <experimental/coroutine>
, and components about coroutine are in namespace std::experimental
.
Declaration of fibonacci_generator
Assume we write the definition of fibonacci_generator
.
1 |
|
There is a complaint from compiler:
1 |
|
Add a struct or class promise_type
in fibonacci_generator
.
Declaration of promise_type
1 |
|
There are several complaints from compiler:
1 |
|
Definition of promise_type
Since there are a few functions and contents, let me fill them in and explain them in comments.
In order to distinguish the execution time, I add some output to help understand.
1 |
|
The form of promise_type
is specified by compiler. We can consider it an interface from compiler and we must implement it with its form.
Imagine a simmilar case. If we would like to make an object iterable, we must make it provide some specified functions begin()
, end()
and make its iterator provide operator++()
, operator!=
and operator*()
. Only in this way the functionality could be implemented.
1 |
|
Usage of fibonacci_generator
Before we use fibonacci_generator
, add a member function operator()
into it to call it like a function calling.
1 |
|
1 |
|
1 |
|
Output
1 |
|
To understand the life cycle of coroutines or other cases, there is a useful method to insert some output at the beginning and the end of functions and other any necessary locations. :)
More Explanations
1. How to understand promise_type
?
It indicates the situation of the coroutine. It’s like a controller that not only stores states and context but also controls coroutine’s suspension and resumption.
2. How to understand coroutine_handle
?
As the name implies, it’s a handle to a coroutine. Haha…
I have an informal idea to understand it. We might consider it as the pointer to the coroutine. coroutine_handle<>
points to the coroutine itself and coroutine_handle<promise_type>
points to the promise_type
object.
- With
coroutine_handle<promise_type>
we’are able to access thepromise_type
to read or write states inside the return type(fibonacci_generator
is the return type in above example) or even outside the coroutine. - We can just use
coroutine_handle<>
if we don’t need to access any states of the coroutine.
3. How to release coroutine safely?
- Carefully consider the return type of
initial_suspend
set asstd::suspend_always
std::suspend_never
or other custom schema. - Sometimes we need to access states in the coroutine as the coroutine has ended, we must set the return type of
final_suspend
asstd::suspend_always
, otherwise they’re actually wild resources which are unsafe. At the same time, placehandle_.destroy()
at the destructor of the return type with RAII. - Note that never call
handle_.destroy()
if the return type offinal_suspend
asstd::suspend_never
. - It’s better to estimate whether the coroutine is done via
handle_.done()
or not when it’s called every time. It’s callable only if it isn’t done.
4. Construct promise_type
with parameters
1 |
|
It can be understanded that the coroutine is initialized by some parameters.
5. Abstract Workflow
The abstract workflow of coroutine is below:
1 |
|
If we ignore exception handler, it can be simplified as below:
1 |
|
6. the Perspective of co_yield
as Syntax Sugar
co_yield x
is equivalent to co_await promise.yield_value(x)
.
Example: Pop up elements from multiple vectors
1 |
|
References
The sub items below are where coroutines can be used.
- How C++20 Changes the Way We Write Code - Timur Doumler - CppCon 2020
- Generator
- Compiler
- C++20’s Coroutines for Beginners - Andreas Fertig - CppCon 2022
- Deciphering C++ Coroutines - A Diagrammatic Coroutine Cheat Sheet - Andreas Weis - CppCon 2022
- syncronous
read()
and asyncronousco_await async_read
- syncronous
- 知乎专栏: C++20 新特性 协程 Coroutines(1)