@zephyr
Articles8
Tags10
Categories0
c++20-coroutine

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
2
3
4
5
6
7
8
{
socket.read_some(buffer, [socket, buffer](ec, read_n){
...
socket.write_some(buffer, [](ec, write_n){
...
});
});
}

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
2
3
4
5
{
auto [ec, n] = co_await socket.read_some(buffer, use_awaitable);

auto [ec, n] = co_await socket.write_some(buffer, use_awaitable);
}

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
2
3
4
5
6
struct Task {
struct Promise {

};
using promise_type = Promise;
};

promise_type must have these function members:

1
2
3
4
5
6
7
struct Promise {
get_return_object();
initial_suspend();
final_suspend();
return_value(); // or return_void
unhandled_exception();
};

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
2
3
4
5
struct Awaiter {
bool await_ready();
void or bool await_suspend(coroutine_handle);
ReturnType await_resume();
};
  • If await_ready returns true, 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, then await_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 to co_await;

First Coroutine

Look at the example below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <iostream>
#include <coroutine>

struct Task {
struct Promise;
using promise_type = Promise;

struct Promise {
Task get_return_object() {
return {
std::coroutine_handle<Promise>::from_promise(*this)
};
}
std::suspend_always initial_suspend() {
std::cout << "initial suspend" << std::endl;
return {};
}
std::suspend_always final_suspend() noexcept {
std::cout << "final suspend" << std::endl;
return {};
}
void unhandled_exception() {}
void return_void() {
std::cout << "co_return called" << std::endl;
}
};

std::coroutine_handle<Promise> this_handle;
};

Task coro1()
{
std::cout << "enter coro1" << std::endl;

co_await std::suspend_always{};

std::cout << "leave coro1" << std::endl;
}

int main()
{
auto handle = coro1().this_handle;

std::cout << "resume(1) in main" << std::endl;
handle.resume();
std::cout << "resume(2) in main" << std::endl;
handle.resume();
std::cout << "resume(3) in main" << std::endl;
}

Output:

1
2
3
4
5
6
7
8
initial suspend
resume(1) in main
enter coro1
resume(2) in main
leave coro1
co_return called
final suspend
resume(3) in main
  • promise_type::initial_suspend is called when Task created. If it returns suspend_always, then the coroutine is lazy, it will stop execution and give the control flow back to the main funtion. If it returns suspend_never, then it will continue to execute, until next co_await.

  • promise::return_void/return_value and promise::final_suspend is called when the Task finishes. In this example, co_await is implicitly called at the end of coro1. If co_return is called earlier, then the Task will finish early, and return_void/value & final_suspend will be called.

  • control flow:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
co_await promise_type::initial_suspend();

// this is coro1
{
std::cout << "enter coro1" << std::endl;

co_await std::suspend_always{};

std::cout << "leave coro1" << std::endl;
}

co_await promise_type::final_suspend();
}
  • resume(1) resumes initial suspend

  • resume(2) resumes co_await std::suspend_always{} defined in coro1

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
struct Task {
struct Promise;
using promise_type = Promise;

struct Promise {
Task get_return_object() {
return {
std::coroutine_handle<Promise>::from_promise(*this)
};
}

std::suspend_always
initial_suspend() { return {}; }

struct FinalAwaiter {
constexpr bool await_ready() noexcept { return false; }

std::coroutine_handle<>
await_suspend(std::coroutine_handle<Promise> this_handle) noexcept {
return this_handle.promise().super_handle;
}

// nothing to do, the coroutine will no longer be executed
void await_resume() noexcept {}
};

FinalAwaiter
final_suspend() noexcept { return {}; }


void unhandled_exception() {}
void return_void() {}

std::coroutine_handle<> super_handle;
};

std::coroutine_handle<Promise> this_handle;


auto operator co_await() {
struct Awaiter {
std::coroutine_handle<Promise> this_handle;

constexpr bool await_ready() { return false; }

std::coroutine_handle<>
await_suspend(std::coroutine_handle<> super_handle) {
this_handle.promise().super_handle = super_handle;
return this_handle;
}

// return result of inner coroutine..
void await_resume() {}
};

return Awaiter{ this_handle };
}
};

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.

Author:@zephyr
Link:https://zephyr.moe/2022/01/16/cpp20-coroutine/
License:CC BY-NC-SA 3.0 CN