一、C20协程:从函数到可暂停的计算单元

说实话,我第一次接触C20协程时,脑子里冒出的第一个念头是——这不就是函数版的"暂停键"吗?

我们平时写的函数,一旦调用,就得从头跑到尾。但协程不一样。它可以在执行中途停下来,把控制权交回去,然后过一会儿再回来接着跑。嗯,就像你写代码写到一半,去泡杯咖啡,回来继续写一样。

我个人习惯把协程理解成"有状态的函数"。普通函数每次调用都是全新的开始,局部变量重新分配。但协程能记住上次停在哪,局部变量还在不在?这就要说到协程帧了。

1.1 协程 vs 普通函数:核心区别

特性 普通函数 协程
执行方式 一次性执行到底 可暂停、可恢复
栈帧生命周期 调用时创建,返回时销毁 可能跨多次调用存活
局部变量 每次调用重新分配 在协程帧中持久保存
返回方式 return co_return / co_yield / co_await

你看,协程本质上是一种"可挂起的函数"。它用三个新关键字来管理生命周期:co_awaitco_yieldco_return。这三个家伙,就是协程的"暂停、产出、结束"三件套。

二、三个核心关键字:co_await / co_yield / co_return

2.1 co_await:等待一个可挂起的操作

co_await 是协程里最常用的关键字。它的作用是:挂起当前协程,等待某个操作完成,然后恢复执行。

我在项目中遇到过这样一个场景:需要从多个传感器异步读取数据。如果用传统回调,代码会碎成一地。用 co_await 就清爽多了——

// 伪代码:协程方式读取传感器
Task<SensorData> readSensor() {
    // 发起异步读取,挂起等待
    auto raw = co_await async_read(sensor_pin);
    // 恢复后继续处理
    SensorData result = process(raw);
    co_return result;
}

这里 co_await async_read(...) 会触发挂起。等数据准备好了,协程自动恢复,接着往下走。你想想看,这不就是"同步写法,异步执行"吗?

我的经验:co_await 后面跟的对象必须实现 Awaitable 接口(有 await_ready、await_suspend、await_resume 三个方法)。刚开始写容易忘,我建议你从标准库的 std::suspend_always 和 std::suspend_never 入手。

2.2 co_yield:产出值,但不结束

co_yield 有点像生成器里的 yield。它把当前值"吐"出去,然后挂起自己,等下次被唤醒再继续。

说白了,co_yield 就是"我还没干完,先给你一个中间结果"。

Generator<int> fibonacci() {
    int a = 0, b = 1;
    while (true) {
        co_yield a;   // 产出当前值,挂起
        int next = a + b;
        a = b;
        b = next;
    }
}

调用方每次取一个值,协程就推进一次。这种"惰性求值"的模式,在处理无限序列或流式数据时特别有用。

2.3 co_return:结束协程

co_return 就是协程的 return。它表示协程执行完毕,返回最终结果。

注意:一旦协程里出现了 co_return,就不能再用普通的 return 了。这是编译器强制的规则。

Task<int> compute() {
    int sum = 0;
    for (int i = 0; i < 10; ++i) {
        sum += i;
        co_await step();  // 每步都挂起一下
    }
    co_return sum;  // 最终结果
}
我曾经踩过的坑:在协程里混用 return 和 co_return 会导致编译错误。另外,如果协程没有 co_return,但函数体里用了 co_await 或 co_yield,编译器会默认在末尾插入 co_return;(对于返回 void 的协程)。

三、协程帧与栈管理:协程的"记忆体"

协程能暂停和恢复,靠的是什么?答案是——协程帧(coroutine frame)。

普通函数的局部变量存在栈上,函数返回栈帧就销毁了。但协程不一样。它暂停时,局部变量不能丢,得找个地方存起来。这个"地方"就是堆上分配的协程帧。

3.1 协程帧里有什么?

  • 局部变量:所有在协程体内定义的自动变量
  • 挂起点信息:记录上次执行到哪一行,恢复时从哪继续
  • promise 对象:协程的"管家",管理协程状态和返回值
  • 参数拷贝:协程参数会拷贝到帧里(按值传递)

说白了,协程帧就是协程的"记忆体"。它让协程在挂起后,所有状态都完好无损。

3.2 栈管理:堆分配与优化

协程帧默认在堆上分配。这意味着每次创建协程,都可能有一次 malloc。高频创建协程时,这会是性能瓶颈。

我个人的建议是:

  • 对性能敏感的场景,考虑使用自定义分配器(通过 promise_type 的 operator new)
  • 避免在热路径上频繁创建和销毁协程
  • 协程帧的大小在编译期就确定了,所以分配开销是可控的
// 自定义分配器示例
struct MyPromise {
    // 自定义协程帧分配
    void* operator new(size_t size) {
        return my_pool_alloc(size);
    }
    void operator delete(void* ptr, size_t size) {
        my_pool_free(ptr, size);
    }
    // ... 其他必要方法
};
关键理解:协程帧的生命周期独立于调用栈。它可能比调用它的函数活得更久(比如被移动到其他线程恢复)。这也是协程能实现"异步非阻塞"的根本原因。

四、知识体系总览

下面这张图,是我梳理的协程核心知识结构。你可以把它当作学习路线图——

C20 协程核心 co_await co_yield co_return 协程帧 (Coroutine Frame) 局部变量 挂起点信息 Promise 对象 栈管理:堆分配 + 自定义分配器

五、避坑指南与个人经验

讲到这里,我想分享几个实际项目中遇到的教训——

  • 协程帧的生命周期管理:我曾经在协程里捕获了一个局部变量的引用,结果协程挂起后,那个变量所在的栈帧已经被销毁了。恢复时访问悬空引用,程序直接崩溃。记住:协程帧里保存的是参数的拷贝,不是引用。
  • 不要滥用 co_await:有些开发者喜欢在每个函数前都加 co_await,觉得这样"更异步"。其实没必要。只有真正需要挂起的操作才用 co_await。否则只会增加协程帧分配的开销。
  • 协程不是线程:协程是"可挂起的函数",不是并发执行单元。它可以在单线程里实现协作式多任务,但不会利用多核。如果你需要并行,还是得用线程。
调试小技巧:协程的调用栈在挂起时会被截断,调试器可能看不到完整的调用链。我习惯在关键挂起点加日志,打印协程 ID 和当前状态。这样排查问题会快很多。

好了,关于 C20 协程的基本概念,我们就聊到这里。co_await 让你能等待异步操作,co_yield 让你能产出中间值,co_return 用来结束协程。而协程帧,就是这一切能实现的"幕后功臣"。

下次写异步代码时,不妨试试协程。你会发现,原来"暂停"和"恢复"可以这么优雅。


公众号:蓝海资料掘金营,微信 deep3321