C++20 Coroutines: operator co_await
上篇介绍了 C++20 协程的诸多内容,独余 co_await
未曾涉及,它是协程中非常重要的一个关键字,用以支持挂起(suspend)和恢复(resume)的逻辑。
本篇便专门来对其进行介绍。
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 进行控制的。
operator co_await
一元操作符 co_await
用于启动具体的行为逻辑,用法如下:
co_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
表达式结束前销毁。
现在,先来看一个简单的例子:
class coroutine_type
{
public:
struct promise_type {
using coro_handle = std::experimental::coroutine_handle<promise_type>;
auto get_return_object() {
return coroutine_type{ coro_handle::from_promise(*this) };
}
auto initial_suspend() { return std::experimental::suspend_never{}; }
auto final_suspend() { return std::experimental::suspend_always{}; }
void unhandle_exception() { std::terminate(); }
int cur_value;
void return_value(int value) {
cur_value = value;
}
};
using coro_handle = std::experimental::coroutine_handle<promise_type>;
coroutine_type(coro_handle handle) : handle_(handle) { assert(handle_); }
coroutine_type(const coroutine_type&) = delete;
coroutine_type(coroutine_type&& other) : handle_(other.handle_) { other.handle_ = nullptr; }
~coroutine_type() { handle_.destroy(); }
bool resume(){
if (!handle_.done())
handle_.resume();
return !handle_.done();
}
int get_result() {
return handle_.promise().cur_value;
}
private:
coro_handle handle_;
};
coroutine_type coroutine()
{
std::cout << "begin coroutine\n";
co_await std::experimental::suspend_always{};
std::cout << "resumed\n";
co_return 42;
}
int main()
{
auto coro = coroutine();
coro.resume();
std::cout << coro.get_result() << "\n";
}
/*
* Output:
begin suspendsion
resumed
42
*/
标准提供了两个 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()
,在那对值进行保存; - 协程返回,输出返回值,之后协程销毁。
逻辑框架与流程解析
由前文可知,co_await
的语义由 Awaitable object 提供的三个接口来进行控制,所有的操作全部都围绕着这三个接口进行执行。
那么我把这一系列的逻辑,称为协程的「逻辑框架」。绘制结构如图:
在这个逻辑框架中,没有任何多余的细节,它表示协程总体的逻辑流程。
当前的工作 current works,就是协程函数。其他的工作 other works,可以是普通函数,也可以是协程函数。
由 awaiter 所组成的「逻辑三角」,共同协调着当前工作与其它工作之间的切换。
而这一切的引子,便依赖于 co_await
关键字。一切的逻辑,便依赖于 ready(await_ready
)。
ready 表示当前工作是否阻塞,准备完成意味着非阻塞,准备未完成意味着阻塞。非阻塞的情况,其实就相当于普通函数,此时会走 ③T
这条路。阻塞的情况,便需要挂起当前协程,切换到 other works。
那么具体的细节如何?看下面的代码描述。
{
// 1. 将expr转换为Awaitable
auto&& value = expr;
auto&& awaitable = get_awaitable(promise, static_cast<decltype(value)>(value));
// 2. 获取Awaiter
auto&& awaiter = get_awaiter(static_cast<decltype(awaitable)>(awaitable));
std::exception_ptr exception = nullptr;
if(!awaiter.await_ready()) // 3. 判断是否准备完成
{
// 4. 挂起当前协程,此时所需的局部变量将被保存
suspend_coroutine();
// 5. 根据await_suspend的返回值,决定返回到何处
// if await_suspend returns void
try {
awaiter.await_suspend(coroutine_handle);
return_to_the_caller();
} catch(...) {
exception = std::current_exception();
goto resume_point;
}
// if await_suspend returns bool
try {
await_suspend_result = awaiter.await_suspend(coroutine_handle);
} catch(...) {
exception = std::current_exception();
goto resume_point;
}
if(!await_suspend_result)
goto resume_point();
return_to_the_caller();
// if await_suspend returns another coroutine_handle
decltype(await.await_suspend(std::declval<coro_handle_t>())) another_coro_handle;
try {
another_coro_handle = awaiter.await_suspend(coroutine_handle);
} catch(...) {
exception = std::current_exception();
goto resume_point;
}
}
resume_point:
if(exception)
std::rethrow_exception(exception); // await_resume将不会被调用
a.await_resume(); // 6. the end, 恢复当前协程
}
首先,当遇到有 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
返回,将打破循环。
Timer awaiter
现在,来看最后一个例子,也比较简单。
这里我们将自定义一个简单的定时器,即时间一到,才会执行下面的逻辑;时间没到,切出去执行别的工作。
#include <coroutine>
#include <iostream>
struct my_timer {
int duration;
std::coroutine_handle<> handle;
my_timer(int d) : duration(d), handle(nullptr) {}
};
class timer_awaiter
{
public:
my_timer& timer;
timer_awaiter(my_timer& t) : timer(t) {}
bool await_ready() noexcept {
std::cout << "timerawaiter::await_ready()\n";
return timer.duration <= 0;
}
void await_suspend(std::coroutine_handle<> handle) noexcept {
timer.handle = handle;
std::cout << "timer::await_suspend(), duration==" << (--timer.duration) << std::endl;
}
void await_resume() noexcept {
std::cout << "timerawaiter::await_resume()\n";
timer.handle = nullptr;
}
};
struct coro_task
{
struct promise_type {
auto get_return_object() {
return coro_task{};
}
auto initial_suspend() { return std::suspend_never{}; }
auto final_suspend() noexcept { return std::suspend_never{}; }
void unhandled_exception() { std::terminate(); }
void return_void() {}
};
};
auto operator co_await(my_timer& t) noexcept {
return timer_awaiter{ t };
}
coro_task coro_test_func(my_timer& timer)
{
std::cout << "begin coro_test_func()\n";
co_await timer;
co_await timer; // 只会在前两次切出协程
co_await timer;
co_await timer;
std::cout << "end of coro_test_func()\n";
}
int main()
{
my_timer timer(2);
coro_test_func(timer);
for (; timer.handle && !timer.handle.done();)
{
std::cout << "in main for loop\n";
// 恢复协程
timer.handle.resume();
}
return 0;
}
这里,定义了一个 Awaiter timer_awaiter
,此时的 Awaitable 是 my_timer
,通过重载 operator co_await
,其返回值便是由 Awaitable 得到的 Awaiter。
当定时器时间为 0
时,计时结束,之后 await_ready
将不满足条件,所以之后的调用都不会切出,而是直接返回到当前协程。
恢复协程的权力在调用方这里,所以需要提供 Coroutine handle,这是通过 await_suspend
传递进来的。
我们的 await_suspend
返回值为 void
,所以便会直接返回到调用方。
其他东西前面都已见过,便不细述,输出结果为:
begin coro_test_func()
timerawaiter::await_ready()
timer::await_suspend(), duration==1
in main for loop
timerawaiter::await_resume()
timerawaiter::await_ready()
timer::await_suspend(), duration==0
in main for loop
timerawaiter::await_resume()
timerawaiter::await_ready()
timerawaiter::await_resume()
timerawaiter::await_ready()
timerawaiter::await_resume()
end of coro_test_func()
Conclusion
本篇介绍了 C++ Coroutine 中的 co_await
关键字,内容不多,但可说是协程中非常关键的东西。
文中所涉例子也比较简单,它们全都是在帮助你理解协程的「逻辑框架」,这也是本文中心之所在。
至此,协程的基础知识全部介绍完毕,大家现在也可以用协程来编写属于自己的程序了。顺手之后,便会觉得协程其实并不难。
若再要进行学习,就需要看一些协程库的设计了,有时间了再来写吧。
最后,本篇内容皆属个人理解,可能有错误之处,如觉哪里不妥,欢迎直接指正。
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