协程(Coroutines)基础:C++20协程的概念、co_await、co_yield与co_return、协程与线程的对比

聊到协程,我得先坦白一件事。几年前我第一次看到 C++20 协程提案时,心里想的是:「这玩意儿不就是个语法糖吗?」直到我在一个高并发网络服务里,被成千上万的线程切换开销折磨得睡不着觉,才真正意识到——协程这东西,是来救命的。

好,咱们今天就把协程这层窗户纸捅破。你不需要一开始就理解所有细节,跟着我的节奏走就行。

什么是协程?一个直觉上的理解

协程,说白了就是「可以暂停和恢复的函数」。普通函数一旦调用,就得一口气执行完。协程不一样——它可以在中间停下来,把控制权交出去,等条件满足了再回来接着跑。

你想想看,这像什么?像不像一个会「让路」的函数?

我在项目中遇到过这样的场景:一个网络请求过来,需要读数据库、调远程服务、做计算。如果用线程,每个请求占一个线程,1000 个并发就是 1000 个线程。线程切换、栈空间、锁竞争……系统很快就扛不住了。换成协程,一个线程可以同时处理成千上万个「半路暂停」的任务。这就是协程的魅力。

核心区别一句话: 线程是操作系统调度的,协程是程序员调度的。协程的切换开销,比线程小一到两个数量级。

C++20 协程的核心概念

C++20 的协程不是库,而是一套语言机制。它提供了三个关键字:co_awaitco_yieldco_return。别被它们吓到,咱们一个一个拆。

co_await:等待一个异步操作

co_await 是协程的灵魂。它的作用是:暂停当前协程,等待某个操作完成,然后恢复执行

举个例子。假设你要从网络读取数据:

// 伪代码,展示 co_await 的语义
Task<std::string> fetchData() {
    std::string data = co_await asyncRead("https://example.com");
    // 这里 data 已经准备好了
    co_return data;
}

当执行到 co_await asyncRead(...) 时,协程会暂停。它不会阻塞线程,而是把线程让出来给别的协程用。等数据读完了,协程再恢复执行。整个过程,线程一直在干活,没有空转。

我的经验: 刚开始用 co_await 时,我总担心「暂停了会不会丢数据?」不会。协程的局部变量和状态都保存在堆上(通过 promise 对象),暂停时全部保留,恢复时原样取回。

co_yield:生成一个值,然后暂停

co_yield 用于「生成器」场景。它返回一个值给调用者,然后暂停自己,等待下一次被唤醒。

我记得有一次写一个数据流处理模块,需要从一个大文件里逐行读取并处理。如果用普通函数,得一次性读完整个文件,内存扛不住。用 co_yield 就优雅多了:

Generator<std::string> readLines(const std::string& filename) {
    std::ifstream file(filename);
    std::string line;
    while (std::getline(file, line)) {
        co_yield line;  // 返回一行,暂停
    }
}

// 使用
for (auto& line : readLines("bigfile.txt")) {
    process(line);  // 每次只处理一行
}

这里 co_yield line 做了两件事:把 line 返回给调用者,然后暂停协程。下次循环进来时,从暂停的地方继续,读取下一行。

co_return:结束协程并返回值

co_return 就是协程的 return。它标志着协程执行完毕,同时把最终结果返回给调用者。

注意,协程里不能同时用 returnco_return。一旦函数体内出现了 co_awaitco_yieldco_return 中的任何一个,这个函数就自动变成了协程。

Task<int> compute() {
    int a = co_await fetchValue();
    int b = co_await fetchValue();
    co_return a + b;
}

协程与线程的对比

好,咱们来做个对比。我直接画了张图,帮你理清思路。

协程 vs 线程:核心对比 线程 • 由操作系统内核调度 • 切换成本高(上下文切换) • 每个线程有独立栈(MB级) • 适合 CPU 密集型任务 • 并发数受限于系统资源 • 抢占式调度 • 需要锁、条件变量同步 协程 • 由程序员/用户态调度 • 切换成本极低(函数调用级) • 共享栈或极小栈(KB级) • 适合 I/O 密集型任务 • 轻松支持数十万并发 • 协作式调度(co_await 让出) • 无需锁,天然避免竞态 选择依据:I/O 密集用协程,CPU 密集用线程,两者常配合使用

这张图我画得比较直白。你看,线程和协程不是替代关系,而是互补关系。我个人的习惯是:I/O 操作用协程,计算操作用线程。比如一个网络服务器,接收请求、读写数据库这些 I/O 密集的事交给协程;而图像处理、加密解密这些 CPU 密集的事,还是老老实实扔到线程池里。

一个完整的协程示例

光说不练假把式。咱们写一个能跑起来的例子,感受一下协程的完整流程。

#include <coroutine>
#include <iostream>
#include <thread>
#include <chrono>

// 一个极简的 Task 类型
struct Task {
    struct promise_type {
        int value;
        Task get_return_object() { return Task{*this}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void unhandled_exception() { std::terminate(); }
        std::suspend_always yield_value(int v) {
            value = v;
            return {};
        }
        void return_value(int v) { value = v; }
    };
    
    std::coroutine_handle<promise_type> handle;
    explicit Task(promise_type& p) : handle(std::coroutine_handle<promise_type>::from_promise(p)) {}
    ~Task() { if (handle) handle.destroy(); }
    
    int get() { return handle.promise().value; }
};

// 一个简单的协程
Task simpleCoroutine() {
    std::cout << "协程开始\n";
    co_yield 42;
    std::cout << "协程恢复\n";
    co_yield 100;
    std::cout << "协程即将结束\n";
    co_return 200;
}

int main() {
    auto coro = simpleCoroutine();
    std::cout << "第一次 yield 的值: " << coro.get() << "\n";
    coro.handle();  // 恢复协程
    std::cout << "第二次 yield 的值: " << coro.get() << "\n";
    coro.handle();  // 再次恢复
    std::cout << "最终返回值: " << coro.get() << "\n";
    return 0;
}

这个例子虽然简单,但把 co_yieldco_return 的配合展示得很清楚。你运行一下就会发现,协程真的可以在中间停下来,等外面处理完了再回来继续跑。

曾经踩过的坑: 协程的 promise_type 是必须的,但很多初学者会忘记定义它。编译器报错时,错误信息又长又绕。我的建议是:先抄一个能跑的模板,理解了再自己写。别一开始就想着手撸全部。

什么时候用协程?什么时候用线程?

这个问题我经常被问到。我的回答是:

  • 用协程的场景: 网络请求、文件读写、数据库查询、消息队列消费——总之,任何需要「等」的地方,都适合用协程。
  • 用线程的场景: 图像渲染、视频编码、科学计算、游戏物理引擎——这些需要「算」的地方,线程更合适。
  • 两者结合: 一个典型的架构是:协程处理 I/O 和业务逻辑,线程池处理计算密集型任务。协程把计算结果通过 co_await 交给线程池,线程池算完再唤醒协程。

嗯,这里要注意一点:协程不是银弹。如果你的任务全是 CPU 密集型的,用协程反而会增加复杂度,没有任何收益。我见过有人把加密算法写成协程,结果性能还不如普通函数——因为协程的暂停恢复也是有开销的,虽然很小,但架不住你频繁调用。

小结

协程是 C++20 给并发编程带来的一股清流。它让异步代码写起来像同步代码一样直观,同时又避免了线程切换的高昂代价。我个人觉得,未来五年,协程会成为 C++ 后端开发者的标配技能,就像今天大家都会用智能指针一样。

别急,协程的生态还在完善中。标准库里的 std::generator 在 C++23 才正式落地,第三方库像 cppcorolibunifex 也各有千秋。但核心概念——co_awaitco_yieldco_return——是不会变的。把这几个关键字玩熟了,后面学什么库都顺手。


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