协程 (C++20)

来自cppreference.com
< cpp‎ | language

协程是能暂停执行以在之后恢复的函数。协程是无栈的:它们通过返回到调用方暂停执行,并且恢复执行所需的数据与栈分离存储。这样就可以编写异步执行的顺序代码(例如不使用显式的回调来处理非阻塞输入/输出),还支持作用于惰性计算的无限序列上的算法及其他用途。

定义包含了以下之一的函数是协程:

  • co_await 表达式——用于暂停执行,直到恢复:
task<> tcp_echo_server()
{
    char data[1024];
    while (true)
    {
        std::size_t n = co_await socket.async_read_some(buffer(data));
        co_await async_write(socket, buffer(data, n));
    }
}
  • co_yield 表达式——用于暂停执行并返回一个值:
generator<int> iota(int n = 0)
{
    while (true)
        co_yield n++;
}
  • co_return 语句——用于完成执行并返回一个值:
lazy<int> f()
{
    co_return 7;
}

每个协程必须具有能够满足一组要求的返回类型,如下所述。

限制

协程不能使用变长实参,普通的 return 语句,或占位符返回类型autoConcept)。

consteval 函数constexpr 函数构造函数析构函数main 函数 不能是协程。

执行

每个协程都与下列对象关联:

  • 承诺(promise)对象,在协程内部操纵。协程通过此对象提交其结果或异常。
  • 协程句柄 (coroutine handle),在协程外部操纵。这是用于恢复协程执行或销毁协程帧的不带所有权句柄。
  • 协程状态 (coroutine state),它是一个动态存储分配(除非优化掉其分配)的内部对象,其包含:
  • 承诺对象
  • 各个形参(全部按值复制)
  • 当前暂停点的一些表示,使得程序在恢复时知晓要从何处继续,销毁时知晓有哪些局部变量在作用域内
  • 生存期跨过当前暂停点的局部变量和临时量

当协程开始执行时,它进行下列操作:

  • operator new 分配协程状态对象
  • 将所有函数形参复制到协程状态中:按值传递的形参被移动或复制,按引用传递的参数保持为引用(因此,如果在被指代对象的生存期结束后恢复协程,它可能变成悬垂引用)
  • 调用承诺对象的构造函数。如果承诺类型拥有接收所有协程形参的构造函数,那么以复制后的协程实参调用该构造函数。否则调用其默认构造函数。
  • 调用 promise.get_return_object() 并将结果保存在局部变量中。该调用的结果将在协程首次暂停时返回给调用方。至此并包含这个步骤为止,任何抛出的异常均传播回调用方,而非置于承诺中。
  • 调用 promise.initial_suspend()co_await 它的结果。典型的承诺类型 Promise 要么(对于惰性启动的协程)返回std::suspend_always,要么(对于急切启动的协程)返回std::suspend_never
  • co_await promise.initial_suspend() 恢复时,开始协程体的执行。

一些形参会垂悬的例子:

#include <coroutine>
#include <iostream>
 
struct promise;
struct coroutine : std::coroutine_handle<promise>
{ using promise_type = ::promise; };
 
struct promise
{
    coroutine get_return_object()
    { return {coroutine::from_promise(*this)}; }
    std::suspend_always initial_suspend() noexcept { return {}; }
    std::suspend_always final_suspend() noexcept { return {}; }
    void return_void() {}
    void unhandled_exception() {}
};
 
struct S
{
    int i;
    coroutine f()
    {
        std::cout << i;
        co_return;
    }
};
 
void bad1()
{
    coroutine h = S{0}.f();
    // S{0} 被销毁
    h.resume(); // 协程恢复并执行了 std::cout << i ,这释放后使用了 S::i
    h.destroy();
}
 
coroutine bad2()
{
    S s{0};
    return s.f(); // 返回的协程不能被恢复执行,否则会导致释放后使用
}
 
void bad3()
{
    coroutine h = [i = 0]() -> coroutine // 一个 lambda ,同时也是个协程
    {
        std::cout << i;
        co_return;
    }(); // 立即调用
    // lambda 被销毁
    h.resume(); // 释放后使用了 (anonymous lambda type)::i
    h.destroy();
}
 
void good()
{
    coroutine h = [](int i) -> coroutine // i 是一个协程形参
    {
        std::cout << i;
        co_return;
    }(0);
    // lambda 被销毁
    h.resume(); // 没有问题, i 已经作为按值传递的参数被复制到协程帧中
    h.destroy();
}

当协程抵达暂停点时:

  • 将先前获得的返回对象返回给调用方/恢复方,如果需要就先隐式转换到协程的返回类型。

当协程抵达 co_return 语句时,它进行下列操作:

  • 对下列情形调用 promise.return_void()
  • co_return;
  • co_return expr;,其中 expr 具有 void 类型
  • 控制流抵达返回 void 的协程的结尾。此时如果承诺类型 Promise 没有 Promise::return_void() 成员函数,那么行为未定义。
  • 或对于 co_return expr; 调用 promise.return_value(expr),其中 expr 具有非 void 类型
  • 以创建顺序的逆序销毁所有具有自动存储期的变量。
  • 调用 promise.final_suspend()co_await 它的结果。

如果协程因未捕获的异常结束,那么它进行下列操作:

  • 捕获异常并在处理块内调用 promise.unhandled_exception()
  • 调用 promise.final_suspend()co_await 它的结果(例如,恢复某个继续或发布某个结果)。此时开始恢复协程是未定义行为。

当经由 co_return 或未捕获异常而终止协程导致协程状态被销毁,或经由它的句柄而导致它被销毁时,它进行下列操作:

  • 调用承诺对象的析构函数。
  • 调用各个函数形参副本的析构函数。
  • 调用 operator delete 释放协程状态所用的内存。
  • 转移执行回到调用方/恢复方。

动态分配

协程状态通过非数组形式 operator new 动态分配。

如果承诺类型 Promise 定义了类级别的替代函数,那么会使用它,否则会使用全局的 operator new

如果承诺类型 Promise 定义了接收额外形参的 operator new 的布置形式,且它们所匹配的实参列表中的第一实参是要求的大小(std::size_t 类型),而其余则是各个协程函数实参,那么将这些实参传递给 operator new(这使得能对协程使用前导分配器约定

以下情况下,可以优化掉对 operator new 的调用(即使使用了自定义分配器):

  • 协程状态的生存期严格内嵌于调用方的生存期,且
  • 协程帧的大小在调用点已知

此时协程状态嵌入调用方的栈帧(如果调用方是普通函数)或协程状态(如果调用方是协程)之中。

如果分配失败,那么协程抛出 std::bad_alloc,除非承诺类型 Promise 类型定义了成员函数 Promise::get_return_object_on_allocation_failure()。如果定义了该成员函数,那么使用 operator new 的不抛出形式进行分配,而在分配失败时,协程会立即将从 Promise::get_return_object_on_allocation_failure() 获得的对象返回给调用方,例如

struct Coroutine::promise_type
{
    /* ... */
 
    // 确保使用不抛出operator-new
    static Coroutine get_return_object_on_allocation_failure()
    {
        std::cerr << "get_return_object_on_allocation_failure()\n";
        throw std::bad_alloc(); // 或者返回 Coroutine(nullptr);
    }
 
    // 自定义重载不抛出new
    void* operator new(std::size_t n) noexcept
    {
        if (void* mem = std::malloc(n))
            return mem;
        return nullptr; // 分配失败
    }
};

承诺类型(Promise

编译器用 std::coroutine_traits 从协程的返回类型确定承诺类型。

正式而言,令 RArgs... 分别代表协程的返回类型与参数类型列表,ClassTcv限定 (如果存在)分别代表协程所属的类与它的 cv 限定,如果定义它为非静态成员函数,以如下方式确定它的承诺类型:

  • std::coroutine_traits<R, Args...>::promise_type,如果不定义协程为非静态成员函数,
  • std::coroutine_traits<R, ClassT /*cv限定*/&, Args...>::promise_type,如果定义协程为非右值引用限定的非静态成员函数,
  • std::coroutine_traits<R, ClassT /*cv限定*/&&, Args...>::promise_type,如果定义协程为右值引用限定的非静态成员函数。

例如:

如果定义协程为 那么它的承诺类型 Promise
task<void> foo(int x); std::coroutine_traits<task<void>, int>::promise_type
task<void> Bar::foo(int x) const; std::coroutine_traits<task<void>, const Bar&, int>::promise_type
task<void> Bar::foo(int x) &&; std::coroutine_traits<task<void>, Bar&&, int>::promise_type

co_await

一元运算符 co_await 暂停协程并将控制返回给调用方。它的操作数是一个表达式,它的类型要么必须定义 operator co_await,要么能以当前协程的 Promise::await_transform 转换到这种类型。

co_await 表达式

co_await 表达式只能在通常函数体里面的潜在求值表达式中出现,并且不能在以下位置出现:

首先,以下列方式将 表达式 转换成可等待体(awaitable):

  • 如果 表达式 由初始暂停点、最终暂停点或 yield 表达式所产生,那么可等待体是 表达式 本身。
  • 否则,如果当前协程的承诺类型 Promise 拥有成员函数 await_transform,那么可等待体是 promise.await_transform(表达式)
  • 否则,可等待体是 表达式 本身。

然后以下列方式获得等待器(awaiter)对象:

  • 如果针对 operator co_await 的重载决议给出单个最佳重载,那么等待器是该调用的结果(对于成员重载为 awaitable.operator co_await();,对于非成员重载为 operator co_await(static_cast<Awaitable&&>(awaitable));
  • 否则,如果重载决议找不到 operator co_await,那么等待器是可等待体本身
  • 否则,如果重载决议有歧义,那么程序非良构

如果上述表达式为纯右值,那么等待器对象是从它实质化的临时量。否则,如果上述表达式为泛左值,那么等待器对象是它所指代的对象。

然后,调用 awaiter.await_ready()(这是当已知结果就绪或可以同步完成时,用以避免暂停开销的快捷方式)。如果它的结果按语境转换到 bool 的结果是 false,那么:

暂停协程(以各局部变量和当前暂停点填充其协程状态)。
调用 awaiter.await_suspend(handle),其中 handle 是表示当前协程的协程句柄。这个函数内部可以通过这个句柄观察暂停的协程,而且此函数负责调度它以在某个执行器上恢复,或将其销毁(并返回 false 当做调度)
  • 如果 await_suspend 返回 void,那么立即将控制返回给当前协程的调用方/恢复方(此协程保持暂停),否则
  • 如果 await_suspend 返回 bool,那么:
  • 值为 true 时将控制返回给当前协程的调用方/恢复方
  • 值为 false 时恢复当前协程。
  • 如果 await_suspend 返回某个其他协程的协程句柄,那么(通过调用 handle.resume())恢复该句柄(注意这可以连锁进行,并最终导致当前协程恢复)
  • 如果 await_suspend 抛出异常,那么捕捉该异常,恢复协程,并立即重抛异常
最后,调用 awaiter.await_resume(),它的结果就是整个 co_await expr 表达式的结果。

如果协程在 co_await 表达式中暂停而在后来恢复,那么恢复点处于紧接对 awaiter.await_resume() 的调用之前。

注意,因为协程在进入 awaiter.await_suspend() 前已经完全暂停,所以该函数可以自由地在线程间转移协程句柄而无需额外同步。例如,可以将它放入回调,将它调度成在异步输入/输出操作完成时在线程池上运行等。此时因为当前协程可能已被恢复,从而执行了等待器的析构函数,同时由于 await_suspend() 在当前线程上持续执行, await_suspend() 应该把 *this 当作已被销毁并且在句柄被发布到其他线程后不再访问它。

示例

#include <coroutine>
#include <iostream>
#include <stdexcept>
#include <thread>
 
auto switch_to_new_thread(std::jthread& out)
{
    struct awaitable
    {
        std::jthread* p_out;
        bool await_ready() { return false; }
        void await_suspend(std::coroutine_handle<> h)
        {
            std::jthread& out = *p_out;
            if (out.joinable())
                throw std::runtime_error("jthread 输出参数非空");
            out = std::jthread([h] { h.resume(); });
            // 潜在的未定义行为:访问潜在被销毁的 *this
            // std::cout << "新线程 ID:" << p_out->get_id() << '\n';
            std::cout << "新线程 ID:" << out.get_id() << '\n'; // 这样没问题
        }
        void await_resume() {}
    };
    return awaitable{&out};
}
 
struct task
{
    struct promise_type
    {
        task get_return_object() { return {}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() {}
    };
};
 
task resuming_on_new_thread(std::jthread& out)
{
    std::cout << "协程开始,线程 ID:" << std::this_thread::get_id() << '\n';
    co_await switch_to_new_thread(out);
    // 等待器在此销毁
    std::cout << "协程恢复,线程 ID:" << std::this_thread::get_id() << '\n';
}
 
int main()
{
    std::jthread out;
    resuming_on_new_thread(out);
}

可能的输出:

协程开始,线程 ID:139972277602112
新线程 ID:139972267284224
协程恢复,线程 ID:139972267284224

注意:等待器对象是协程状态的一部分(作为生存期跨过暂停点的临时量),并且在 co_await 表达式结束前销毁。可以用它维护某些异步输入/输出 API 所要求的每操作内状态,而无需用到额外的堆分配。

标准库定义了两个平凡的可等待体:std::suspend_alwaysstd::suspend_never

co_yield

yield 表达式向调用方返回一个值并暂停当前协程:它是可恢复生成器函数的常用构建块

co_yield 表达式
co_yield 花括号初始化器列表

它等价于

co_await promise.yield_value(表达式)

典型的生成器的 yield_value 会将其实参存储(复制/移动或仅存储它的地址,因为实参的生存期跨过 co_await 内的暂停点)到生成器对象中并返回 std::suspend_always,将控制转移给调用方/恢复方。

#include <coroutine>
#include <exception>
#include <iostream>
 
template<typename T>
struct Generator
{
    // 类名 'Generator' 只是我们的选择,使用协程魔法不依赖它
    // 编译器通过关键词 'co_yield' 的存在识别协程
    // 你可以使用 'MyGenerator' (或者任何别的名字)作为替代
    // 只要你在类中包括了拥有
    // 'MyGenerator get_return_object()' 方法的嵌套类 promise_type
    // (注意:在重命名时,你还需要调整构造函数/析构函数的名字)
 
    struct promise_type;
    using handle_type = std::coroutine_handle<promise_type>;
 
    struct promise_type // 必要
    {
        T value_;
        std::exception_ptr exception_;
 
        Generator get_return_object()
        {
            return Generator(handle_type::from_promise(*this));
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void unhandled_exception() { exception_ = std::current_exception(); } // 保存异常
 
        template <std::convertible_to<T> From> // C++20 概念
        std::suspend_always yield_value(From&& from)
        {
            value_ = std::forward<From>(from); // 在承诺中缓存结果
            return {};
        }
        void return_void() {}
    };
 
    handle_type h_;
 
    Generator(handle_type h)
        : h_(h)
    {
    }
    ~Generator() { h_.destroy(); }
    explicit operator bool()
    {
        fill(); // 获知协程是结束了还是仍能通过 C++ getter(下文的 operator())
                // 获得下一个值的唯一方式是执行/恢复协程到下一个 co_yield 节点
                // (或让执行流抵达结尾)
                // 我们在承诺中存储/缓存了执行结果,使得 getter(下文的 operator())
                // 可以获得这一结果而不执行协程
        return !h_.done();
    }
    T operator()()
    {
        fill();
        full_ = false;// 我们将移动走先前缓存的结果来重新置空承诺
        return std::move(h_.promise().value_);
    }
 
private:
    bool full_ = false;
 
    void fill()
    {
        if (!full_)
        {
            h_();
            if (h_.promise().exception_)
                std::rethrow_exception(h_.promise().exception_);
            // 在调用上下文中传播协程异常
 
            full_ = true;
        }
    }
};
 
Generator<uint64_t>
fibonacci_sequence(unsigned n)
{
    if (n == 0)
        co_return;
 
    if (n > 94)
        throw std::runtime_error("斐波那契序列过大,元素将会溢出。");
 
    co_yield 0;
 
    if (n == 1)
        co_return;
 
    co_yield 1;
 
    if (n == 2)
        co_return;
 
    uint64_t a = 0;
    uint64_t b = 1;
 
    for (unsigned i = 2; i < n; i++)
    {
        uint64_t s = a + b;
        co_yield s;
        a = b;
        b = s;
    }
}
 
int main()
{
    try
    {
        auto gen = fibonacci_sequence(10); // 最大值94,避免 uint64_t 溢出
 
        for (int j = 0; gen; j++)
            std::cout << "fib(" << j << ")=" << gen() << '\n';
    }
    catch (const std::exception& ex)
    {
        std::cerr << "发生了异常:" << ex.what() << '\n';
    }
    catch (...)
    {
        std::cerr << "未知异常。\n";
    }
}

输出:

fib(0)=0
fib(1)=1
fib(2)=1
fib(3)=2
fib(4)=3
fib(5)=5
fib(6)=8
fib(7)=13
fib(8)=21
fib(9)=34

注解

功能特性测试 标准 注释
__cpp_impl_coroutine 201902L (C++20) 协程 (编译器支持)
__cpp_lib_coroutine 201902L (C++20) 协程 (库支持)
__cpp_lib_generator 202207L (C++23) std::generator: 适用于范围的同步协程生成器

库支持

协程支持库定义数个类型,提供协程的编译与运行时支持。

参阅

(C++23)
表示同步协程生成器的 view
(类模板)

外部链接

1.  David Mazières, 2021 - C++20 协程教程
2.  Lewis Baker, 2017-2022 - Asymmetric Transfer.