[Typescript/C++]: Coroutines: What are they and what we have in C++20
With my latest project I started working with coroutines. This was a Typescript project where I really loved to use coroutines and since C++20 we have coroutines in C++ too. So let's take a closer look on coroutines in this article, what they are and what we already have in C++20.
Coroutines vs. Functions
The difference between coroutines and functions is illustrated in the picture below. Compared to a function, a coroutine gets suspended (or paused) and can be continued if we want to continue it. Where a function gets called one times and returns one value at one point.
Typescript
As a very first example take a look at the following Typescript code:
function* doWork() {
yield 1;
console.log('do some work');
yield 2;
console.log('do some more work');
yield 3;
console.log('do some more more work');
yield 4;
console.log('do final work');
return 0;
}
const g = doWork();
console.log(g.next().value);
console.log(g.next().value);
console.log(g.next().value);
console.log(g.next().value);
console.log(g.next().value);
which prints this output:
[LOG]: 1
[LOG]: do some work
[LOG]: 2
[LOG]: do some more work
[LOG]: 3
[LOG]: do some more more work
[LOG]: 4
[LOG]: do final work
[LOG]: 0
As you can see we can pause our coroutine and continue doing something else. With each call g.next()
we jump back into the coroutine and continue from the last yield
. g.next()
holds two values, the value
itsself and a boolean flag done
which symbolizes that we're done with our coroutine. All this magic is done by a Generator
.
Now, declare our coroutine explicitly with the generator type. Consider this code:
// return type is a Generator with a number type
// this means we're accessing the numbers then via the generator type
function* getValues() : Generator<number> {
const values = [1,2,3,4,5];
for (const v of values) {
// no return, at this point we'll continue later again.
yield v;
}
}
and now we have some options to call this, here are two:
// option one: avoid creating generator and get each value directly in a for loop
for (const value of getValues()){
// process the value and continue doing something else
console.log(value);
console.log('do something else now');
}
// option two: create a generator and call next() explicitly
// g.next().done -> true or false if we're finished
// g.next().value -> gets the values successively
const g : Generator<number> = getValues();
var next;
// be careful to set the brackets correctly to not end in an endless loop
while (!(next = g.next()).done) {
console.log(next.value);
console.log('do something else now')
}
and either way we receive the values, we get here the same output
[LOG]: 1
[LOG]: "do something else now"
[LOG]: 2
[LOG]: "do something else now"
[LOG]: 3
[LOG]: "do something else now"
[LOG]: 4
[LOG]: "do something else now"
[LOG]: 5
[LOG]: "do something else now"
In my recent project from work I used this often. For instance, instead of collecting data and returning an array, I used the yield
keyword to get each value and processed it one by one from the caller.
Look at this coroutine where we can iterate over all files in a directory recursively including all subdirectories (as you see there is also an async generator type available):
async function* getFiles(dir: string) : AsyncGenerator<string> {
// read the directory and loop through all files
// if it's another directory we call this function again recursively
const files = await readdir(dir, { withFileTypes: true });
for (const f of files) {
const res = resolve(dir, f.name);
if (f.isDirectory()) {
yield* getFeatureFiles(res);
} else {
yield res;
}
}
}
// ...
// and the function call looks like this:
for await (const file of getFiles(dir){
// do some work with each file
}
C++20's Coroutines
With C++20 we got the three keywords:
co_return
the final return value of the coroutineco_yield
pauses the coroutine and gives us a valueco_await
pauses the coroutine and we can pass a value from the caller to the coroutine
Unfortunately we didn't get the gnerator type which we saw in the Typescript examples above. So we'll implement them ourselves. In this article we'll cover co_yield
and co_return
. For a very good beginner guide on coroutines I recommend Andreas Fertig's talk
C++20’s Coroutines for Beginners.
get_values()
Coroutine in C++
Like in the example above, we'll create a get_values()
coroutine which gives us some values. First of all we need to implement the generator type:
// forward declaration of a promise type
// the template parameter represents the value type which we want to return
template<typename T>
struct promise;
// this is our generator where we inherit cpp20's coroutine_handle
template<typename T>
struct generator : std::coroutine_handle<promise<T>> {
// we need this alias because a valid generator must have 'promise_type'
// otherwise we'll get a compilation error
using promise_type = struct promise<T>;
// next will return the value and continues the coroutine
T next() {
// resume to the next co_yield
this->resume();
// and return the value
return this->promise().m_value;
}
~generator() {
this->destroy();
}
};
// the promise type holds the value we get from each co_yield
// on each suspend function we create cpp20's suspend_always type
// and consider later the output to understand when is what called.
template<typename T>
struct promise {
T m_value;
generator<T> get_return_object() {
std::cout << "get_return_object() " << std::endl;
return {generator<T>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept {
std::cout << "initial_suspend()" << std::endl;
return {};
}
std::suspend_always final_suspend() noexcept {
std::cout << "final_suspend()" << std::endl;
return {};
}
std::suspend_always yield_value(T value) noexcept {
std::cout << "yield_value(T value)" << std::endl;
m_value = std::move(value);
return {};
}
void return_value(T value) {
std::cout << "return_value(T value) " << std::endl;
m_value = std::move(value);
}
void unhandled_exception() {
std::cout << "unhandled_exception()" << std::endl;
}
};
Now we can use this generator with our first coroutine:
// define the coroutine get_values
generator<int> get_values() {
std::vector values{1,2,3,4,5};
for (const auto& v : values) {
// lets get each value one by one
co_yield v;
}
// if we don't add a co_return, we'd get the last value (from the
// last co_yield) at the end of the coroutine another time
co_return 0;
}
int main () {
// create the generator
auto g = get_values();
// while we aren't done with the coroutine
while(!g.done()) {
// we call next() to get the value
std::cout << g.next() << std::endl;
}
return 0;
}
which prints now:
get_return_object()
initial_suspend()
yield_value(T value)
1
yield_value(T value)
2
yield_value(T value)
3
yield_value(T value)
4
yield_value(T value)
5
return_value(T value)
final_suspend()
0
So I'm not gonna lie, it is a bit of work to implement the generator and looking forward to C++23 where we get the generator type in the standard library. You can find this example here on compiler explorer..
Conclusion
I haven't used coroutines in productive C++ code yet, but I'm really looking forward to do so. Also I'm looking forward to C++23 and how coroutines in this language evolve, since coroutines not really something new. The first proposal was from Melvin Conway around 1960.
But anyway, implementing a generator type and playing around helped me a lot to understand coroutines better.
I hope that helped.
Best Thomas