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_readyreturnstrue, the coroutine will not be suspended, the other two functions will not be called.co_await Awaitabledoes not make any effort.If
await_suspendreturns false, it will be resumed, thenawait_resumewill be called immediately. Otherwise, the coroutine is suspended.When the coroutine is resumed(call
coroutine_handle.resume()),await_resumewill be called, and return its value toco_await;
First Coroutine
Look at the example below:
1 |
|
Output:
1 | initial suspend |
promise_type::initial_suspendis 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_valueandpromise::final_suspendis called when the Task finishes. In this example,co_awaitis implicitly called at the end ofcoro1. Ifco_returnis called earlier, then the Task will finish early, andreturn_void/value&final_suspendwill be called.control flow:
1 | { |
resume(1)resumes initial suspendresume(2)resumesco_await std::suspend_always{}defined incoro1resume(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.