c++20-coroutine
Implement minimal coroutine primitives with c++20
Overview
Using callback style APIs is painful. Still, lambda is not a good-enough solution. When deeply nested, the code looks terrible and becomes hard to understand. Objects’ lifetimes are also hard to manage, even with the help of smart pointers and RAII. Look at the code below:
(BTW, Asio is a great library:))
1 | { |
We have to pass shared_ptr(catched and saved in lambda struct) to keep objects alive, although they are obviously unique. Here the use of shared_ptr brings some overhead, to make things work correctly, we sacrificed performance.
What about using coroutine:
1 | { |
The code is much simplified. No more callback, no more shared_ptr.
Concepts
Other languages(JS, C#, Python..) provides syntaxs like async
await
, very easy to use. As for C++, it gives developers more detailed controls, but we need to implement almost everything on our own, or use third-party libraries. I have made a toy coroutine library yesterday, the purpose of this article is to introduce how coroutine works, and how to implement some minimal primitives.
Coroutine is pre-allocated on heap, its data is stored in Task::promise_type. Task is a user-defined type, it must have a inner promise_type
.
1 | struct Task { |
promise_type
must have these function members:
1 | struct Promise { |
coroutine_handle
is used to refer to a coroutine. With a non-type coroutine_handle<void>
, we can resume, or destroy the corresponded coroutine. With a concrete typed coroutine_handle<T>
, we can get its data, the type of which is mentioned above: promise_type
.
awaitable
object is what we’ll call co_await
on. It is also a use-defined type, must have these three function members:
1 | struct Awaiter { |
If
await_ready
returnstrue
, the coroutine will not be suspended, the other two functions will not be called.co_await Awaitable
does not make any effort.If
await_suspend
returns false, it will be resumed, thenawait_resume
will be called immediately. Otherwise, the coroutine is suspended.When the coroutine is resumed(call
coroutine_handle.resume()
),await_resume
will be called, and return its value toco_await
;
First Coroutine
Look at the example below:
1 |
|
Output:
1 | initial suspend |
promise_type::initial_suspend
is called when Task created. If it returnssuspend_always
, then the coroutine is lazy, it will stop execution and give the control flow back to the main funtion. If it returnssuspend_never
, then it will continue to execute, until nextco_await
.promise::return_void/return_value
andpromise::final_suspend
is called when the Task finishes. In this example,co_await
is implicitly called at the end ofcoro1
. Ifco_return
is called earlier, then the Task will finish early, andreturn_void/value
&final_suspend
will be called.control flow:
1 | { |
resume(1)
resumes initial suspendresume(2)
resumesco_await std::suspend_always{}
defined incoro1
resume(3)
resumes final suspend
When calling coroutine_handle.resume()
, the control flow goes to where the coroutine is suspended last time, and the execution continues, until reach next suspend. When the coroutine is suspended, the control flow goes back to the location where the caller called resume().
We can also pass control flow to another coroutine, by passing(return by await_resume
) another coroutine’s coroutine_handle
.
To allow syntax like co_await Task
, we need to implement await_ready
, await_suspend
, await_resume
for Task, or overload its operator co_await
With the two points above, we can create nested Task, and pass control flow back and forth. The trick is to store outer coroutine_handle
when Task is called co_await
on, store it in promise_type
, and return inner coroutine_handle
to have inner coroutine executed. After the inner coroutine finished, return the outer coroutine handle
to go back to outer coroutine
1 | struct Task { |
At last, when the task is finished(or the promise is fulfilled), delete the promise(coroutine frame) with coroutine_handle.destroy()
. If final_suspend
does not suspend, it will release the coroutine frame automatically. To get the result value(stored in promise) of the Task, the the promise should not be destroyed, it should be suspended at final_suspend
. Then, we get the value from promise, and destroy the promise manually(It is recommended to do this in Task’s destructor, RAII).
For more details, you can refer to this repo. It implements Task
and Readable & Writable Awaiter
for Epoll events.