71、STL与协程:协程概念、协程实现、协程与线程对比

协程这个话题,说实话在C++社区里火了好几年了。C++20正式把协程纳入标准后,我身边不少老同事都开始重新审视自己的异步代码。今天我们就来聊聊协程到底是什么,怎么用,以及它和线程到底有什么区别。

一、协程是什么?一个直观的理解

协程,说白了就是可以暂停和恢复的函数。普通函数一旦调用,就得从头跑到尾,中间不能停。但协程不一样——它可以在执行到某个点时主动让出控制权,等条件满足了再回来接着跑。

我刚开始接触这个概念时,总觉得它和回调函数差不多。后来在项目中写了一个复杂的异步网络库,回调嵌套了五六层,代码完全没法维护。那时候我才真正体会到协程的价值——它让异步代码写起来像同步代码一样直观。

核心要点:协程是一种更轻量级的并发原语,它允许函数在执行过程中暂停,并在之后恢复执行。

二、C++20协程的基本实现

C++20的协程实现,说实话有点复杂。它不像Python那样用yield关键字就完事了,而是提供了一套底层机制,让你可以自己定制协程的行为。这里我给大家拆解一下核心组件。

2.1 协程的三个关键对象

每个协程背后都有三个关键对象在协同工作:

  • promise_type:管理协程的状态,决定协程返回什么值
  • awaiter:控制协程何时暂停、何时恢复
  • coroutine_handle:协程的句柄,用来手动控制协程的暂停和恢复

来看一个最简单的协程示例:

#include <coroutine>
#include <iostream>

struct SimpleTask {
    struct promise_type {
        SimpleTask get_return_object() {
            return SimpleTask{
                std::coroutine_handle<promise_type>::from_promise(*this)
            };
        }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    std::coroutine_handle<promise_type> handle;
};

SimpleTask myCoroutine() {
    std::cout << "第一步:开始执行\n";
    co_await std::suspend_always{};
    std::cout << "第二步:恢复执行\n";
}

int main() {
    auto task = myCoroutine();
    std::cout << "主线程继续工作\n";
    task.handle.resume();
    return 0;
}

这段代码里,co_await std::suspend_always{} 让协程在第一次执行后暂停。主线程打印完消息后,通过 handle.resume() 恢复协程。输出结果是:

第一步:开始执行
主线程继续工作
第二步:恢复执行

我的经验:刚开始写协程时,我建议先别急着搞复杂的promise_type。先用std::suspend_alwaysstd::suspend_never把暂停/恢复的流程跑通,再逐步定制。

2.2 协程的生命周期

协程的执行流程其实很清晰:

  1. 调用协程函数,创建promise对象
  2. 执行initial_suspend,决定是否立即暂停
  3. 执行协程体,遇到co_await时暂停
  4. 外部通过coroutine_handle恢复执行
  5. 协程执行完毕,执行final_suspend

嗯,这里要注意:final_suspend如果返回suspend_never,协程结束后会自动销毁。如果返回suspend_always,你需要手动调用handle.destroy(),否则会内存泄漏。我曾经在这个坑里栽过一次,排查了半天才发现是协程句柄没释放。

三、协程与线程的对比

很多初学者会把协程和线程搞混。其实它们的定位完全不同。我整理了一个对比表,方便大家理解:

对比维度 协程 线程
调度方式 用户态主动让出(协作式) 内核态抢占式调度
切换开销 极低(仅保存寄存器) 较高(涉及内核上下文切换)
内存占用 栈可极小(KB级别) 默认栈大小(MB级别)
并发能力 单线程内可创建上万个 受限于CPU核心数和内存
数据竞争 无需锁(单线程内切换) 需要锁或原子操作
适用场景 IO密集型、异步任务 CPU密集型、并行计算

你想想看,线程切换一次,光内核态到用户态的切换就要几百个CPU周期。而协程切换,说白了就是保存几个寄存器的值,几十个周期就搞定了。这就是为什么协程能轻松创建上万个,而线程到几千个就开始吃力了。

避坑指南:我曾经在一个项目中用线程池处理大量网络连接,结果线程数一上去,CPU全花在上下文切换上了。后来改用协程,同样的硬件配置,吞吐量提升了3倍多。但要注意,协程不适合CPU密集型的计算任务——它只是让等待不阻塞,并不能让计算变快。

四、协程的实际应用场景

协程最典型的应用场景就是异步IO。比如网络请求、文件读写、数据库查询这些需要等待的操作。用协程写出来的代码,比回调函数清晰得多。

来看一个模拟的异步读取示例:

struct AsyncReader {
    struct promise_type {
        // ... 省略promise实现
    };
    
    bool await_ready() { return false; }
    void await_suspend(std::coroutine_handle<> h) {
        // 模拟异步IO,启动后立即返回
        std::thread([h]() {
            std::this_thread::sleep_for(std::chrono::seconds(1));
            h.resume();  // IO完成后恢复协程
        }).detach();
    }
    std::string await_resume() { return "读取完成的数据"; }
};

AsyncReader asyncRead() {
    std::string data = co_await AsyncReader{};
    std::cout << data << std::endl;
}

这段代码里,co_await 让协程暂停,等后台线程读取完数据后再恢复。主线程在此期间可以去做其他事情。这就是协程的魅力——用同步的写法,实现异步的效果。

五、协程与STL的配合

C++20的协程和STL配合得还不错。比如你可以用std::generator(C++23引入)来生成惰性序列:

#include <generator>
#include <ranges>

std::generator<int> fibonacci() {
    int a = 0, b = 1;
    while (true) {
        co_yield a;
        auto next = a + b;
        a = b;
        b = next;
    }
}

int main() {
    for (int n : fibonacci() | std::views::take(10)) {
        std::cout << n << " ";
    }
    // 输出:0 1 1 2 3 5 8 13 21 34
}

我个人习惯用co_yield来生成数据流,配合std::views做管道操作,代码非常简洁。不过要注意,std::generator目前只在C++23中可用,如果你的编译器还不支持,可以用第三方库如cppcoro替代。

六、协程的注意事项

最后说几个我踩过的坑:

  • 协程对象必须保持存活:协程暂停后,如果协程对象被销毁,再恢复就是未定义行为。我建议用智能指针管理协程句柄。
  • 不要在协程中持有锁:协程暂停时不会释放锁,容易造成死锁。如果必须加锁,考虑用异步锁。
  • 注意栈溢出:协程默认使用小栈,递归调用要小心。我遇到过递归深度超过1000层导致栈溢出的情况。
  • 调试比较困难:协程的调用栈在暂停和恢复时会断裂,调试器可能看不到完整的调用链。建议多打日志。

总结一下:协程不是线程的替代品,而是互补品。线程负责并行计算,协程负责异步等待。把两者结合起来,才能写出高性能的C++程序。

协程核心概念与执行流程 协程函数调用 initial_suspend 暂停 立即执行 协程体执行 co_await 暂停 resume() 恢复 final_suspend / 结束

这张图展示了协程从创建到结束的完整流程。你可以看到,co_await 是协程的核心——它让协程暂停,等外部条件满足后再恢复。整个过程都在用户态完成,没有内核参与,所以效率极高。

好了,关于协程的内容就聊到这里。记住一句话:协程让异步代码回归同步写法,线程让并行计算变得简单。两者各司其职,才是C++并发编程的正确打开方式。


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