引言:C++20 协程的革命性意义

C++20 引入的协程(Coroutines)被誉为自 C++11 移动语义以来最重要的语言特性。不同于操作系统线程的抢占式调度,C++ 协程是用户态协作式的执行单元——程序员显式控制挂起与恢复,无需内核介入,无需锁保护共享状态。一次协程切换的开销仅为几次指针交换(纳秒级),而线程切换需要保存/恢复完整的 CPU 上下文(微秒级)。

但 C++20 协程的设计哲学与众不同:标准库不提供现成的"协程类型",而是提供了一套编译器原语(low-level building blocks),让开发者自行构建适合场景的协程抽象。这种"给予鱼竿而非鱼"的设计赋予了极致的灵活性,但也带来了陡峭的学习曲线。

C++ 协程的核心思想:将函数的执行状态(局部变量、指令指针、挂起点)打包到一个可恢复的"Continuation"对象中——这就是协程帧(Coroutine Frame)。

第一节:无栈协程 vs 有栈协程

1.1 两种协程模型对比

特性有栈协程 (Stackful)无栈协程 (Stackful)
典型实现Boost.Coroutine2、goroutine、Lua CoroutineC++20 coroutines、C# async/await、JS async
独立栈空间✅ 拥有独立栈(通常 8KB-8MB)❌ 共享调用者栈,挂起时数据存入堆
嵌套调用✅ 可调用普通函数并挂起其内部⚠️ 只能从协程内部挂起,不可跨非协程函数挂起
内存开销固定栈空间 + 上下文仅存活变量的精确大小(编译器计算)
上下文切换swapcontext / 汇编保存寄存器协程帧指针交换
执行效率中(需切换栈指针)高(通常内联为状态机)

1.2 为什么 C++ 选择无栈模型?

有栈协程虽然直观(看起来像线程),但存在栈空间浪费问题:你无法预知一个协程运行时的实际栈需求。设太大浪费内存,设太小则栈溢出。无栈协程通过编译器精确分析,将仅跨越挂起点的变量存入堆上的协程帧,其余临时变量保留在调用者栈上——后者在协程挂起时自动释放,内存利用率极高。

第二节:C++20 协程三大关键字解析

2.1 co_await — 挂起的核心

co_await expr 是协程挂起的唯一方式。编译器将其转化为一系列 Awaiter 接口调用:

// 伪代码:co_await awaiter 的编译展开
bool await_ready = awaiter.await_ready();
if (!await_ready) {
    // 将当前协程注册到 awaiter 的恢复句柄
    awaiter.await_suspend(coroutine_handle);
    return;  // 挂起!控制权返回给调用者/恢复者
}
// 如果 await_ready() == true,不挂起,直接取值
auto result = awaiter.await_resume();

Awaiter 可以是任意满足这三方法接口的类型。这意味着你可以控制:

  • await_ready():是否需要挂起?如果已知结果,返回 true 避免挂起,零开销。
  • await_suspend(handle):挂起时做什么?通常将 handle 交给调度器或注册到 I/O 事件循环。
  • await_resume():恢复时返回的值——就是 co_await 表达式的最终结果。

2.2 co_yield — 序列生成器


// co_yield expr 等价于:
co_await promise.yield_value(expr);

co_yield 非常适合实现 Generator 模式:每次调用者拉取一个值时,协程恢复 → 执行到下一个 co_yield → 保存新值并挂起。典型应用是无限序列生成(斐波那契、数据流分块读取)。

2.3 co_return — 协程终结


// co_return expr 等价于:
promise.return_value(expr);
// 跳转到最终挂起点并销毁
co_await promise.final_suspend();

不带值的 co_return; 调用 promise.return_void()。如果忘却 co_return,协程会在函数体末尾隐式调用 return_void()——但这是未定义行为(UB),C++20 要求所有返回非 void 的协程必须有 co_return。

第三节:Promise 机制 — 协程的灵魂

Promise 是协程的"控制中枢"——它决定了协程的行为模式。编译器在发现函数体内使用 co_await/co_yield/co_return 时,会为该函数特化 std::coroutine_traits<RetType, Args...>,从中提取 Promise 类型。

// 完整的 Task 类型定义
template<typename T = void>
struct Task {
    struct promise_type {
        T value;
        std::exception_ptr eptr;

        // 1. 返回协程类型本身(决定函数签名 → 返回类型的映射)
        Task get_return_object() {
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }

        // 2. 初始挂起策略:suspend_always(惰性启动)或 suspend_never(立即执行)
        std::suspend_always initial_suspend() { return {}; }

        // 3. 最终挂起策略:必须 suspend_always,否则协程帧自动销毁
        std::suspend_always final_suspend() noexcept { return {}; }

        // 4. 返回值处理
        void return_value(T v) { value = std::move(v); }

        // 5. 异常处理
        void unhandled_exception() { eptr = std::current_exception(); }

        // 6. 自定义 co_await 变换(C++20 返回值推导)
        // auto await_transform(CustomType) → 允许对特定类型启用 co_await
    };

    // 句柄是协程帧的"遥控指针"
    std::coroutine_handle<promise_type> handle;

    // RAII 管理协程帧生命周期
    ~Task() { if (handle) handle.destroy(); }
};

3.1 initial_suspend 策略选择

  • suspend_always(惰性启动):协程创建后立即挂起,等待调度器驱动。适合 I/O 密集型任务。
  • suspend_never(立即启动):协程创建时就开始执行,直到第一个挂起点。适合需要尽快执行的"热"任务。

3.2 协程帧内存布局

编译器将协程帧精确布局为:

// 协程帧布局(概念上)
struct Frame {
    Promise promise;         // Promise 对象(含 return_value 存储)
    // --- register save area --- (仅在需要时保存)
    int local_var1;          // 跨越挂起点的局部变量
    string local_var2;       // 同上
    Awaiter awaiter;         // 当前挂起点的 Awaiter 对象
    void* resume_addr;       // 恢复时的指令地址(类似程序计数器)
};

对比有栈协程固定预留数百 KB 栈空间,无栈协程帧大小可能仅有几十到几百字节——这使得创建大量协程成为可能(百万级协程并发是可行的)。

第四节:Task 类型完整实现 — 构建可组合的协程

4.1 最小的 Recoverable Task

template<typename T = void>
struct Task {
    struct promise_type {
        std::optional<T> result;
        std::exception_ptr error;

        Task get_return_object() {
            return {Handle::from_promise(*this)};
        }
        std::suspend_always initial_suspend() { return {}; }
        void unhandled_exception() { error = std::current_exception(); }

        auto final_suspend() noexcept struct FinalAwaiter {
            bool await_ready() noexcept { return false; }
            // 关键技巧:恢复调用者链
            std::coroutine_handle<> await_suspend(Handle h) noexcept {
                auto& prom = h.promise();
                if (prom.continuation) return prom.continuation;
                return std::noop_coroutine();
            }
            void await_resume() noexcept {}
        };

        void return_value(T val) { result = std::move(val); }
    };

    using Handle = std::coroutine_handle<promise_type>;

    // 链式延续:允许 task1 完成后恢复 task2
    void continuations(Handle next) {
        handle.promise().continuation = next;
    }
    void resume() { handle.resume(); }

    Handle handle;
    ~Task() { if (handle) handle.destroy(); }
};

4.2 when_all — 并发等待多个协程

// N 个协程全部完成时恢复主协程
template<typename... Ts>
Task<std::tuple<Ts...>> when_all(Task<Ts>... tasks) {
    // 预分配结果元组
    std::tuple<Ts...> results;
    size_t remaining = sizeof...(tasks);

    // 为每个 task 设置 continuation 链
    co_await std::suspend_always{};  // 让出控制权,让子任务独立执行
    // ... 当所有子任务均调用你的 continuation 句柄时,再 co_await 一个汇总 awaiter
    co_return results;
}

第五节:协程调度器设计

5.1 多线程 Work-Stealing 调度器

单线程事件循环无法满足多核 CPU 需求。Work-Stealing 是每个线程维护一个本地 LIFO 队列,窃取其他线程的 FIFO 任务:本地 pop hottest(cache-friendly),窃取 FIFO 减少竞争。

class Scheduler {
    // 本地队列:LIFO,线程自己 push/pop 无需加锁
    std::vector<ch> local_queue[MAX_THREADS];
    // 窃取队列:FIFO,其他线程 steal 时加锁/chase-lock
    StealableQueue<ch> global_queue;

public:
    void schedule(ch h) {
        int tid = current_thread_id();
        local_queue[tid].push_back(h);
    }

    void run(int tid) {
        while (running) {
            ch h = local_pop(tid);
            if (!h) h = steal_from_other(tid);
            if (!h) { sleep_briefly(); continue; }
            h.resume();  // 驱动协程执行
            if (h.done()) h.destroy();
        }
    }

private:
    ch steal_from_other(int tid) {
        for (int i = 0; i < MAX_THREADS; i++) {
            if (i == tid) continue;
            ch h = global_queue.steal(i);
            if (h) return h;
        }
        return nullptr;
    }
};

5.2 整合 io_uring — 异步 I/O 协程恢复

真正的协程运行时必须与 I/O 事件循环配合。Linux 5.1+ 的 io_uring 提供真正的异步 I/O(无 AIO/vsyscall 限制),是构建高性能协程运行时的理想选择:

// 将 io_uring 绑定到协程调度器
class UringLoop {
    struct io_uring ring;
    Scheduler& sched;

public:
    UringLoop(Scheduler& s) : sched(s) {
        io_uring_queue_init(QUEUE_DEPTH, &ring, IORING_SETUP_SQPOLL);
    }

    // 提交一个协程的 I/O 请求,完成后自动恢复
    Task<ssize_t> read_async(int fd, void* buf, size_t count) {
        // 获取 SQE 并填充 read 请求
        auto* sqe = io_uring_get_sqe(&ring);
        io_uring_prep_read(sqe, fd, buf, count, 0);
        // 将协程 handle 存入 user_data
        sqe->user_data = (uint64_t)co_await std::noop_coroutine();

        io_uring_submit(&ring);

        // 挂起当前协程
        co_await suspend_until_io_done();

        // io_uring 完成时被事件循环恢复
        co_return get_cqe_result();
    }

    void event_loop() {
        while (running) {
            // 批量收割完成事件
            io_uring_cqe* cqes[BATCH];
            int n = io_uring_peek_batch_cqe(&ring, cqes, BATCH);
            for (int i = 0; i < n; i++) {
                ch h = (ch)io_uring_cqe_get_data(cqes[i]);
                sched.schedule(h);  // 重新进入调度队列
                io_uring_cqe_seen(&ring, cqes[i]);
            }
            // io_uring 的 shared ring kernel 主动 polling,无需系统调用
        }
    }
};

第六节:协程陷阱与最佳实践

6.1 最常见的 UB 场景

陷阱后果规避方法
协程帧销毁后访问 PromiseHeap-use-after-free(通常是崩溃或数据损坏)final_suspend 中必须 disconnect,handle.destroy() 前确保无状态依赖
忘记 co_returnC++20 UB → 编译器警告 + 可能内存泄漏开启 -Werror=return-type,使用 static_assert
在协程内使用 catch(...) 吞掉异常协程帧泄漏,promise.unhandled_exception() 未调用要么 rethrow,要么显式调用 current_exception() 存储
跨线程 resume 时未同步数据竞争(UB)使用 sched.schedule(handle) 将 resume 投递到原始线程
lambda 协程中引用捕获局部变量lambda 销毁后引用失效(经典悬空)值捕获或确保 lambda 生命周期长于协程执行时间

6.2 性能关键原则

  • 避免协程帧 heap 分配爆炸:重载 promise_type::operator new 使用内存池(Pool Allocator / Arena),将百万级协程帧的分配开销从 O(N) malloc 降为 O(1) 预分配。
  • 使用 symmetric transfer(对称转移):await_suspend 返回下一个协程 handle 时,编译器生成尾调用(tail call),避免递归 resume 导致的栈溢出。
  • 让 Awaiter 小且 trivially movable:Awaiter 嵌入在协程帧中,如果 Awaiter 很大或有非平凡析构函数,协程帧大小膨胀。
  • 热路径避免虚函数:协程调度器的核心路径(schedule/resume)应使用 CRTP 或 concepts 而非 virtual 实现,避免间接调用开销。

第七节:生产级协程运行时架构参考

构建一个类似 libcppcoro(已废弃)或 Mara(现代 C++20)的高性能协程运行时,需要整合以下模块:


┌───────────────────────────────────────────────────────┐
│                   Application Layer                    │
│  co_await http_get(...); co_await db_query(...);     │
├───────────────────────────────────────────────────────┤
│                  Task / Generator API                  │
│  Task<T>, Generator<T>, when_all(), when_any()       │
├───────────────────────────────────────────────────────┤
│              Scheduler (Work-Stealing)                 │
│  N 线程 × 本地 LIFO + 全局 Work-Stealing Queue        │
├───────────────────────────────────────────────────────┤
│               I/O Backend (io_uring)                   │
│  Fixed Buffers + SQPOLL + CQ batch reap               │
├───────────────────────────────────────────────────────┤
│               Timer / Signal / AIO                    │
│  TimerFD → uring submit or epoll + eventfd           │
└───────────────────────────────────────────────────────┘
  • 协程层:提供 Task/Generator 类型,通过 co_await 组合异步操作。
  • 调度层:多线程 Work-Stealing,自动均衡负载。
  • I/O 层:io_uring + Fixed Buffers 零拷贝 I/O;若不支持 io_uring 则回退到 epoll + eventfd。
  • 基础设施层:定时器(timerfd_create + IORING_OP_TIMEOUT)、信号处理(signalfd → uring)、AIO(IORING_OP_READ/WRITE)。

总结与展望

C++20 协程是"零成本抽象"哲学的极致体现:你只为你使用的功能付出代价,未使用的语言机制不产生运行时开销。从零构建协程运行时是理解现代异步编程本质的最佳路径——它让你看清 Go 的 goroutine调度器、Rust 的 tokio 运行时、JavaScript 的 event loop 在底层是如何通过的同一组机制实现看似不同的API。

随着 C++23 的 std::generator 和 std::stackless_coroutine 进入标准,以及 C++26 的 std::execution(基于 Sender/Receiver 模型)的逐步落地,C++ 的协程与异步编程生态正在迎来真正的收获期。

推荐学习资源

  • 《C++20 Coroutines — Complete Guide》:lewissbaker.github.io,作者为 C++ 委员会协程提案作者,权威深度解析
  • cppcoro 库(已停更但设计精妙):github.com/lewissbaker/cppcoro,展示了完整的协程原语集合
  • Folly 的 coroutine 实现:facebook/folly 的 CPUExecutor + Task 类型,生产级大规模验证
  • Mara(Modern Async Runtime Abstraction):C++20 + io_uring 的协程网络库,最贴近本文架构的参考实现
  • C++Now / CppCon 协程主题演讲:David Sankel 和 Gor Nishanov 历年演讲深度与实用性兼备
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部