协程实战:生成器与异步I/O、协程的调度与性能、协程在并发编程中的应用

聊到协程,很多人第一反应是「哦,就是那个能暂停的函数」。嗯,这么说也没错,但太浅了。我在项目中真正开始大规模用协程,是因为一个很现实的问题——我们有个数据管道,既要处理大量I/O,又要保持代码可读性。用回调?回调地狱。用线程?上下文切换太贵。最后,协程成了那个「刚刚好」的答案。

生成器:协程的「最小原型」

其实C++20的协程,底层机制和生成器是一脉相承的。你想想看,生成器能yield暂停、能resume继续,这不就是协程的核心吗?

我习惯把生成器看作协程的「教学版本」。它让你直观感受到:函数可以暂停,暂停后可以恢复,恢复后还能接着上次的位置继续跑

核心要点:生成器通过co_yield返回值,通过co_return结束。每次co_yield都会把控制权交还给调用者,同时保存当前状态。

#include <generator>
#include <ranges>

std::generator<int> fibonacci(int n) {
    int a = 0, b = 1;
    for (int i = 0; i < n; ++i) {
        co_yield a;  // 暂停,返回a
        auto next = a + b;
        a = b;
        b = next;
    }
}

int main() {
    for (int val : fibonacci(10)) {
        // 每次循环,协程从上次co_yield处恢复
        std::cout << val << " ";
    }
}

你看,这个生成器每次yield一个值,调用者消费完,它再继续算下一个。我在做流式数据处理时,经常用这种模式——数据是一批一批到的,我不想一次性全加载到内存里。

我的经验:生成器特别适合「懒加载」场景。比如读取一个大文件,逐行处理,用生成器包装读取逻辑,调用方只管for循环,清爽得很。

异步I/O:协程的真正战场

生成器只是开胃菜。协程真正大显身手的地方,是异步I/O。我曾经维护过一个网络库,早期用回调+状态机,代码写得跟迷宫一样。后来改成协程,逻辑直接变成顺序的了——读、处理、写,三行搞定。

为什么会这样?因为协程让异步代码看起来像同步代码。你不需要手动管理状态,不需要把「下一步做什么」塞进回调里。

// 伪代码,展示协程异步I/O的直观性
task<void> handle_connection(tcp::socket sock) {
    char buf[1024];
    // 看起来是同步读,实际上是异步等待
    auto n = co_await sock.async_read_some(buffer(buf));
    
    std::string response = process(buf, n);
    
    // 又是异步写,但写起来像同步
    co_await async_write(sock, buffer(response));
    
    // 协程结束,自动清理
}

嗯,这里要注意:co_await 不是阻塞。它只是告诉调度器「我现在没事干,你先跑别的协程,等数据到了再叫我」。这背后的调度机制,才是协程性能的关键。

协程的调度与性能

说到调度,我得先泼盆冷水:协程本身不是银弹。它的性能优势,取决于你如何调度它。

我见过有人把协程当线程用——开几千个协程,每个里面都做CPU密集计算。结果呢?比线程还慢。因为协程的切换虽然轻量,但毕竟不是零成本。

调度方式 适用场景 性能特点
单线程调度器 I/O密集型,少量计算 无锁,切换极快,但无法利用多核
多线程工作窃取 混合负载,需要多核 有锁开销,但能均衡负载
专用调度器 实时性要求高 可定制优先级,但实现复杂

我个人习惯的做法是:I/O密集型用单线程调度器,计算密集型老老实实开线程池。协程和线程不是替代关系,是互补关系。

我曾经踩过的坑:在协程里做了个耗时1秒的CPU计算,结果整个调度器卡死了。因为单线程调度器里,所有协程共享一个线程,一个协程阻塞,其他全等着。后来我把计算任务扔到线程池里,用co_await等待结果,问题就解决了。

协程在并发编程中的应用

协程在并发编程里,最典型的应用就是「协作式多任务」。说白了,就是每个任务主动让出CPU,而不是被操作系统强行打断。

我参与过一个实时数据聚合系统,需要同时从多个数据源拉取数据,然后合并、去重、输出。用线程的话,每个数据源一个线程,光同步就够头疼的。用协程呢?每个数据源一个协程,共享一个调度器,数据到了就处理,没到就让出。

task<void> data_pipeline() {
    // 同时启动三个数据源协程
    auto src1 = fetch_from_source("source1");
    auto src2 = fetch_from_source("source2");
    auto src3 = fetch_from_source("source3");
    
    // 等待所有数据源完成
    auto [r1, r2, r3] = co_await when_all(src1, src2, src3);
    
    // 合并处理
    auto result = merge(r1, r2, r3);
    co_await output(result);
}

你看,when_all 这个模式,在协程世界里就是「并发等待」。三个协程同时跑,谁先完成谁先让出,调度器自动切换。这比手动管理线程优雅太多了。

关键认知:协程的并发是「逻辑并发」,不是「物理并发」。在单线程调度器下,同一时刻只有一个协程在跑,但多个协程可以交替执行,看起来就像并发一样。这种模式特别适合I/O等待多的场景。

知识体系总览

下面这张图,是我对协程在并发编程中定位的理解。你可以把它当作一个「决策地图」——遇到并发问题时,先看是I/O密集还是CPU密集,再决定用协程还是线程。

协程在并发编程中的定位与决策 并发任务 I/O密集型 CPU密集型 推荐:协程 + 单线程调度器 优势:低开销切换,高并发I/O 推荐:线程池 + 协程辅助 优势:充分利用多核,避免阻塞 核心原则:I/O用协程,计算用线程,两者结合才是王道

说实话,协程在C++里还是个「年轻」的特性。C++20只是开了个头,C++23和C++26还在继续完善。但我觉得,现在开始用完全没问题——标准库的std::generatorstd::task(虽然还在提案中),以及各种第三方库(比如cppcoro),已经足够支撑生产级应用了。

我的建议:从生成器开始练手,然后尝试用协程包装一个异步I/O操作,最后再考虑自己写调度器。别一上来就搞复杂的调度框架,容易把自己绕进去。

好了,协程这部分就聊到这儿。记住一句话:协程不是用来替代线程的,是用来让异步代码更好写的。你想想看,如果能把回调地狱变成顺序代码,把状态机变成普通函数,那开发效率的提升可不是一星半点。


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