C++ / a working model

60 / 80   ·   C++20   ·   约 15 分钟

Coroutines:暂停、恢复与协程帧所有权

先记住这句话

C++20 协程让函数暂停后恢复,但不会自动创建线程、事件循环或后台任务。最小可用抽象也必须明确管理协程帧、完成状态与异常;下面的同步生成器用独占 RAII 所有权保证提前结束时仍能释放资源。

本篇内容
  1. 语言机制不等于调度器
  2. 句柄不拥有帧,包装器必须拥有
  3. 异常与借用必须穿过暂停点审查
  4. 运行示例
  5. 动手练习

语言机制不等于调度器

函数含有 co_await、co_yield 或 co_return 等协程语法时,编译器按返回类型的 promise_type 协议组织执行。跨暂停点仍需保存的状态进入协程帧,帧通常需要动态存储,但特定条件下分配可以被优化消除。它不是给每个函数配一条完整线程栈。

协程只提供暂停与恢复机制,何时恢复、在哪条线程恢复由外部抽象决定。例子由 main 主动调用 next,因此所有代码同步地在调用线程执行;co_yield 交出一个整数后暂停,并没有启动后台计算,更没有自动并行运行。

句柄不拥有帧,包装器必须拥有

coroutine_handle 是可复制的非拥有句柄,复制它不会复制协程帧。示例把句柄藏在 Generator 内,禁止复制、允许移动,并由析构函数 destroy 帧。initial_suspend 使用 suspend_always,使函数体在首次 next 才运行;final_suspend 同样暂停,让所有者统一负责销毁。

next 先检查空句柄与完成状态,避免恢复已经结束的协程。每次 yield 将整数保存到 promise,next 再按值返回 optional,调用者不持有帧内部元素的引用。移动赋值先释放自身旧帧,再接管新帧,提前离开作用域也走同一条 RAII 释放路径。

异常与借用必须穿过暂停点审查

函数体未处理异常进入 unhandled_exception,示例保存 exception_ptr 并在恢复方重新抛出,避免把失败伪装成普通序列结束。帧仍由 Generator 管理,异常传播不会要求调用者手工 destroy。next 返回空 optional 后再次调用也安全地保持结束状态。

引用参数不会因为进入协程帧就变成拥有副本,this 指针和捕获 lambda 的对象同样可能提前销毁。本例生成函数无外部借用,局部 Guard 用断言展示暂停时存活、提前销毁时释放。这是同步、单消费者的教学生成器,不承诺并发恢复安全。

容易答错的地方

  • 不能对已到 final_suspend 的协程再次 resume,也不能由两个复制句柄各自 destroy;独占所有权与完成检查必须同时成立。
  • 协程接受引用或依赖 this 时,调用返回不代表相关对象可销毁;它们必须覆盖未来所有恢复操作的生命期。

运行一个例子

最低标准 C++20 · 完整程序 · 下载 .cpp

#include <cassert>
#include <coroutine>
#include <exception>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <utility>

class Generator {
public:
    struct promise_type {
        int current = 0;
        std::exception_ptr error;
        Generator get_return_object();
        std::suspend_always initial_suspend() noexcept { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(int n) noexcept {
            current = n;
            return {};
        }
        void return_void() noexcept {}
        void unhandled_exception() noexcept { error = std::current_exception(); }
    };
private:
    using Handle = std::coroutine_handle<promise_type>;
    Handle handle_;
    explicit Generator(Handle h) noexcept : handle_(h) {}
public:
    Generator(const Generator&) = delete;
    Generator& operator=(const Generator&) = delete;
    Generator(Generator&& other) noexcept
        : handle_(std::exchange(other.handle_, {})) {}
    Generator& operator=(Generator&& other) noexcept {
        if (this != &other) {
            if (handle_) handle_.destroy();
            handle_ = std::exchange(other.handle_, {});
        }
        return *this;
    }
    ~Generator() { if (handle_) handle_.destroy(); }
    std::optional<int> next() {
        if (!handle_ || handle_.done()) return std::nullopt;
        handle_.resume();
        if (handle_.promise().error)
            std::rethrow_exception(handle_.promise().error);
        if (handle_.done()) return std::nullopt;
        return handle_.promise().current;
    }
};
Generator Generator::promise_type::get_return_object() {
    return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}

struct Guard {
    inline static int live = 0;
    Guard() { ++live; }
    ~Guard() { --live; }
};
Generator numbers() {
    Guard guard;
    for (int n = 1; n <= 3; ++n) co_yield n;
}
Generator failure() {
    throw std::runtime_error("failed");
    co_return;
}
int main() {
    {
        auto early = numbers();
        assert(Guard::live == 0);
        auto first = early.next();
        assert(first && *first == 1 && Guard::live == 1);
        auto moved = std::move(early);
        assert(!early.next());
    }
    assert(Guard::live == 0);
    auto all = numbers();
    int total = 0;
    while (auto n = all.next()) total += *n;
    assert(total == 6 && Guard::live == 0);
    assert(!all.next());
    bool caught = false;
    try { auto bad = failure(); bad.next(); }
    catch (const std::runtime_error&) { caught = true; }
    assert(caught);
    std::cout << total << ' ' << Guard::live << '\n';
}

在本地编译

g++ -std=c++20 -Wall -Wextra -Wpedantic -pthread modern-coroutines.cpp -o example && ./example

预期结果

6 0

CHECK YOUR UNDERSTANDING

合上答案,试着解释。

若把 final_suspend 改为 suspend_never,却仍由 Generator 析构时 destroy,会发生什么所有权问题?

查看参考答案

正常结束后帧可能已自动销毁,Generator 中却还保存原句柄,随后检查或 destroy 就可能访问失效帧。这个包装器的契约依赖最终暂停保留帧,不能只改一个返回类型。要采用自动销毁策略,必须重新设计句柄失效通知与所有权协议,而不是继续复用现有析构逻辑。

继续查证

标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。

回到目录