查看原文
其他

C++20 Coroutines:operator co_await

cpluspluser CppMore 2023-04-20

上篇介绍了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就有两点作用:

  1. 强制编译器生成一些样板代码。为的是完成相关的启动操作。

  2. 创建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时,步骤解析如下:

  1. 将表达式转换为Awaitable,这里由于suspend_always本身便是Awaiter,便无需多做处理,最终获得到的Awaiter就是suspend_always

  2. 获得到了Awaiter,便开始调用await_ready(),由于suspend_alwaysawait_ready()总是返回false,所以将调用await_suspend()挂起协程。

  3. 挂起协程,便会返回到调用方继续执行。此时,通过reume()便可恢复协程。

  4. 协程恢复,输出"resumed",接着通过co_return返回了42。co_return会调用promise type中的return_value(),在那对值进行保存。

  5. 协程返回,输出返回值,之后协程销毁。

最终输出如下:

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,此时的Awaitablemy_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



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存