0: A short series on Coroutines and Modern C++
Coroutines: Basics, Py->C++20,
Benchmarking
- Introduction
- What are coroutines?
- What coroutines can look like in C++
- Benchmark generators: C++03, C++20 & Python
This is a mini-series leading up to implementing coroutines in C++20, (“what I learnt on my holiday”). It also has comparison with other implementations, as part of my ModernCPP explorations. (first post)
Introduction
This is the first in a mini-series of posts leading up to implementing coroutines in C++20.
This is something I did while on holiday, as were most of these notes, I’ve just editted this into some better shape. It’s really part of an infrequent series of posts on Modern C++. The reason this became a (sub) series of posts itself rather than one is because it became, frankly, huge and unwieldy. So instead, I’ve split it and this is an overview post which links to the individual posts in the headings - which go into greater depth.
First of all I cover what coroutines are, using python’s PEP 255 simple generators as an example. I then move on to what they can look like in both old style C++ and modern C++20 form.
Given the real focus of this series of posts is “Modern C++”, the post on the implementation of C++20 coroutines is remarkably lengthy, but stay with it. As a contrast, I include a post on implementing coroutines in C++03 - which is by contrast, remarkably short. I’m really undecided on which is actually “better” at this stage.
The reason I’m undecided is because after benchmarking, the performance of C++20 coroutines isn’t what I would expect. While I expected the C++03 version to have a difference, I didn’t expect it to be that much better. (However, the C++03 version is definitely a little … odd .. even from a C++ perspective)
The rest of this post gives a brief summary of each post under headings/links to each post in the series.
Where’s the code?
Update: 11/4/2023
Want to read and runcode, not the blog? I know the feeling. I recently created a github repo specifically for putting code from blog posts in. Though recognising that it might also have non-code stuff added at some point, I’ve called it blog-extras.
The blog-extras repo can be found here:
The code specific to this series of short posts can be found here:
That repo will get fleshed out with code/examples from other posts in this blog too.
What are coroutines?
Coroutines are subroutines that can return and continue. Consider the following simple function:
def printing_fibonnaci():
= 1,1
a, b while True:
print("Fib:", a)
= b, a+b a,b
When run, this prints the fibonacci sequence. If we have another function that wants to use this sequence to calculate the golden ratio, it can’t do this:
# NOTE: This is not valid, it won't work
= 1
last for i in printing_fibonnaci(): # NOTE: This won't work
= i/last
gr print("Golden Ratio:", gr)
= i last
If however we replace “print” with “yield”, we can do this:
def fibonnaci():
= 1,1
a, b while True:
yield a
= b, a+b
a,b
= 1
last for i in fibonnaci():
= i/last
gr print("Golden Ratio:", gr)
= i last
The reason for this is because the “yield” statement turns
fibonacci
from being a function (subroutine) to a generator
(simplified coroutine). In particular calling the function returns an
object that can be iterated over to yield
new values.
>>> x = fibonnaci()
>>> x.__next__()
1
>>> x.__next__()
1
>>> x.__next__()
2
>>> x.__next__()
3
>>> x.__next__()
5
From this simple mechanism you can compose all sorts of useful systems and it underlies an awful lot of the modern async systems. For some 20 year tech in this arena see my work on Kamaelia and in particular mini-axon.
However, the whole async world in Python over the past 20 years has grown significantly beyond PEP255 generators - so this just scratches the surface.
What CAN C++ Coroutines look like?
C++ doesn’t enforce a particular style of coroutine. So any
discussion of coroutines tends to include discussion of the
implementation of the coroutine library. I think this is a bit of
mistake in C++20 because it replicates the problems of memory management
prior to the existence of
unique_ptr
/shared_ptr
. You get the pieces to
build things, but you’re expected to DIY a working coroutine system. (or
pick someone else’s idea of what they should look like)
C++20 Based generator
This can look similar to the Python version:
// Generator definition:
<int> fibs(int max) {
simple_generatorint a{1}, b{1}, n{0};
for(int i=0; i< max; i++) {
co_yield a;
= a + b;
n = b;
a = n;
b }
}
// Usage:
for(auto i : fibs(20) ) {
("FIB : {}\n", i);
print}
- Pros: Looks clean, not too dissimilar to other languages, looks “C++”-y
- Cons:
- Implementation overhead (cognitive and performance)
- What the lines above actually mean is NOT standardised. You might think they mean specific things, and behaviour but you have no guarantees.
- In particular, there’s no hint that this is dependent entirely on the implementation of generators that this code is relying on. (It’s essentially no better than pseudocode without specifying that implementation)
C++03 Based generator
This builds on some Duff’s device style trickery and doesn’t look too bad, but has some oddities. (The implementation this uses is about 18 1/2 years old and somewhat naive C++)
// Generator definition
class Fibonnaci: public Generator {
int a, b, s;
public:
() : a(1), b(1), s(0) { }
Fibonnaciint next() {
GENERATOR_CODE_STARTwhile ( true ) {
(a);
YIELD= a + b;
s = b;
a = s;
b };
GENERATOR_CODE_END};
};
// Usage:
void runFibonnaci() {
;
Fibonnaci afor(int j=0; j<22; j++) {
try {
std::cout << "Yield in Fibonnaci:"<< j<< " value:" << a.next() << std::endl;
} catch(StopIteration null){
std::cout << " Exception Caught" << "...\n";
break;
}
};
}
- Pros:
- The usage here is similar to how python’s works under the hood
- Lightweight
- Much higher performance
- Clear route to serialisation/pausing of state
- Relies on a feature of the language that won’t go away
- Oddness means you’re more likely to look up what this actually does?
- Not likely to be implemented differently by a “similar” library
- Cons:
- A bit odd even for C++
- “Local” variables are explicitly attributes of the generator object leading to initialisation of local variables being a bit “odd”.
- Relies on preprocessor macros to function.
- Probably more around the oddity of implementation
- Without enabling exception handling in emscripton this will not work in a webassembly compiled version of this. (we’d need to use a sentinels instead) (See emscripton:C++ Exceptions Support)
Implementing C++20 and C++03 Generators
The implementation of these is somewhat out of scope in an overview like this. I can provide some headlines here though.
C++03 Implementation
The C++03 version is fundamentally a combination of:
- Duff’s device - to implement “yield”
- The
__LINE__
magic variable that C compilers auto populate. - Classes - to provide the equivalent of a stack frame
- Pre-processor macros - to clean up the code
The actual code for this is 24 lines of relatively low complexity code. It’s also 18 1/2 years old, and not really needed changes in that time. (I also use this code in Pyxie - a sometimes worked on py-/-C++ compiler of mine)
It’s also loosely based on a post by Simon Tatham about coroutines in C as used by the Putty ssh client.
C++20 Implementation
The C++20 version has the following core attributes:
- You define a base generator class. Part of this has subclass which defines how the coroutine operates
- This s templated to allow changing the return type.
- This class provides an external API for people implementing
application specific coroutines (like
fibonacci
) to use. - Your coroutine class contains a subclass that defines an
internal API. This is hooked into by the compiler when specific
things happen at runtime (like
co_yield
,co_return
, etc) - A second external API to enable use in
for
loops.
The overview of this looks like this:
template<typename T>
class simple_generator {
public:
class promise_type; // Forward decl - to be defined lower inside this class
using handle_type = std::coroutine_handle<promise_type>;
private:
handle_type mCoro;
public:
// External API implementation
class promise_type { } // Internal coroutine API
class iterator { } // Actual iterator impl
// iterator external API
// TBD - see below
}
The details of this are somewhat involved and definitely a lot longer than 24 lines, and there’s definitely a higher cognitive complexity involved! I’m going to leave discussing implementation there in this post, and encourage you to look at the post that covers this instead.
This leads to one of the downsides of C++ coroutines: any you write
are very specific to the particular brand of coroutine or generator
implementation libraries. On the surface of things, this leads to people
going “Oh, just use Lewis Baker’s cppcoro
library` – except
that’s now not maintained.
IMO, C++20 coroutines as a result feel a bit like how smart pointers
used to be before unique_ptr
/shared_ptr
came
into existence/gained traction. (Do you use your own, do you use boost’s
or …)
Benchmarking Generators
Not to put too fine a point on it, performance follows underlying implementation complexity. The benchmarking post also “only” really measures the “context switch” time.
The code that’s being run in the body of the co-routine will run at the same speed for the language so the benchmarks are a little pathological.
As a result it’s not a spoiler to note that the Python code is the slowest and the C++03 code is the fastest. The margin of difference between C++03 and C++20 does give me pause.
Discussion
The key comments in the benchmarking post contain a brief discussion of the results. However, in summary, I think the key points are:
- The context switch in C++ is faster than in python
- The C++20 coroutines/generators are clearer than the C++03 equivalents and more idiomatic for the language and look closer to the python equivalents
- The C++03 implementation is dramatically faster than both python and C++20.
- If you need raw performance for context switching, switching to a C++03 style implementation is perhaps a good idea.
If you’re interested and writing systems in C++, the C++20 approach is probably easier to start off with at least. However if you’re generating C++ code from another system - like I do with Pyxie (my slowly evolving python->C++ compiler) sticking with the C++03 coroutines has real benefits.
I do find C++20 coroutines a little disappointing considering the very naive C++ code I wrote 18 1/2 years ago performs so much better. However, the clarity of the C++20 code cannot be underestimated - especially when debugging systems.
NEXT POST: The first post in this short series asks (and answers!) “What are coroutines?”
Updated: 2023/09/15 21:12:30