i007.cc

i007.cc

优先队列-降维打击

05.价值资料

详解C++20的协程

C++20 协程是标准里最复杂的特性之一,设计目标是”零开销可定制”,代价是学习曲线极陡。下面从底层机制到实际用法系统讲清楚。


一、什么是 C++20 协程

C++20 的协程是无栈协程(stackless coroutine)。区别在于:

  • 有栈协程(如 Boost.Coroutine、goroutine):每个协程有自己的调用栈,可以在任意深度的函数调用中间暂停
  • 无栈协程:暂停点只能出现在协程函数本身的顶层,局部变量保存在堆上的协程帧里,没有独立调用栈

无栈协程的内存开销远小于有栈协程(通常几十字节 vs 数 KB),但表达能力稍有限制。

一个函数只要用了 co_awaitco_yieldco_return 三个关键字之一,它就是协程。


二、三个关键字

cpp
co_await expr;    // 暂停协程,等待 expr 完成
co_yield value;   // 暂停协程,向调用者产出一个值
co_return value;  // 结束协程,返回最终结果

 

注意:协程函数的返回类型不是 intstring 这类普通类型,而是一个特殊的 Promise 类型(下文详解)。


三、编译器做了什么

这是理解 C++20 协程的关键。当你写下一个协程函数,编译器会把它变换成大量”看不见”的代码。

假设你写:

cpp
Task<int> compute() {
    int x = co_await fetch_data();
    co_return x * 2;
}

 

编译器大致会把它变换成:

cpp
// 伪代码,展示编译器的变换逻辑
Task<int> compute() {
    // 1. 在堆上分配协程帧,存放局部变量和暂停状态
    auto* frame = operator new(sizeof(ComputeFrame));
    
    // 2. 构造 promise 对象(位于协程帧内)
    promise_type promise;
    
    // 3. 拿到返回给调用者的对象
    Task<int> result = promise.get_return_object();
    
    // 4. 执行初始暂停(如果 initial_suspend 返回 suspend_always)
    co_await promise.initial_suspend();
    
    // ---- 协程体开始 ----
    int x = co_await fetch_data();   // 暂停点
    promise.return_value(x * 2);     // co_return 展开
    // ---- 协程体结束 ----
    
    // 5. 执行最终暂停
    co_await promise.final_suspend();
    
    // 6. 销毁协程帧
    frame->destroy();
    
    return result;
}

 

关键在于:result 在步骤 3 就返回给调用者了,此时协程体甚至还没开始执行(取决于 initial_suspend 的策略)。


四、协程帧(Coroutine Frame)

每个协程实例在堆上有一个协程帧,包含:

  • 所有局部变量(在暂停点之间存活的那些)
  • promise_type 对象
  • 当前暂停点的索引(实现为一个状态机)
  • 协程函数的参数副本

编译器会把协程体变成一个状态机,每次 resume() 从上次暂停的地方继续执行:

状态 0 → 执行到 co_await fetch_data() → 暂停 → 状态 1
状态 1 → fetch_data() 完成,resume() 被调用 → 执行 co_return → 状态 2 (done)

 


五、四个核心定制点

C++20 协程的”复杂”主要来自这里——标准只提供机制,不提供策略,所有行为都通过这四个东西定制。

1. Promise 类型(promise_type

协程的返回类型 T 必须有一个嵌套类型 T::promise_type(或通过 coroutine_traits 特化指定),它控制协程的生命周期行为:

cpp
struct MyPromise {
    // 必须:返回给调用者的对象
    Task<int> get_return_object();
    
    // 必须:协程刚创建时是否立即暂停
    // suspend_always → 懒启动;suspend_never → 立即开始执行
    auto initial_suspend() { return std::suspend_always{}; }
    
    // 必须:协程结束时是否暂停(控制谁销毁协程帧)
    auto final_suspend() noexcept { return std::suspend_always{}; }
    
    // 必须:处理未捕获异常
    void unhandled_exception() { std::terminate(); }
    
    // co_return value 时调用
    void return_value(int v) { result = v; }
    
    // co_yield value 时调用(返回一个 awaitable)
    auto yield_value(int v) { 
        result = v; 
        return std::suspend_always{}; 
    }
    
    int result;
};

 

2. Awaitable 与 Awaiter

co_await expr 中的 expr 必须是一个 Awaitable。编译器会把它变换成:

cpp
auto&& awaitable = expr;

// 先尝试从 awaitable 获取 awaiter(可以是 awaitable 自身)
auto&& awaiter = awaitable.operator co_await();
// 或者 operator co_await(awaitable)

// 然后执行三步协议:
if (!awaiter.await_ready()) {           // 是否已经就绪?
    awaiter.await_suspend(handle);      // 暂停,handle 是当前协程的句柄
    // ... 控制权返回给调用者 ...
}
auto result = awaiter.await_resume();   // 恢复时拿到结果

 

所以自定义 awaiter 需要实现三个方法:

cpp
struct MyAwaiter {
    bool await_ready() { return false; }  // false = 需要暂停
    
    void await_suspend(std::coroutine_handle<> handle) {
        // 在这里:把 handle 交给某个异步操作
        // 当异步操作完成时,调用 handle.resume() 恢复协程
        async_op.on_complete([handle]{ handle.resume(); });
    }
    
    int await_resume() { return result; }  // 暂停结束后的返回值
    
    int result;
};

 

await_suspend 还可以返回 bool(false 则不暂停)或另一个 coroutine_handle<>(对称转移,避免栈溢出)。

3. coroutine_handle<P>

std::coroutine_handle<P> 是协程的句柄,本质上是指向协程帧的指针。它的核心操作:

cpp
handle.resume();   // 恢复协程执行
handle.destroy();  // 销毁协程帧,释放内存
handle.done();     // 是否已经结束
handle.promise();  // 访问 promise 对象

 

coroutine_handle<void> 是类型擦除版本,可以指向任何协程。

4. std::suspend_always 和 std::suspend_never

这两个是标准提供的最简单的 awaiter:

cpp
struct suspend_always {
    bool await_ready() { return false; }   // 总是暂停
    void await_suspend(coroutine_handle<>) {}
    void await_resume() {}
};

struct suspend_never {
    bool await_ready() { return true; }    // 从不暂停
    void await_suspend(coroutine_handle<>) {}
    void await_resume() {}
};

 


六、从零实现一个 Generator

用协程实现惰性序列生成器,是最直观的入门案例:

cpp
#include <coroutine>
#include <optional>

template<typename T>
class Generator {
public:
    struct promise_type {
        std::optional<T> value;
        
        Generator get_return_object() {
            return Generator{Handle::from_promise(*this)};
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void unhandled_exception() { std::terminate(); }
        void return_void() {}
        
        std::suspend_always yield_value(T v) {
            value = std::move(v);
            return {};
        }
    };
    
    using Handle = std::coroutine_handle<promise_type>;
    
    explicit Generator(Handle h) : handle(h) {}
    ~Generator() { if (handle) handle.destroy(); }
    
    // 不可拷贝,可移动
    Generator(const Generator&) = delete;
    Generator(Generator&& o) : handle(o.handle) { o.handle = {}; }
    
    std::optional<T> next() {
        if (!handle || handle.done()) return std::nullopt;
        handle.resume();
        if (handle.done()) return std::nullopt;
        return handle.promise().value;
    }

private:
    Handle handle;
};

// 使用:
Generator<int> fibonacci() {
    int a = 0, b = 1;
    while (true) {
        co_yield a;
        auto c = a + b;
        a = b;
        b = c;
    }
}

int main() {
    auto gen = fibonacci();
    for (int i = 0; i < 10; i++) {
        std::cout << *gen.next() << " ";
    }
    // 输出: 0 1 1 2 3 5 8 13 21 34
}

 


七、异步任务:实现 Task

异步场景下,Task<T> 表示一个懒启动的单次异步操作:

cpp
template<typename T>
class Task {
public:
    struct promise_type {
        std::variant<std::monostate, T, std::exception_ptr> result;
        std::coroutine_handle<> continuation;  // 等待我的那个协程
        
        Task get_return_object() {
            return Task{Handle::from_promise(*this)};
        }
        
        // 懒启动:创建后不立即执行
        std::suspend_always initial_suspend() { return {}; }
        
        // 结束时暂停,然后恢复等待者
        auto final_suspend() noexcept {
            struct FinalAwaiter {
                bool await_ready() noexcept { return false; }
                auto await_suspend(Handle h) noexcept {
                    // 对称转移:直接跳转到 continuation,不增加调用栈
                    return h.promise().continuation;
                }
                void await_resume() noexcept {}
            };
            return FinalAwaiter{};
        }
        
        void return_value(T v) { result = std::move(v); }
        void unhandled_exception() { result = std::current_exception(); }
    };
    
    using Handle = std::coroutine_handle<promise_type>;
    
    // Task 本身是一个 Awaitable
    bool await_ready() { return false; }
    
    auto await_suspend(std::coroutine_handle<> caller) {
        handle.promise().continuation = caller;
        return handle;  // 对称转移到 task 的协程
    }
    
    T await_resume() {
        auto& result = handle.promise().result;
        if (std::holds_alternative<std::exception_ptr>(result))
            std::rethrow_exception(std::get<std::exception_ptr>(result));
        return std::move(std::get<T>(result));
    }
    
    // ... 构造、析构、移动语义省略 ...
    
private:
    Handle handle;
};

// 使用:
Task<int> fetch_user_id(std::string name) {
    auto data = co_await http_get("/users/" + name);
    co_return data["id"].as<int>();
}

Task<std::string> get_username() {
    int id = co_await fetch_user_id("alice");
    auto info = co_await http_get("/profiles/" + std::to_string(id));
    co_return info["name"].as<std::string>();
}

 


八、对称转移(Symmetric Transfer)

这是 C++20 协程的一个关键技术细节,解决的是栈溢出问题。

没有对称转移时:

main → resume(task_A) → task_A 结束 → resume(task_B) → task_B 结束 → ...

 

每次 resume 都是一次函数调用,链式调用会撑爆调用栈。

await_suspend 返回 coroutine_handle<> 时,编译器会把它变成”尾调用跳转”:

cpp
auto await_suspend(coroutine_handle<> h) -> coroutine_handle<> {
    return next_coro;  // 直接跳转,不增加栈帧
}

 

这是 C++20 协程能高效实现 continuation-passing style 的基础。


九、内存分配优化

协程帧默认用 operator new 分配。但编译器有一个重要优化:协程帧省略(Heap Allocation Elision Optimization, HALO)——如果编译器能确定协程的生命周期不超过调用者,它可以把协程帧分配在调用者的栈上,彻底消除堆分配。

这个优化在实践中常见于简单的 generator 场景,但在跨越异步边界时通常无法触发。

自定义分配可以在 promise_type 里重载 operator new

cpp
struct promise_type {
    void* operator new(size_t size, MyAllocator& alloc, ...) {
        return alloc.allocate(size);
    }
    void operator delete(void* ptr, size_t size) {
        MyAllocator::deallocate(ptr, size);
    }
};

 


十、标准库提供了什么,没提供什么

提供了:

  • std::coroutine_handle<P> — 协程句柄
  • std::coroutine_traits<R, Args...> — 定制 promise_type 的特化点
  • std::suspend_alwaysstd::suspend_never — 两个基础 awaiter
  • std::noop_coroutine() — 用于对称转移链的终止节点

没有提供:

  • Task<T>Generator<T>AsyncGenerator<T> — 你要自己写或用库
  • 线程池调度器
  • 任何异步 I/O 集成

这是 C++20 协程被诟病最多的一点:标准只给了机制,没给工具。你拿到的是发动机零件,不是一辆车。


十一、实际上用什么

手写 Task/Generator 非常繁琐且容易出错,实际项目中通常用:

  • cppcoro(Lewis Baker 写的,是 P2300 的精神前身,已停止维护但代码质量极高,值得学习)
  • libunifex(Meta,P2300 的另一个参考实现,与 sender/receiver 集成)
  • Asio(Boost.Asio 和 standalone Asio 都支持 co_await,是目前生产可用度最高的选项)
  • cpptask / folly::coro(Facebook 内部,有开源版)
  • C++26 的 std::generator(标准终于提供了一个 generator,不过只是 C++26)

十二、与 Rust async/await 的对比

C++20 协程 Rust async/await
协程类型 无栈 无栈
运行时 无内置运行时,完全定制 无内置运行时,需要 tokio/async-std
开销 理论零开销,实践中取决于实现 类似
学习曲线 极陡,需理解 promise/awaiter 协议 较平,async fn 自动推导 Future
生态 碎片化,无统一 executor tokio 一统江湖
错误处理 手动,unhandled_exception ? 运算符天然支持
取消 无标准机制 无标准机制,tokio 有 CancellationToken

C++ 协程的定制能力比 Rust 强得多(你可以完全控制调度策略和内存分配),但这种自由度的代价是极高的复杂性。Rust 选择了一个更窄但更好用的点。


一句话总结

C++20 协程是一套可定制的编译期变换机制,把协程函数变成状态机,把局部状态存到堆上的协程帧里。它本身极其灵活,但几乎不提供开箱即用的工具——真正拿来用还需要一个成熟的上层库(目前 Asio 是最现实的选择),或者等 C++26/C++29 标准库逐步补全配套设施。

发表回复