协程 Coroutines

概述

维基百科的定义

协程是计算机程序的⼀类组件,推⼴了协作式多任务的⼦程序,允许执⾏被挂起与被恢复。相对⼦例程⽽⾔,协程更为⼀般和灵活。

yield 在 Python 里叫做“生成器”(generator),返回的是一个可迭代的对象,每次迭代就能得到一个 yield 出来的结果,是一种很常见的协程形式。

from itertools import islice, takewhile

def f():
    a = 0
    b = 1
    while True:
        yield b
        a, b = b, a + b

# 1 1 2 3 5
for i in islice(f(), 5):
    print(i)

# 1 1 2 3 5 8
for i in takewhile(lambda x: x < 10, f()):  
    print(i)

在代码的执行过程中,f 和它的调用代码是交叉执行的。

a = 0  # f()
b = 0  # f()
yield b  # f()
print(i)  # 调用者
a, b = 1, 0 + 1  # f()
yield b  # f()
print(i)  # 调用者
a, b = 1, 1 + 1  # f()
yield b  # f()
print(i)  # 调用者
a, b = 2, 1 + 2  # f()
yield b  # f()
print(i)  # 调用者

C++20 协程

C++20 协程的基础是微软提出的 Coroutines TS,在 2019 年 7 月被批准加入到 C++20 草案中。MSVC 和 Clang 已经支持协程。目前被标准化的只是协程的底层语言支持,而不是上层的高级封装。

协程可以有很多不同的用途:

  • 生成器
  • 异步 I/O
  • 惰性求值
  • 事件驱动应用

为了正确运行代码,在 VS2019 中使用最新的C++支持 /std:c++ latest

协程相关的新关键字:

  • co_await
  • co_yield
  • co_return

这三个关键字最初是没有 co_ 前缀的,但考虑到 await、yield 已经在很多代码里出现,就改成了目前这个样子。同时,return 和 co_return 也作出了明确的区分:一个协程里只能使用 co_return,不能使用 return。这三个关键字只要有一个出现在函数中,这个函数就是一个协程了。

co_await

auto result = co_await expression;

编译器会把它理解为:

auto&& _a = expression;
if (!_a.await_ready()) {
  _a.await_suspend(coroutine handle);
  // 挂起/恢复点
}
auto result = _a.await_resume();

expression 需要支持 await_ready、await_suspend 和 await_resume 三个接口。

如果 await_ready() 返回真,就代表不需要真正挂起,直接返回后面的结果就可以;否则,执行 await_suspend 之后即挂起协程,等待协程被唤醒之后再返回 await_resume() 的结果。这样一个表达式被称作是个 awaitable。

标准里定义了两个 awaitable:

struct suspend_always 
{
    bool await_ready() const noexcept { return false; }

    void await_suspend(coroutine_handle<>) const noexcept {}

    void await_resume() const noexcept {}
};

struct suspend_never 
{
    bool await_ready() const noexcept { return true; }
    
    void await_suspend(coroutine_handle<>) const noexcept {}

    void await_resume() const noexcept {}
};

suspend_always 永远告诉调用者需要挂起,而 suspend_never 则永远告诉调用者不需要挂起。

两者的 await_suspend 和 await_resume 都是平凡实现,不做任何实际的事情。

一个 awaitable 可以自行实现这些接口,以定制挂起之前和恢复之后需要执行的操作。

coroutine_handle 是 C++ 标准库提供的类模板,这个类是用户代码跟系统协程调度真正交互的地方。

  • destroy:销毁协程
  • done:判断协程是否已经执行完成
  • resume:让协程恢复执行
  • promise:获得协程相关的 promise 对象,是协程和调用者的主要交互对象,一般类型名称为 promise_type
  • from_promise(静态):通过 promise 对象的引用来生成一个协程句柄

协程的执行过程

  1. 为协程调用分配一个协程帧,含协程调用的参数、变量、状态、promise 对象等所需的空间。
  2. 调用 promise.get_return_object(),返回值会在协程第一次挂起时返回给协程的调用者。
  3. 执行 co_await promise.initial_suspsend();根据上面对 co_await 语义的描述,协程可能在此第一次挂起(但也可能此时不挂起,在后面的协程体执行过程中挂起)。
  4. 执行协程体中的语句,中间可能有挂起和恢复;如果期间发生异常没有在协程体中处理,则调用 promise.unhandled_exception()。
  5. 当协程执行到底,或者执行到 co_return 语句时,会根据是否有非 void 的返回值,调用 promise.return_value(…) 或 promise.return_void(),然后执行 co_await promise.final_suspsend()。

代码表示如下:

frame = operator new(...);

promise_type& promise = frame->promise;
  
// 在初次挂起时返回给调用者
auto return_value = promise.get_return_object();
  
co_await promise.initial_suspsend();
  
try {
  执行协程体;
  可能被 co_wait、co_yield 挂起;
  恢复后继续执行,直到 co_return;
}
catch (...) {
  promise.unhandled_exception();
}

final_suspend:co_await promise.final_suspsend();

co_yield 表达式等价于:

co_await promise.yield_value(expression);

uint64_resumable

#include <coroutine>               
using std::coroutine_handle;
// using std::suspend_always;
// using std::suspend_never;

struct suspend_always 
{
    bool await_ready() const noexcept { return false; }

    void await_suspend( coroutine_handle<>) const noexcept {}

    void await_resume() const noexcept {}
};

class uint64_resumable {
public:
    // 存放协程定制点的类型
    struct promise_type 
    {
        // 存放供 uint64_resumable::get 取用的数值。
        uint64_t value_;

        using coro_handle = coroutine_handle<promise_type>;

        // 第一个定制点,调用协程的返回值就是 get_return_object() 的结果。
        // 使用 promise 对象来构造一个 uint64_resumable。
        auto get_return_object() { return uint64_resumable{coro_handle::from_promise(*this) };}

        // 第二个定制点,返回 suspend_always()。
        // 协程立即挂起,调用者马上得到 get_return_object() 的结果。
        constexpr auto initial_suspend() { return suspend_always(); } 

        // 第三个定制点,返回 suspend_always(),即使执行到了 co_return 语句,协程仍处于挂起状态。
        // 如果返回 suspend_never(),一旦执行了 co_return 或执行到协程结束,
        // 协程就会被销毁,连同已初始化的本地变量和 promise,并释放协程帧内存。
        constexpr auto final_suspend() { return suspend_always(); }

        // 第四个定制点,仅对 value_ 进行赋值,然后让协程挂起(执行控制回到调用者)。
        auto yield_value(uint64_t value) { value_ = value; return suspend_always(); }

        // 第五个定制点,代码永不返回,无事可做。
        void return_void() {}

        // 第六个定制点,不应该发生任何异常,调用 terminate 来终结程序的执行。
        void unhandled_exception() { std::terminate(); }
    };

    using coro_handle = coroutine_handle<promise_type>;

    // 协程构造需要一个协程句柄。
    explicit uint64_resumable( coro_handle handle) : handle_(handle) { }

    // 析构时将使用协程句柄来销毁协程。
    ~uint64_resumable() { handle_.destroy(); }

    // 不可复制,以免重复调用 handle_.destroy()。
    uint64_resumable( const uint64_resumable&) = delete;

    // 允许移动。
    uint64_resumable( uint64_resumable&&) = default;

    // 恢复执行。
    bool resume()
    { 
        if (!handle_.done()) handle_.resume(); 
        return !handle_.done(); 
    }

    // 获取数据。
    uint64_t get() { return handle_.promise().value_; }

private:
    // 协程句柄
    coro_handle handle_;
};

测试

uint64_resumable f()
{
    uint64_t a = 0;
    uint64_t b = 1;
    while (true) {
        co_yield b;
        auto tmp = a;
        a = b;
        b += tmp;
    }
}

调用代码

auto res = f();
while (res.resume()) 
{
    auto i = res.get();
    if (i >= 10) break;
    cout << i << endl; //1 1 2 3 5 8
}

使用 Coroutines TS 的生成器:

#include <experimental/generator>
using std::experimental::generator;

generator<uint64_t> f()
{
    uint64_t a = 0;
    uint64_t b = 1;
    while (true) {
        co_yield b;
        auto tmp = a;
        a = b;
        b += tmp;
    }
}

调用代码:

for (auto i : f()) {
    if (i >= 10) {
        break;
    }
    cout << i << endl;
}

一些问题

  1. 操作系统看到的是线程,调度的也是线程。一个线程中可以有很多个协程,这些协程的执行顺序由程序员自己来调度。比较明显的好处:
    • 同一个线程中的协程不需要考虑数据的竞争问题,因为这些协程的执行顺序是固定的。
    • 协程能够很方便的保存执行状态,使复杂状态机的实现变得简单。
  2. 最基本协程和线程的区别就是,协程是程序自己调度的,线程是操作系统调度的。操作系统干的事情,开销自然大一些。协程的优势,性能就是很重要的一块。
  3. 协程在哪个线程运行,是你的代码决定的。你从哪个线程去调用协程或去 co_await、co_yield 了,协程就在那个线程执行。生成器那样的用法,完全只有一个线程,也不适合用多线程来改造。