C++20 Coroutines:operator co_await
上篇介绍了C++20协程的诸多内容,独余co_await未曾涉及。
co_await是协程中非常重要的一个关键字,用以支持挂起(suspend)和恢复(resume)的逻辑。
本篇便专门来对其进行介绍。
1. Awaitable type and Awaiter type
较于普通函数,协程支持挂起和恢复。
那么何时挂起,何时恢复,便是逻辑之所在。
由于许多问题在解决时都无法立刻得到答案,即结果存在延迟性,在程序中就表现为阻塞。阻塞会导致CPU大量空闲,效率大减,于是就要想办法实现非阻塞。
多线程便是解决阻塞的一个方式,遇到阻塞,便由操作系统进行切换调度,以此实现非阻塞。
重叠IO亦是一个非阻塞方案,遇到阻塞,提供一个回调函数给操作系统,系统在阻塞完成后调用其继续执行。
这些方案,本质上都是在处理如何挂起和恢复的问题。换言之,就是在遇到阻塞时暂停当前工作,先去进行别的工作,等阻塞完成后再回来继续完成当前工作。
既然拥有共同的处理问题的逻辑,那么对其进行抽象,便能得到一个高层级的类型。这个高层级的类型便是Awaitable type(即上篇的Awaitable object)。
简单地说,Awaitable type就是对阻塞问题进行总结、归纳、提炼要点,所得到的模型。
那么有何好处呢?
好处就是,我们只要依照抽象后所得模型中的一些规则,便能定义出所有类似问题的解决逻辑,所有类似问题都能依次模型进行解决。
乍听很复杂,其实并不难,只要依规则行事便可。
那么具体规则又是什么?
其实只是三个接口,
await_ready()
await_suspend()
await_resume()
它们分别代表着:是否阻塞、挂起、恢复。
将程序中阻塞完成的条件,写到await_ready函数中,便能依此决策何时挂起,何时恢复。
一个类型若直接实现了这三个接口,那么这个类就被称为Awaiter。
什么意思呢?若类型A本身并未实现这三个接口,而是通过类型B实现的,那么类型A就称作Awaitable,类型B称作Awaiter。
若类型A直接实现了这三个接口,那么它既是Awaitable,也是Awaiter。
Awaiter是真正的逻辑所在,co_await只是一个导火索,用来触发具体的语义,具体语义实际是由Awaiter进行控制的。
2. operator co_await
如前所述,一元操作符co_await用于启动具体的行为逻辑,用法如下:
1co_await exp
那么这个exp就是所谓的Awaitable了,它必须实现所需的接口。
这么说来,co_await就有两点作用:
强制编译器生成一些样板代码。为的是完成相关的启动操作。
创建Awaiter对象。为的是完成实际的逻辑。
创建Awaiter对象有两种方式。
第一种是重载operator co_await,由此可通过返回值得到创建的Awaiter。
第二种是在当前协程的promise type(见上篇)中定义await_transform函数,由此将相关类型转换为Awaiter。
如果一个exp直接是Awaiter,那么Awaiter就是exp本身。
获得了Awaiter,便能根据await_ready来决策挂起和恢复的逻辑。具体细节,见于后文。
co_await最终的返回结果,就是await_resume()的结果,Awaiter会在co_await表达式结束前销毁。
现在,先来看一个简单的例子:
1class coroutine_type
2{
3public:
4 struct promise_type {
5 using coro_handle = std::experimental::coroutine_handle<promise_type>;
6 auto get_return_object() {
7 return coroutine_type{ coro_handle::from_promise(*this) };
8 }
9
10 auto initial_suspend() { return std::experimental::suspend_never{}; }
11 auto final_suspend() { return std::experimental::suspend_always{}; }
12
13 void unhandle_exception() { std::terminate(); }
14
15 int cur_value;
16 void return_value(int value) {
17 cur_value = value;
18 }
19 };
20
21 using coro_handle = std::experimental::coroutine_handle<promise_type>;
22 coroutine_type(coro_handle handle) : handle_(handle) { assert(handle_); }
23 coroutine_type(const coroutine_type&) = delete;
24 coroutine_type(coroutine_type&& other) : handle_(other.handle_) { other.handle_ = nullptr; }
25 ~coroutine_type() { handle_.destroy(); }
26
27 bool resume(){
28 if (!handle_.done())
29 handle_.resume();
30 return !handle_.done();
31 }
32
33 int get_result() {
34 return handle_.promise().cur_value;
35 }
36
37private:
38 coro_handle handle_;
39};
40
41coroutine_type coroutine()
42{
43 std::cout << "begin coroutine\n";
44 co_await std::experimental::suspend_always{};
45 std::cout << "resumed\n";
46 co_return 42;
47}
48
49int main()
50{
51 auto coro = coroutine();
52 coro.resume();
53 std::cout << coro.get_result() << "\n";
54
55 return 0;
56}
若你已经学习了上篇的内容,那么就完全可以理解这段代码。
我们知道,标准提供了两个Trivial Awaitable object,一个是suspend_always,另一个是suspend_never。
那么这两个就是可以直接使用的Awaitable type,而因为它们本身就满足所需的三个接口,所以既是Awaitable,也是Awaiter。
当执行时co_await时,步骤解析如下:
将表达式转换为Awaitable,这里由于suspend_always本身便是Awaiter,便无需多做处理,最终获得到的Awaiter就是suspend_always。
获得到了Awaiter,便开始调用await_ready(),由于suspend_always的await_ready()总是返回false,所以将调用await_suspend()挂起协程。
挂起协程,便会返回到调用方继续执行。此时,通过reume()便可恢复协程。
协程恢复,输出"resumed",接着通过co_return返回了42。co_return会调用promise type中的return_value(),在那对值进行保存。
协程返回,输出返回值,之后协程销毁。
最终输出如下:
1begin suspendsion
2resumed
342
3. 逻辑框架与流程解析
通过前面,我们知道co_await的语义由Awaitable object提供的三个接口来进行控制,所有的操作全部都围绕着这三个接口进行执行。
那么我把这一系列的逻辑,称为协程的「逻辑框架」。绘制结构如图:
在这个逻辑框架中,没有任何多余的细节,它表示协程总体的逻辑流程。
当前的工作current works,就是协程函数。其它的工作other works,可以是普通函数,也可以是协程函数。
由awaiter所组成的「逻辑三角」,共同协调着当前工作与其它工作之间的切换。
而这一切的引子,便依赖于co_await关键字。一切的逻辑,便依赖于ready(await_ready)。
ready表示当前工作是否阻塞,准备完成意味着非阻塞,准备未完成意味着阻塞。非阻塞的情况,其实就相当于普通函数,此时会走③T这条路。阻塞的情况,便需要挂起当前协程,切换到other works。
那么具体的细节如何?我将用下面的代码来描述。
1{
2 // 1. 将expr转换为Awaitable
3 auto&& value = expr;
4 auto&& awaitable = get_awaitable(promise, static_cast<decltype(value)>(value));
5
6 // 2. 获取Awaiter
7 auto&& awaiter = get_awaiter(static_cast<decltype(awaitable)>(awaitable));
8
9 std::exception_ptr exception = nullptr;
10 if(!awaiter.await_ready()) // 3. 判断是否准备完成
11 {
12 // 4. 挂起当前协程,此时所需的局部变量将被保存
13 suspend_coroutine();
14
15 // 5. 根据await_suspend的返回值,决定返回到何处
16
17 // if await_suspend returns void
18 try {
19 awaiter.await_suspend(coroutine_handle);
20 return_to_the_caller();
21 } catch(...) {
22 exception = std::current_exception();
23 goto resume_point;
24 }
25
26 // if await_suspend returns bool
27 try {
28 await_suspend_result = awaiter.await_suspend(coroutine_handle);
29 } catch(...) {
30 exception = std::current_exception();
31 goto resume_point;
32 }
33 if(!await_suspend_result)
34 goto resume_point();
35 return_to_the_caller();
36
37 // if await_suspend returns another coroutine_handle
38 decltype(await.await_suspend(std::declval<coro_handle_t>())) another_coro_handle;
39 try {
40 another_coro_handle = awaiter.await_suspend(coroutine_handle);
41 } catch(...) {
42 exception = std::current_exception();
43 goto resume_point;
44 }
45 }
46
47resume_point:
48 if(exception)
49 std::rethrow_exception(exception); // await_resume将不会被调用
50 a.await_resume(); // 6. the end, 恢复当前协程
51}
首先,当遇到有co_await修饰的表达式时,编译器便知该函数是一个协程。于是,就尝试获取Awaitable,通过Awaitable再得到Awaiter。
接着,便可通过Awaiter进行实质的逻辑操作。先调用await_ready()检测是否准备完成,准备完成则直接调用await_resume()恢复执行。若不满足条件,那么就需要挂起协程,转去执行其它工作。
此时就需要调用await_suspend()并返回控制给调用者,这决定了程序接下来的控制流去向。
await_suspend()有三种返回值:
如果返回值为void,将会直接跳转到当前协程的调用方。
如果返回值为bool,那么在为true时,会跳转到当前协程的调用方;为false时,会恢复当前协程。
如果返回值为coroutine handle,那么就会跳转到其它的协程,亦即其它协程将被恢复。
协程跳转后,便会到达other works,恢复操作在这里控制,只要通过resume()便能恢复协程。由此,就构成了一个循环。当协程函数执行完毕或通过co_return返回,将打破循环。
4. Timer awaiter
现在,来看最后一个例子,也比较简单。
这里我们将自定义一个简单的定时器,即时间一到,才会执行下面的逻辑;时间没到,切出去执行别的工作。
1struct my_timer {
2 int duration;
3 std::experimental::coroutine_handle<> handle;
4 my_timer(int d) : duration(d), handle(nullptr) {}
5};
6
7class timer_awaiter
8{
9public:
10 my_timer& timer;
11 timer_awaiter(my_timer& t) : timer(t) {}
12
13 bool await_ready() noexcept {
14 std::cout << "timerawaiter::await_ready()\n";
15 return timer.duration <= 0;
16 }
17
18 void await_suspend(std::experimental::coroutine_handle<> handle) noexcept {
19 timer.handle = handle;
20
21 std::cout << "timer::await_suspend(), duration==" << (--timer.duration) << std::endl;
22 }
23
24 void await_resume() noexcept {
25 std::cout << "timerawaiter::await_resume()\n";
26 timer.handle = nullptr;
27 }
28};
29
30struct coro_task
31{
32 struct promise_type {
33 auto get_return_object() {
34 return coro_task{};
35 }
36
37 auto initial_suspend() { return std::experimental::suspend_never{}; }
38 auto final_suspend() { return std::experimental::suspend_never{}; }
39
40 void unhandle_exception() { std::terminate(); }
41 void return_void() {}
42 };
43};
44
45auto operator co_await(my_timer& t) noexcept {
46 return timer_awaiter{ t };
47}
48
49coro_task coro_test_func(my_timer& timer)
50{
51 std::cout << "begin coro_test_func()\n";
52
53 co_await timer;
54 co_await timer; // 只会在前两次切出协程
55 co_await timer;
56 co_await timer;
57
58 std::cout << "end of coro_test_func()\n";
59}
60
61int main()
62{
63 my_timer timer(2);
64 coro_test_func(timer);
65
66 for (; timer.handle && !timer.handle.done();)
67 {
68 std::cout << "in main for loop\n";
69
70 // 恢复协程
71 timer.handle.resume();
72 }
73
74 return 0;
75}
这里,我们定义了一个Awaiter timer_awaiter,此时的Awaitable是my_timer,通过重载operator co_await,其返回值便是由Awaitable得到的Awaiter。
当定时器时间为0时,则计时结束,之后await_ready将不满足条件,所以之后的调用都不会切出,而是直接返回到当前协程。
恢复协程的权力在调用方这里,所以需要提供Coroutine handle,这是通过await_suspend传递进来的。
我们的await_suspend返回值为void,所以便会直接返回到调用方。
其它东西前面都已见过,便不细述,输出如下:
5. 总结(Summary)
本篇介绍了C++ Coroutine中的co_await关键字,内容不多,但可说是协程中非常关键的东西。
文中所涉例子也比较简单,它们全都是在帮助你理解协程的「逻辑框架」,这也是本文中心之所在。
到此,协程的基础知识全部介绍完毕,大家现在也可以用协程来编写属于自己的程序了。顺手之后,便会觉得协程其实并不难。
若再要进行学习,就需要看一些协程库的设计了,有时间了再来写吧。
最后,本篇内容皆属个人理解,可能有错误之处,如觉哪里不妥,欢迎直接指正。
6. References
https://en.cppreference.com/w/cpp/language/coroutines
https://lewissbaker.github.io/2017/11/17/understanding-operator-co-await
https://owent.net/2019/1904.html
N4760 Working draft, c++ extensions for coroutines
往期推荐
Demystifying C++20 Coroutines