13、C++20协程入门:协程概念与优势、co_await/co_return/co_yield、promise_type与awaiter、协程与线程的对比

说实话,C++20 的协程刚出来那会儿,我第一反应是:又来新东西了?

但真正用上之后,我得承认——这东西确实香。它不像线程那样动不动就给你整个上下文切换的开销,也不像回调那样把代码写成金字塔。协程,说白了就是让你用写同步代码的方式,去处理异步逻辑。

这一章,咱们就把它掰开揉碎,看看协程到底是个什么玩意儿。

协程是什么?一个简单的定义

协程是一个可以暂停、可以恢复的函数。你想想看,普通函数一旦调用,就得从头跑到尾,中间不能停。但协程不一样——它可以在某个点停下来,把控制权交出去,等条件满足了再回来接着跑。

我习惯把协程比作一个「会喘气的函数」。它跑累了可以歇会儿,歇够了再继续。这个特性在 I/O 密集型场景下特别有用。

核心区别一句话:线程是抢占式调度,协程是协作式调度。协程自己决定什么时候让出 CPU,而不是被操作系统强行打断。

协程的三个关键字:co_await、co_return、co_yield

这三个关键字是协程的「三驾马车」。咱们一个一个说。

co_await:等待一个异步操作

co_await 是协程里最常用的关键字。它的意思是:我在这里等一个结果,但我不阻塞线程。等结果回来了,再继续往下走。

我在项目中遇到过这样一个场景:需要从网络读取数据,然后解析。如果用传统方式,要么阻塞线程,要么写回调。用 co_await 就简单多了:

// 伪代码,展示 co_await 的用法
Task<std::string> fetchData() {
    auto request = makeHttpRequest("https://api.example.com/data");
    // 发起请求,但不阻塞线程
    auto response = co_await request;
    // 等响应回来了,继续执行
    co_return parseResponse(response);
}

你看,代码是顺序写的,但执行是异步的。这就是协程的魅力。

co_return:返回结果

co_return 就是协程版的 return。但它做的事情比普通 return 多——它会触发 promise_type 的 return_value 方法,把结果存起来,然后通知等待者。

小提示:协程里不能同时用 co_return 和 co_yield。要么返回一个值,要么生成一系列值,二选一。

co_yield:生成一个值

co_yield 用于生成器场景。它把当前值抛出去,然后暂停自己,等调用者要下一个值的时候再恢复。

// 一个简单的生成器
Generator<int> range(int start, int end) {
    for (int i = start; i < end; ++i) {
        co_yield i;  // 每次生成一个值
    }
}

// 使用
for (auto v : range(0, 5)) {
    std::cout << v << " ";  // 输出 0 1 2 3 4
}

promise_type 与 awaiter:协程的幕后英雄

这两个东西是协程的「基础设施」。你平时写代码可能不直接碰它们,但理解它们的工作原理,能帮你写出更靠谱的协程代码。

promise_type:协程的「管家」

每个协程都有一个关联的 promise_type。它负责管理协程的状态——协程什么时候开始、什么时候结束、返回值怎么存、异常怎么处理。

我刚开始学协程时,被 promise_type 搞得一头雾水。后来我把它理解成「协程的构造函数和析构函数」——它定义了协程的生命周期。

// 一个最简单的 promise_type 示例
struct TaskPromise {
    // 协程开始时的行为
    std::suspend_never initial_suspend() { return {}; }
    // 协程结束时的行为
    std::suspend_never final_suspend() noexcept { return {}; }
    // 处理 co_return
    void return_void() {}
    // 处理异常
    void unhandled_exception() { std::terminate(); }
    // 返回协程对象
    Task get_return_object() { return Task{this}; }
};

awaiter:控制等待行为

awaiter 决定了 co_await 具体怎么「等」。它有三个方法:

  • await_ready:检查结果是否已经准备好了。如果准备好了,就不暂停,直接继续。
  • await_suspend:如果没准备好,这个函数会被调用。它决定协程暂停后做什么——比如注册回调、启动异步操作。
  • await_resume:协程恢复时,这个函数的返回值就是 co_await 表达式的结果。

注意:await_suspend 的返回值类型很重要。返回 void 表示无条件暂停;返回 bool 表示根据条件决定是否暂停;返回其他协程的句柄,可以实现协程间的跳转。

协程与线程的对比

很多人刚接触协程时,会问一个问题:协程和线程到底有什么区别?

嗯,这个问题我当年也问过。咱们直接上表格对比:

对比维度 线程 协程
调度方式 抢占式(操作系统决定) 协作式(协程自己决定)
切换开销 高(涉及内核态切换) 极低(用户态切换)
并发数量 受限于系统资源(通常几千) 可以轻松达到数十万
数据共享 需要锁或原子操作 通常不需要锁(单线程内)
适用场景 CPU 密集型、并行计算 I/O 密集型、高并发网络服务

说白了,线程适合「算」,协程适合「等」。如果你的程序大部分时间在等 I/O(网络、磁盘、数据库),协程是更好的选择。

协程的优势:为什么值得学?

我总结了几点,都是我在实际项目中体会到的:

  • 代码简洁:用同步的方式写异步代码,不用回调嵌套,不用状态机。
  • 性能好:协程切换只保存几个寄存器,比线程切换快两个数量级。
  • 内存省:每个协程的栈可以很小(甚至无栈),百万协程不是梦。
  • 组合性强:可以轻松实现超时、取消、错误处理等复杂逻辑。

避坑指南:我曾经在项目里用协程处理大量短连接,结果发现协程对象创建销毁的开销也不小。后来我用了协程池,把协程复用起来,性能才上去。记住:协程不是银弹,该优化的地方还是得优化。

知识体系结构图

下面这张图展示了协程的核心概念和它们之间的关系:

C++20 协程 co_await co_return co_yield promise_type 管理协程生命周期 awaiter 控制等待行为 线程(抢占式) 高开销,适合CPU密集 协程(协作式) 低开销,适合I/O密集 协程 = 可暂停的函数 + 用户态调度

一个完整的协程示例

最后,咱们看一个完整的例子,把上面这些概念串起来:

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

// 一个简单的协程返回类型
struct Task {
    struct promise_type {
        Task get_return_object() { return Task{}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
};

// 一个可等待对象
struct SleepAwaiter {
    int ms;
    
    bool await_ready() { return false; }
    
    void await_suspend(std::coroutine_handle<> handle) {
        std::thread([handle, this]() {
            std::this_thread::sleep_for(std::chrono::milliseconds(ms));
            handle.resume();  // 恢复协程
        }).detach();
    }
    
    void await_resume() {}
};

// 协程函数
Task myCoroutine() {
    std::cout << "协程开始" << std::endl;
    co_await SleepAwaiter{1000};  // 等待1秒,但不阻塞线程
    std::cout << "协程恢复" << std::endl;
}

int main() {
    myCoroutine();
    std::this_thread::sleep_for(std::chrono::seconds(2));
    return 0;
}

这个例子虽然简单,但包含了协程的完整流程:创建协程、暂停、等待、恢复、结束。你在实际项目中遇到的协程,本质上都是这个模式的变体。

总结一下:协程不是用来替代线程的,而是用来补充线程的。线程负责「算」,协程负责「等」。两者配合使用,才能写出高性能的并发程序。


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