C++ Coroutines(协程) 详解

概念

C++ Coroutines 是 C++20 引入的一种语言级协程机制,用来把"会中途暂停、以后再继续"的函数写成顺序代码。它适合表达异步 I/O、生成器、事件驱动状态机等场景。

它不是线程,也不是运行时调度器。C++ 标准只定义了语法和编译器变换规则,不提供像 Go 那样的内置调度。你通常还需要配套库或自己实现 awaitable、task、scheduler。

最核心的三个关键字是:

  1. co_await

    表示"如果结果还没准备好,就先挂起;准备好后再继续"。

  2. co_yield

    表示"产出一个值,并暂停自己",常用于生成器。

  3. co_return

    表示"协程结束,并返回结果"。

只要函数体里用了这三个关键字之一,它就会被当成协程来编译。

它和普通函数的区别

普通函数调用时,要么一路执行到结束,要么抛异常退出。协程则可以:

  1. 执行到某个挂起点暂停
  2. 保存当前状态
  3. 稍后从暂停点恢复
  4. 最终完成并销毁状态

编译器会把协程拆成一个状态机。局部变量、当前位置、异常状态等会被放进一块协程帧里。恢复时,本质上是在重新进入这台状态机。

它和线程的区别

线程是操作系统调度的执行流,切换成本相对高,通常有独立栈。

协程是语言级、用户态的可暂停计算,C++20 协程是无栈协程,切换通常更轻量。

简单说:

  1. 线程解决"并行执行"
  2. 协程更擅长解决"等待期间不要阻塞、代码仍然像同步一样写"

一个最小协程长什么样

协程的返回类型不能只是随便一个类型,它需要配套 promise_type。下面是一个极简示意:

cpp 复制代码
#include <coroutine>
#include <exception>

struct Task {
    struct promise_type {
        Task get_return_object() { return {}; }
        std::suspend_never initial_suspend() noexcept { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() noexcept {}
        void unhandled_exception() { std::terminate(); }
    };
};

Task foo() {
    co_return;
}

这段代码虽然没什么实际用途,但足以说明一件事:协程返回类型背后必须有一个 promise_type,编译器靠它决定如何构造、挂起、恢复和结束协程。

协程的底层结构

理解 C++ 协程,抓住这四个对象就够了:

  1. 协程函数

    你写的那个含有 co_await 或 co_yield 的函数。

  2. promise_type

    协程的"控制中心"。定义返回对象、异常处理、初始挂起、最终挂起、返回值如何保存等。

  3. coroutine_handle

    一个轻量句柄,指向协程状态。可以用它来 resume、destroy、done。

  4. 协程帧

    编译器生成的状态存储区域,里面放局部变量、挂起点编号、promise 对象等。

执行生命周期

一个协程大致经历这几个阶段:

  1. 创建协程帧
  2. 构造 promise_type
  3. 调用 get_return_object 生成返回对象
  4. 执行 initial_suspend
  5. 运行协程主体
  6. 遇到 co_await 或 co_yield 可能挂起
  7. 恢复后继续执行
  8. 执行 co_return 或异常退出
  9. 执行 final_suspend
  10. destroy 释放协程帧

其中 initial_suspend 和 final_suspend 非常关键。

initial_suspend 决定协程创建后是"立刻执行"还是"先挂起,等别人手动启动"。

final_suspend 决定协程结束后是否还保留挂起状态,方便外部拿结果、做 continuation,再决定何时销毁。

co_await 到底做了什么

表达式:co_await expr

编译器会把它变成一套 await 协议,大致分三步:

  1. 先把 expr 转成一个 awaiter
  2. 调用 await_ready()
    如果返回 true,说明不用挂起,直接继续
  3. 如果返回 false
    调用 await_suspend(handle)
    决定如何挂起,以及谁来恢复这个协程
  4. 恢复后调用 await_resume()
    拿到 co_await 的结果

也就是说,co_await 不是"等待某个固定类型",而是等待"任何实现了 await 协议的对象"。

一个最小 awaiter 可以长这样:

cpp 复制代码
#include <coroutine>
#include <iostream>

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

    void await_suspend(std::coroutine_handle<>) const noexcept {
        std::cout << "suspend\n";
    }

    int await_resume() const noexcept {
        return 42;
    }
};

如果协程里写:int x = co_await MyAwaiter{};

那么恢复后 x 就是 42。

await_suspend 的意义

await_suspend 是协程和调度器衔接的关键点。它拿到当前协程句柄后,可以:

  1. 什么都不做,让别人以后恢复
  2. 立刻恢复当前协程
  3. 把当前协程挂到某个事件源上
  4. 把 continuation 链起来
  5. 返回另一个协程句柄,让执行权转移给别的协程

这就是为什么 C++ 协程本身不负责调度,但能很好地接入异步框架。

co_yield 是什么

co_yield 常用于生成器。它的语义大致等价于:

  1. 把值交给 promise_type 的 yield_value
  2. 通常挂起当前协程
  3. 下次恢复时再继续往下执行

例如一个生成器可以这样用:

cpp 复制代码
auto g = counter();
while (g.next()) {
    std::cout << g.value() << "\n";
}

协程函数内部则可能是:

cpp 复制代码
co_yield 1;
co_yield 2;
co_yield 3;

每次 co_yield 都把一个值暴露给外部,然后暂停。

co_return 是什么

co_return 表示协程完成。

如果返回 void,通常会走 promise_type.return_void()。

如果返回值类型 T,通常会走 promise_type.return_value(value)。

例如:

cpp 复制代码
struct promise_type {
    void return_value(int v) noexcept {
        result = v;
    }
};

这样协程结束时,返回值会被存进 promise 中,外部再从返回对象里取走。

promise_type 的职责

promise_type 是整个协程设计的核心。常见成员包括:

  1. get_return_object

    构造并返回协程的返回对象。

  2. initial_suspend

    决定创建后是否立即执行。

  3. final_suspend

    决定结束后是否挂起等待收尾。

  4. return_void 或 return_value

    处理 co_return。

  5. unhandled_exception

    处理未捕获异常。

  6. yield_value

    处理 co_yield。

  7. await_transform

    可选。拦截 co_await expr,把 expr 转换成别的 awaitable。

其中 await_transform 很强大。它允许你定制"在这个协程上下文里,co_await 某个对象是什么意思"。

coroutine_handle 是什么

coroutine_handle 可以理解成"协程实例的句柄"。你可以通过它:

  1. resume()

    恢复执行

  2. destroy()

    释放协程帧

  3. done()

    判断是否已经到最终挂起点

它本身很轻,但也很危险,因为它像裸指针一样,不做资源管理。通常要配合 RAII 封装,避免泄漏或重复销毁。

一个简单生成器思路

标准库直到 C++23 才有 std::generator,而且并非所有编译器/标准库都完整可用。自己写生成器时,常见模式是:

  1. 返回对象内部保存 coroutine_handle<promise_type>
  2. promise_type 有一个 current_value
  3. yield_value 把值写入 current_value 并挂起
  4. 外部调用 next() 时 resume()
  5. 用 value() 读取当前值
  6. 析构时 destroy()

这类代码能很好展示协程"按需产出数据"的能力,比一次性构造整个容器更省内存。

为什么说它是无栈协程

C++20 协程不是给每个协程分配一个独立调用栈,而是把暂停点之间需要保存的状态放入协程帧。它不能像某些有栈协程那样在任意深层函数处直接切出去,只有在显式的 co_await、co_yield、co_return 这些语义点才能挂起。

这也是它高效的原因之一,但也意味着它的表达力更依赖编译器变换和 awaitable 设计。

异常处理

协程中的异常如果没被函数体内部捕获,会进入 promise_type.unhandled_exception()。

常见做法是:

  1. 把异常保存到 promise 里
  2. 在 await_resume 或结果读取阶段重新抛出

这样可以把"异步失败"延迟到调用方真正取结果时再感知。

内存分配

协程帧通常会动态分配,但并不一定总在堆上。编译器在某些场景下可能优化掉分配。你也可以在 promise_type 中重载 operator new 和 operator delete,自定义分配策略。

这在高性能系统里很重要,因为频繁创建短生命周期协程时,分配成本可能成为瓶颈。

为什么很多人第一次学会觉得难

难点不在语法,而在"协议"和"控制反转":

  1. 你写的是顺序代码,但真正控制恢复时机的是 awaiter 或调度器
  2. 返回对象、promise_type、handle 三者职责分散
  3. final_suspend 和销毁时机很容易搞错
  4. 生命周期和所有权问题非常容易踩坑

所以学协程,最好分两层理解:

  1. 使用层

    会写 co_await task,知道它让异步代码看起来像同步

  2. 实现层

    知道 promise_type、awaiter、handle 怎么协作

只掌握第一层够日常使用,第二层更适合框架作者或底层库开发。

典型使用场景

  1. 异步 I/O

    网络请求、定时器、文件读写,避免回调地狱。

  2. 生成器

    惰性遍历大数据流、树遍历、解析器。

  3. 状态机

    把复杂状态迁移写成更自然的顺序逻辑。

  4. 协作式任务系统

    游戏引擎、事件循环、任务图调度。

一个直观类比

把普通函数想成"一口气演完的戏"。

把协程想成"分幕演出,每到关键节点可以暂停,布景保留,下次接着演"。

协程帧就是舞台现场保存下来的所有状态。

coroutine_handle 是后台工作人员拿着的控制器。

promise_type 是导演规则,规定开场、暂停、收场、异常怎么处理。

与 future/promise 的关系

很多人会把协程和 std::future 混在一起。实际上:

  1. future/promise 是一种结果传递模型
  2. coroutine 是一种控制流表达模型

协程可以基于 future 实现,也可以完全不依赖 future。现代异步库通常会提供自己的 task<T>,比标准的 std::future 更适合协程整合。

常见坑

  1. 忘记 destroy,导致协程帧泄漏
  2. 句柄悬空后还 resume
  3. final_suspend 设计错误,导致结果还没取就销毁
  4. 在 await_suspend 里错误恢复,造成重入问题
  5. 捕获了引用,但协程比被引用对象活得更久
  6. 误以为 co_await 会自动切线程
  7. 把协程当线程,期待它自己并发执行

第 6 点尤其常见。co_await 只表示"可能挂起并恢复",不代表"切到后台线程"。线程切换要由你的 awaitable 或调度器决定。

学习建议

如果你要真正掌握 C++ 协程,建议按这个顺序:

  1. 先理解 co_await、co_yield、co_return 的表面语义
  2. 再理解 awaiter 三件套:await_ready、await_suspend、await_resume
  3. 然后理解 promise_type 生命周期
  4. 最后再看 task<T>、generator、scheduler 的工程实现

这样比一开始就啃完整底层规范更容易。

一句话总结

C++ Coroutines 本质上是"编译器帮你把可暂停函数变成状态机,再通过 promise_type 和 awaiter 协议把它接入异步世界"。

下面把两个都给出来:一个最小可运行的 Task<T>,一个最小可运行的 Generator<T>。前者用来理解 co_await 和结果传递,后者用来理解 co_yield 和惰性产出。

1. 最小 Task<T>

这个版本刻意只保留最核心机制:

  1. 协程创建后先挂起
  2. 外部通过 get() 驱动它跑完
  3. 也支持在别的协程里 co_await 它
  4. 能保存返回值和异常

示例代码:

cpp 复制代码
#include <coroutine>
#include <exception>
#include <iostream>
#include <optional>
#include <utility>

template <typename T>
class Task {
public:
    struct promise_type;
    using handle_type = std::coroutine_handle<promise_type>;

    struct promise_type {
        std::optional<T> value;
        std::exception_ptr exception;

        Task get_return_object() {
            return Task(handle_type::from_promise(*this));
        }

        std::suspend_always initial_suspend() noexcept {
            return {};
        }

        std::suspend_always final_suspend() noexcept {
            return {};
        }

        void return_value(T v) noexcept {
            value = std::move(v);
        }

        void unhandled_exception() noexcept {
            exception = std::current_exception();
        }
    };

    explicit Task(handle_type h) : coro_(h) {}

    Task(Task&& other) noexcept : coro_(std::exchange(other.coro_, {})) {}

    Task& operator=(Task&& other) noexcept {
        if (this != &other) {
            if (coro_) {
                coro_.destroy();
            }
            coro_ = std::exchange(other.coro_, {});
        }
        return *this;
    }

    Task(const Task&) = delete;
    Task& operator=(const Task&) = delete;

    ~Task() {
        if (coro_) {
            coro_.destroy();
        }
    }

    bool await_ready() const noexcept {
        return !coro_ || coro_.done();
    }

    void await_suspend(std::coroutine_handle<>) noexcept {
        coro_.resume();
    }

    T await_resume() {
        auto& promise = coro_.promise();
        if (promise.exception) {
            std::rethrow_exception(promise.exception);
        }
        return std::move(*promise.value);
    }

    T get() {
        while (!coro_.done()) {
            coro_.resume();
        }

        auto& promise = coro_.promise();
        if (promise.exception) {
            std::rethrow_exception(promise.exception);
        }
        return std::move(*promise.value);
    }

private:
    handle_type coro_;
};

Task<int> compute() {
    co_return 21 + 21;
}

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

int main() {
    auto task = twice();
    std::cout << task.get() << '\n';
    return 0;
}

运行结果:84

这个例子里最重要的点

  1. initial_suspend 返回 suspend_always

    协程一创建先不执行,所以你拿到的是一个"尚未启动"的任务对象。

  2. get() 里反复 resume()

    这是最小驱动方式。真实工程里通常不是手写 while,而是交给事件循环或调度器。

  3. await_suspend 里 resume 当前被等待的任务

    这里为了最小实现,直接同步推进子任务。真实异步框架通常会在这里注册 continuation,而不是直接 resume。

  4. final_suspend 返回 suspend_always

    协程结束后先停在最终挂起点,外部还能安全读取 promise 里的结果,之后再 destroy。

它的执行过程

以 twice() 为例:

  1. 调用 twice(),生成协程帧,先挂起
  2. main 调用 task.get()
  3. get() 调用 resume(),进入 twice()
  4. twice() 遇到 co_await compute()
  5. compute() 被创建,也先挂起
  6. await_suspend 里 resume compute()
  7. compute() 执行到 co_return 42,结束
  8. await_resume() 取到 42
  9. twice() 继续执行,co_return 84
  10. get() 拿到最终结果

所以这个最小 Task<T> 本质上已经把这几件事串起来了:

  1. 协程状态保存
  2. promise 保存结果
  3. handle 控制恢复
  4. co_await 对另一个 Task 取结果

这个实现故意省略了什么

  1. 没处理 void 特化
  2. 没做 continuation 链
  3. 没接线程池或事件循环
  4. await_suspend 是同步恢复,不是真异步调度
  5. 没做更严谨的多次 await / 多次 get 约束

但用来理解底层足够了。

2. 最小 Generator<T>

这个版本用来展示 co_yield。核心思路是:

  1. promise 里保存当前值
  2. 每次 co_yield 调用 yield_value
  3. yield_value 保存值并挂起
  4. 外部每次调用 next() 推进一步
  5. 用 value() 读取当前产出

示例代码:

cpp 复制代码
#include <coroutine>
#include <exception>
#include <iostream>
#include <utility>

template <typename T>
class Generator {
public:
    struct promise_type;
    using handle_type = std::coroutine_handle<promise_type>;

    struct promise_type {
        T current_value;
        std::exception_ptr exception;

        Generator get_return_object() {
            return Generator(handle_type::from_promise(*this));
        }

        std::suspend_always initial_suspend() noexcept {
            return {};
        }

        std::suspend_always final_suspend() noexcept {
            return {};
        }

        std::suspend_always yield_value(T value) noexcept {
            current_value = std::move(value);
            return {};
        }

        void return_void() noexcept {}

        void unhandled_exception() noexcept {
            exception = std::current_exception();
        }
    };

    explicit Generator(handle_type h) : coro_(h) {}

    Generator(Generator&& other) noexcept : coro_(std::exchange(other.coro_, {})) {}

    Generator& operator=(Generator&& other) noexcept {
        if (this != &other) {
            if (coro_) {
                coro_.destroy();
            }
            coro_ = std::exchange(other.coro_, {});
        }
        return *this;
    }

    Generator(const Generator&) = delete;
    Generator& operator=(const Generator&) = delete;

    ~Generator() {
        if (coro_) {
            coro_.destroy();
        }
    }

    bool next() {
        if (!coro_ || coro_.done()) {
            return false;
        }

        coro_.resume();

        if (coro_.promise().exception) {
            std::rethrow_exception(coro_.promise().exception);
        }

        return !coro_.done();
    }

    const T& value() const {
        return coro_.promise().current_value;
    }

private:
    handle_type coro_;
};

Generator<int> counter(int n) {
    for (int i = 1; i <= n; ++i) {
        co_yield i;
    }
}

int main() {
    auto gen = counter(5);
    while (gen.next()) {
        std::cout << gen.value() << ' ';
    }
    std::cout << '\n';
    return 0;
}

运行结果:1 2 3 4 5

这个例子里最重要的点

  1. co_yield i

    会转到 promise_type.yield_value(i)

  2. yield_value 返回 suspend_always

    表示"把值交出去后暂停自己,等外部下次再恢复"

  3. next()

    每调用一次,就把协程推进到下一个 co_yield 或结束点

  4. value()

    读取上一次 yield 出来的值

它的执行过程

以 counter(3) 为例:

  1. 调用 counter(3),协程先创建并挂起
  2. 第一次 next(),resume 后跑到 co_yield 1
  3. yield_value(1) 保存当前值并挂起
  4. value() 读到 1
  5. 第二次 next(),从上次暂停点继续,到 co_yield 2
  6. 重复这个过程
  7. 最后循环结束,协程到 final_suspend
  8. next() 返回 false

这就是"惰性生成"的本质:不是一开始把所有值算完,而是外部要一个,协程才往前走一步。

3. Task<T> 和 Generator<T> 的本质区别

Task<T> 更像"最终会完成一次的异步结果"。

Generator<T> 更像"可以逐个吐出多个值的数据流"。

你可以把它们对照着记:

  1. Task<T> 主要看 co_await / return_value
  2. Generator<T> 主要看 co_yield / yield_value
  3. Task<T> 通常产出一个最终结果
  4. Generator<T> 可以产出很多次中间结果

4. 为什么这两个例子能帮助理解协程

因为它们分别覆盖了两条最核心的协程路径:

  1. Task<T>

    让你看到 promise 怎么保存结果,handle 怎么 resume,co_await 怎么取值。

  2. Generator<T>

    让你看到协程怎么在多个挂起点之间反复暂停和恢复。

如果把这两个吃透,C++ 协程底层已经掌握了大半。

5. 实战里还会再补什么

如果你继续往工程实现走,下一步通常会加这些能力:

  1. Task<void> 和 Task<T> 的完整特化
  2. continuation,避免 await_suspend 里直接同步 resume
  3. 线程池、事件循环、timer、socket 的 awaitable
  4. 更安全的结果存储和生命周期管理
  5. generator 的迭代器接口,做成 range 风格

一句话记忆

Task<T> 是"等最终结果",Generator<T> 是"按次产出值"。

相关推荐
王老师青少年编程8 小时前
csp信奥赛C++高频考点专项训练之前缀和&差分 --【一维前缀和】:求区间和
c++·前缀和·csp·高频考点·信奥赛·求和区间和
kyle~9 小时前
机器人时间链路---工程流程示例
c++·3d·机器人·ros2
汉克老师11 小时前
GESP6级C++考试语法知识(十七、数据结构(三、认识队列 Queue))
数据结构·c++·队列·gesp6级·gesp六级·数组模拟队列
j_xxx404_13 小时前
Linux进程信号捕捉与操作系统运行本质深度解析
linux·运维·服务器·开发语言·c++·人工智能·ai
vx-程序开发13 小时前
基于机器学习的动漫可视化系统的设计与实现-计算机毕业设计源码08339
java·c++·spring boot·python·spring·django·php
啊董dong14 小时前
noi-2026年5月12号小测验
数据结构·c++·算法
咩咦15 小时前
C++学习笔记24:构造函数初始化列表
c++·学习笔记·类和对象·构造函数·初始化列表·const引用
计算机安禾15 小时前
【c++面向对象编程】第43篇:可变参数模板(C++11):优雅处理不定长参数
java·开发语言·c++
10岁的博客15 小时前
C++ 进制转换:通用 a 进制转 b 进制(2-36进制)题解
开发语言·c++