There are more and more examples coming out of how to convert things like the use of futures into coroutines, and you may be forgiven for thinking that there is also some magic that happens in boost::future
or std::future
that lets this work, but that's not the case.
In C++ a coroutine is any function that contains one of the coroutine keywords in its body, that is any of co_return
, co_yield
or co_await
.
What we're going to do is to write a very basic mechanism that allows us to use co_return
to return a value from a coroutine. Coroutines are really a generalisation of a function call, and what this is going to allow us to do is to treat a coroutine as a function call. If we can't do this then we don't stand much chance of doing anything more interesting with them, but it will give us a good starter on how the machinery works.
This version isn't going to be really useful for much, but we'll fix that in the next article.
Let's start off with a coroutine that just returns a value:
sync<int> answer() { std::cout << "Thinking deep thoughts..." << std::endl; co_return 42; }
In order for us to be able to use answer
we have to write a suitable implementation of sync
that interacts in the right way with the compiler so we can do this:
int main() { auto a = answer(); std::cout << "Got a coroutine, let's get a value" << std::endl; auto v = a.get(); std::cout << "And the coroutine value is: " << v << std::endl; return 0; }
Obviously we need an implementation of sync
to use here—that's what we're going to write, but first we need to understand some stuff about what's going to happen here.
When the compiler sees a coroutine it's going to take our function and split it up into parts. How these parts then get executed is what we get to control through the way that sync
works.
If answer
were a normal function it would be entered at the top, the body would run and the return would be used to construct the sync<int>
instance. Coroutines work inside out:
co_return
value turns up at the end of the body execution so we have to put it where the caller can get it.The control for this is through a promise_type
which needs to conform to a particular API. The API is somewhat fluid and the details of what it needs to look like exactly depend on the details of the machinery and the facilities that you want to be able to deal with inside the coroutine.
There's two classes involved here, promise_type
and sync
. The promise_type
represents a part of the coroutine itself (actually part of its stack frame), and the sync
handles how the coroutine interacts with its caller, and ultimately the return value.
For this first version we're only going to support co_return
and not co_yield
or co_await
—we'll get to those later on. We also aren't actually going to do anything asynchronously to start with, we'll be happy if we can get anything at all.
So, to start with we have this (with some tracking for creation and destruction to help us see what's going on):
template<typename T> struct sync { sync() { std::cout << "Created a sync object" << std::endl; } sync(const sync &s) { std::cout << "Copied a sync object" << std::endl; } ~sync() { std::cout << "Sync gone" << std::endl; } void get() { std::cout << "We got asked for the return value..." << std::endl; } struct promise_type { promise_type() { std::cout << "Promise created" << std::endl; } ~promise_type() { std::cout << "Promise died" << std::endl; } }; };
What step 1 above means in practice is that when the coroutine is first entered we have to remember all the stuff it needs somewhere so it'll be available no matter when the body is run. This happens by allocating a single stack frame in the heap which contains the promise_type
in it together with all of the local variables that are used. Right now everything is empty so that's simple enough.
Because we could run the body of the coroutine at any time and any place, we need to make the sync
instance at the start of the function call and not at the end. We tell the promise_type
how to construct the sync
object that the coroutine returns:
auto get_return_object() { std::cout << "Send back a sync" << std::endl; return sync<T>{}; }
This just returns an empty one. The next thing we need to explain to the compiler is whether we want to start on the body straight away or not. Because we want to emulate a normal function the promise_type
needs to answer “yes”:
auto initial_suspend() { std::cout << "Started the coroutine, don't stop now!" << std::endl; return std::experimental::suspend_never{}; }
This takes care of steps 1 and 2. Next we're going to hit the co_return
and we'll find out what the result of our coroutine actually is. Our promise_type
gets told the value, which we'll just print for now:
auto return_value(T v) { std::cout << "Got an answer of " << v << std::endl; return std::experimental::suspend_never{}; }
Notice that we're also answering the question of whether or not we want to suspend the coroutine after it returns a value to us. We just keep going on to step 4.
Now that we've finished the coroutine, we need to say whether we want to stop here or not. If we don't then we run out of coroutine to execute and that stack frame that was allocated in step 1 is automatically freed for us.
auto final_suspend() { std::cout << "Finished the coro" << std::endl; return std::experimental::suspend_never{}; }
There is one last thing the compiler needs to know before it'll let us run anything. What should happen if an exception tries to escape from the coroutine? To start with we're just going to give up and bomb out:
void unhandled_exception() { std::exit(1); }
With all of that done, we now have something that we can actually run.
Promise created Send back a sync Created a sync object Started the coroutine, don't stop now! Thinking deep thoughts… Got an answer of 42 Finished the coro Promise died Copied a sync object Sync gone Got a coroutine sync object, let's get a value We got asked for the return value… And the coroutine value is: 0 Sync gone
This is all well and good, but we still haven't actually managed to get the return value. We do have a problem with that. Look carefully at the order of the messages.
You can see that the promise type is created first, then the return value holder (the sync
instance) is created. The promise then dies and finally the sync
goes. There's an extra sync
to account for the one that gets copied when get_return_object
returns.
The control flow is something like this:
main
, enter the coroutine.initial_suspend
returns suspend_never
we enter the body of the coroutine.co_return
whose value gets passed to our return_value
method, which also tells the coroutine to continue executing.final_suspend
and just keep going.promise_type
.sync
object returned in step 2.sync
object and display it.You can play around with this, for example, change initial_suspend
to return suspend_always
.
auto initial_suspend() { std::cout << "Started the coroutine, put the brakes on!" << std::endl; return std::experimental::suspend_always{}; }
You'll now see this:
Promise created Send back a sync Created a sync object Started the coroutine, put the brakes on! Copied a sync object Sync gone Got a coroutine sync object, let's get a value We got asked for the return value… And the coroutine value is: 0 Sync gone
The control flow is now this:
main
, enter the coroutine.initial_suspend
return suspend_always
we return control to the call site in main
.sync
value created in step 2.sync
object and display it.Notice that the coroutine body never runs, it's never given an opportunity to return the real value and just as bad, the promise_type
never gets destructed because we've leaked the coroutine stack frame.
In the next article we'll solve all of this properly, but for now we'll put initial_suspend
back to what it was and add a std::shared_ptr
to bridge the difference in lifetimes and make the correct value available.
Our complete sync
class now looks like this:
template<typename T> struct sync { std::shared_ptr<T> value; sync(std::shared_ptr<T> p) : value(p) { std::cout << "Created a sync object" << std::endl; } sync(const sync &s) : value(s.value) { std::cout << "Copied a sync object" << std::endl; } ~sync() { std::cout << "Sync gone" << std::endl; } T get() { std::cout << "We got asked for the return value..." << std::endl; return *value; } struct promise_type { std::shared_ptr<T> ptr; promise_type() : ptr(std::make_shared<T>()) { std::cout << "Promise created" << std::endl; } ~promise_type() { std::cout << "Promise died" << std::endl; } auto get_return_object() { std::cout << "Send back a sync" << std::endl; return ptr; } auto initial_suspend() { std::cout << "Started the coroutine, don't stop now!" << std::endl; return std::experimental::suspend_never{}; } auto return_value(T v) { std::cout << "Got an answer of " << v << std::endl; *ptr = v; return std::experimental::suspend_never{}; } auto final_suspend() { std::cout << "Finished the coro" << std::endl; return std::experimental::suspend_never{}; } void unhandled_exception() { std::exit(1); } }; };
And it outputs this:
Promise created Send back a sync Started the coroutine, don't stop now! Thinking deep thoughts… Got an answer of 42 Finished the coro Promise died Created a sync object Got a coroutine sync object, let's get a value We got asked for the return value… And the coroutine value is: 42 Sync gone
We got our value, and a little quirk. I guess because our sync
object is now doing something more obviously useful the compiler has also worked out it can elide the copy so we now only have a single sync
object.
In the next article we'll find out the proper solution to handling this lifetime issue which will get us to a fully proper working coroutine.