一、协程到底是什么?

协程这个概念,其实我最早接触是在 Lua 和 Python 里。那时候做游戏服务器,用 Lua 的协程写状态机,简直爽到飞起。后来 C++20 终于原生支持了,说实话,我等这一天等了很久。

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

你想想看,这像什么?像不像一个可以手动切换的线程?但协程比线程轻量得多。线程切换要内核态,协程切换就是用户态的一次函数调用。我在项目中测过,百万级协程的创建和调度,内存开销远小于同等数量的线程。

核心区别一句话:线程是抢占式调度,协程是协作式调度。协程自己决定什么时候让出 CPU。

二、C++20 协程语法:三个关键字

C++20 的协程语法,说实话,刚看的时候我也觉得有点绕。它不像 Go 的 go 关键字那么直观。C++ 选择了零开销抽象的路线,把底层机制暴露给了你。

三个核心关键字:

  • co_await —— 挂起当前协程,等待某个操作完成
  • co_return —— 从协程返回一个值
  • co_yield —— 产生一个值,然后挂起(类似生成器)

但光有这三个关键字还不够。你需要自己实现一个承诺对象(promise_type)和一个可等待对象(Awaitable)。嗯,这里要注意,C++20 的协程是框架无关的,标准库只提供了底层机制,没有提供高级封装。

一个最简单的协程例子

#include <coroutine>
#include <iostream>

struct Task {
    struct promise_type {
        Task get_return_object() { return {}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
};

Task myCoroutine() {
    std::cout << "Hello from coroutine!\n";
    co_return;
}

int main() {
    myCoroutine();
    return 0;
}

这段代码跑起来,你会看到输出。但说实话,这个协程跟普通函数没啥区别,因为它没有挂起。真正的威力在 co_await 上。

co_await 的工作原理

当你写 co_await expr 时,编译器会做三件事:

  1. 检查 expr 是否有 await_ready() 方法。如果返回 true,说明操作已经完成,直接继续执行。
  2. 如果没准备好,调用 await_suspend(),把当前协程的句柄存起来,然后挂起。
  3. 当操作完成时,调用 await_resume(),恢复协程执行。

我在项目中遇到过一个问题:await_suspend 返回 void 和返回 bool 的行为不一样。返回 void 表示无条件挂起,返回 false 表示不挂起直接继续。这个细节坑了不少人。

注意:协程的 promise_type 对象是存储在堆上的。如果你在高频创建协程的场景下,内存分配会成为瓶颈。我一般会配合自定义分配器来优化。

三、协程调度器:谁来管理这些协程?

协程本身不会自己跑,它需要一个调度器来管理。调度器负责:

  • 维护一个就绪队列
  • 决定下一个执行哪个协程
  • 处理协程的挂起和恢复

最简单的调度器就是一个 std::queue 加上一个循环。但实际项目中,我一般会设计成多级队列,把 I/O 密集型协程和 CPU 密集型协程分开。

一个迷你调度器示例

#include <coroutine>
#include <queue>
#include <functional>

class Scheduler {
    std::queue<std::coroutine_handle<>> tasks;
public:
    void emplace(std::coroutine_handle<> h) {
        tasks.push(h);
    }

    void run() {
        while (!tasks.empty()) {
            auto h = tasks.front();
            tasks.pop();
            h.resume();
            if (!h.done()) {
                tasks.push(h);  // 没执行完,重新入队
            } else {
                h.destroy();
            }
        }
    }
};

这个调度器是协作式的。每个协程执行到 co_await 时主动让出,调度器再选下一个。你想想看,这不就是事件循环吗?对,协程调度器本质上就是一个事件循环。

我的经验:调度器的核心是公平性。我曾经写过一个调度器,某个协程一直不挂起,导致其他协程饿死。后来加了一个时间片限制,每个协程最多执行 N 次 co_await 后强制让出。

四、协程在网络编程中的应用

网络编程是协程最典型的应用场景。传统的 Reactor 模型,你要写回调,代码像意大利面条一样。用协程,你可以用同步的方式写异步代码。

传统回调 vs 协程

场景 传统回调 协程
读数据 注册回调,数据到了再调用 co_await socket.read()
写数据 注册写完成回调 co_await socket.write()
超时处理 启动定时器,回调里取消 co_await timeout(5s)
错误处理 回调里传 error_code 异常或返回值,跟同步代码一样

说白了,协程把异步回调的控制流反转给正了回来。你写代码的时候,逻辑是线性的,读起来跟同步代码一样。但底层其实是异步的,不会阻塞线程。

一个简单的 TCP Echo 服务器

Task handle_connection(Socket sock) {
    char buf[1024];
    while (true) {
        auto n = co_await sock.async_read(buf);
        if (n == 0) break;  // 连接关闭
        co_await sock.async_write(buf, n);
    }
}

Task server_loop() {
    Acceptor acceptor("0.0.0.0", 8080);
    while (true) {
        auto sock = co_await acceptor.async_accept();
        // 每个连接启动一个协程处理
        handle_connection(std::move(sock));
    }
}

你看,这段代码没有一行回调。每个连接的处理逻辑是顺序的,但底层是异步的。我在生产环境中用过类似的模式,单机轻松支撑 10 万连接。

关键点:协程本身不提高吞吐量,它提高的是开发效率代码可维护性。吞吐量取决于你的 I/O 模型和调度器设计。

五、协程的代价与陷阱

协程不是银弹。我踩过几个坑,分享给你:

  • 内存开销:每个协程的栈帧在堆上分配。如果协程数量极大(百万级),内存压力不小。我一般会控制协程的栈大小,或者用对象池复用。
  • 调试困难:协程的调用栈在挂起和恢复时会断裂。GDB 对协程的支持在 C++20 初期并不好,现在好多了,但还是不如普通函数直观。
  • 线程安全:协程本身不是线程安全的。如果你在多线程环境下恢复协程,需要加锁。我习惯用一个线程跑一个调度器,避免竞争。
  • 生命周期管理:协程句柄 std::coroutine_handle 是一个裸指针。忘记 destroy() 会导致内存泄漏。我建议用 RAII 包装一下。

我曾经犯过的错:在协程里捕获了一个局部变量的引用,协程挂起后局部变量析构了,恢复时访问了悬空引用。嗯,这个问题排查了整整一天。记住:协程里尽量传值,或者用 shared_ptr。

六、知识体系总览

下面这张图,是我对协程知识体系的总结。你可以把它当作学习路线图。

C++20 协程 语法基础 co_await / co_return / co_yield promise_type 约定 Awaitable 接口 调度器设计 就绪队列管理 公平调度策略 多线程安全 网络编程应用 异步读写封装 连接生命周期管理 超时与重试机制 常见陷阱与优化 内存泄漏 悬空引用 调试困难 线程安全

这张图把协程分成了三大块:语法基础、调度器设计、网络编程应用。底部是常见陷阱。我个人建议的学习路径是:先搞懂语法,再写一个迷你调度器,最后应用到网络编程中。每一步都动手写代码,光看是学不会的。


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