16、异步I/O与协程:C++20协程原理、co_await与co_yield、异步文件读写、协程与网络编程结合

协程这个话题,说实话在C++社区里等了很久。C++20终于把它带进来了。我记得第一次看到提案的时候,心里想的是——终于不用再靠回调地狱活着了。

但说实话,C++20的协程跟Python、Go那种协程不太一样。它更像是一套底层机制,给你搭了个架子,具体怎么用,得你自己来。嗯,这很C++。

协程的核心概念

先搞清楚一件事:协程不是线程。它是在一个线程内部,把执行权交出去,再拿回来。说白了,就是函数可以暂停,可以恢复。

C++20的协程有几个关键角色:

  • promise_type:协程的“管家”,负责管理协程的状态和返回值
  • awaiter:决定要不要挂起,挂起后怎么恢复
  • coroutine_handle:协程的“遥控器”,你可以手动恢复它

我刚开始学的时候,被这些概念绕得够呛。后来我换了个角度理解:

协程就是一个可以暂停的函数。暂停的时候,它把局部变量都存到堆上。恢复的时候,从上次停下的地方继续跑。

关键点:C++20协程是无栈协程。它不依赖操作系统栈切换,所以开销非常小。我在项目中测过,一次挂起恢复大概几十纳秒,比线程切换快两个数量级。

co_await:挂起与恢复

co_await 是协程的灵魂。它告诉编译器:这里可能要等一会儿,你先挂起我,等条件满足了再叫我。

来看一个最简单的例子:

#include <coroutine>
#include <iostream>

struct SimpleAwaiter {
    bool await_ready() noexcept { return false; }
    void await_suspend(std::coroutine_handle<> h) noexcept {
        std::cout << "挂起协程\n";
        // 这里可以保存handle,稍后恢复
    }
    void await_resume() noexcept {
        std::cout << "恢复协程\n";
    }
};

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

SimpleTask demo() {
    std::cout << "协程开始\n";
    co_await SimpleAwaiter{};
    std::cout << "协程结束\n";
}

这里 await_ready 返回 false,表示“我要挂起”。await_suspend 里你可以把协程句柄存起来,等异步操作完成后再恢复。await_resume 是恢复后执行的逻辑。

我的习惯await_ready 一般用来做快速路径优化。如果数据已经准备好了,直接返回 true,省去挂起开销。我在网络库中经常这么用——数据已经在缓冲区了,就没必要挂起了。

co_yield:生成器模式

co_yield 是用来产生值的。它跟 co_await 的区别在于:co_yield 是“我主动给你一个值,然后我暂停”,co_await 是“我等别人给我值,然后我继续”。

举个例子:

#include <coroutine>
#include <optional>

template<typename T>
struct Generator {
    struct promise_type {
        T current_value;
        
        Generator get_return_object() {
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(T value) {
            current_value = value;
            return {};
        }
        void return_void() {}
        void unhandled_exception() {}
    };
    
    std::coroutine_handle<promise_type> handle;
    
    bool next() {
        handle.resume();
        return !handle.done();
    }
    
    T value() { return handle.promise().current_value; }
};

Generator<int> range(int start, int end) {
    for (int i = start; i < end; ++i) {
        co_yield i;
    }
}

// 使用
auto gen = range(0, 5);
while (gen.next()) {
    std::cout << gen.value() << " ";  // 输出 0 1 2 3 4
}

你想想看,以前要实现这种懒加载的生成器,得写多少状态机代码?现在一个 co_yield 就搞定了。

异步文件读写

协程在I/O上的优势,说白了就是“不阻塞”。传统文件读写,线程会卡在 read/write 上。用协程,你可以挂起当前协程,让线程去干别的活。

我封装过一个简单的异步文件读取器:

#include <coroutine>
#include <fstream>
#include <vector>
#include <thread>

struct FileReadAwaiter {
    std::string filename;
    std::vector<char> buffer;
    std::coroutine_handle<> handle;
    
    bool await_ready() noexcept { return false; }
    
    void await_suspend(std::coroutine_handle<> h) noexcept {
        handle = h;
        // 在线程池里执行真正的文件读取
        std::thread([this]() {
            std::ifstream file(filename, std::ios::binary);
            if (file) {
                file.seekg(0, std::ios::end);
                size_t size = file.tellg();
                file.seekg(0, std::ios::beg);
                buffer.resize(size);
                file.read(buffer.data(), size);
            }
            // 读取完成后恢复协程
            handle.resume();
        }).detach();
    }
    
    std::vector<char> await_resume() noexcept {
        return std::move(buffer);
    }
};

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

ReadTask asyncReadFile() {
    auto data = co_await FileReadAwaiter{.filename = "test.bin"};
    std::cout << "读取了 " << data.size() << " 字节\n";
}

注意:上面的例子为了演示简单,用了 detach 线程。实际项目中千万别这么干。线程池是必须的,否则频繁创建线程的开销比文件读取还大。我曾经在一个项目里踩过这个坑,后来老老实实用了线程池。

协程与网络编程

网络编程是协程最能发挥价值的地方。传统的 epoll + 回调模式,代码逻辑被拆得七零八落。用协程,你可以写出同步风格的异步代码。

来看一个简单的 TCP 回显服务器:

#include <coroutine>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <vector>

struct SocketAwaiter {
    int fd;
    std::vector<char> buffer;
    std::coroutine_handle<> handle;
    
    bool await_ready() noexcept { return false; }
    
    void await_suspend(std::coroutine_handle<> h) noexcept {
        handle = h;
        // 注册到事件循环,当fd可读时恢复
        event_loop_add_read(fd, [this]() {
            buffer.resize(1024);
            ssize_t n = read(fd, buffer.data(), buffer.size());
            if (n > 0) buffer.resize(n);
            handle.resume();
        });
    }
    
    std::vector<char> await_resume() noexcept {
        return std::move(buffer);
    }
};

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

EchoTask handleClient(int client_fd) {
    while (true) {
        auto data = co_await SocketAwaiter{client_fd};
        if (data.empty()) break;  // 连接关闭
        write(client_fd, data.data(), data.size());
    }
    close(client_fd);
}

你看,这段代码读起来就像同步编程一样。但实际上,每次 co_await 的时候,协程都挂起了,线程可以去处理其他连接。

协程的生命周期管理

这里有个容易踩坑的地方:协程对象的生命周期。

协程挂起后,它的局部变量都保存在堆上。如果协程对象被销毁了,这些内存就泄漏了。反过来,如果协程已经结束,你还去恢复它,那就是未定义行为。

我的做法是:

  • unique_ptrshared_ptr 管理协程句柄
  • final_suspend 里返回 suspend_never,让协程自动销毁
  • 或者返回 suspend_always,手动调用 handle.destroy()

避坑指南:我曾经在协程里捕获了一个局部变量的引用,协程挂起后局部变量被销毁了,恢复时访问了悬空引用。排查了好久才发现。记住:协程挂起期间,所有被捕获的引用都必须保证有效。

协程的性能考量

协程不是银弹。它也有开销:

操作 开销(近似) 说明
协程创建 ~100ns 堆分配 + 初始化
挂起/恢复 ~30ns 状态保存 + 跳转
线程切换 ~1μs 上下文切换 + 缓存失效

可以看到,协程的挂起恢复比线程切换快一个数量级。但如果你每次操作都创建销毁协程,那开销也不小。

我的建议是:协程适合 I/O 密集型场景,不适合 CPU 密集型。如果你要算圆周率,老老实实用线程池。

协程与事件循环的配合

协程本身不提供事件循环。你需要自己实现,或者用现成的库(如 libuv、asio)。

基本模式是这样的:

// 事件循环
void event_loop() {
    while (running) {
        auto events = epoll_wait(epfd, ...);
        for (auto& ev : events) {
            auto handle = get_coroutine(ev.data.fd);
            handle.resume();  // 恢复对应的协程
        }
    }
}

每个协程对应一个文件描述符。事件循环检测到 fd 就绪,就恢复对应的协程。协程处理完业务逻辑,再次挂起,等待下一个事件。

这种模式的好处是:一个线程可以管理成千上万个协程。每个协程只占几十字节的栈空间(对比线程的几MB栈),内存效率极高。

我的经验:在项目中,我通常用一个线程跑事件循环,多个工作线程处理 CPU 密集型任务。协程只负责 I/O 等待和业务编排。这样既发挥了协程的 I/O 优势,又利用了多核的并行能力。

协程的调试技巧

协程调试是个麻烦事。因为调用栈是断开的——挂起时的栈和恢复时的栈不连续。

我常用的方法:

  • 给每个协程加一个名字或ID,打印日志时带上
  • std::source_location 记录协程创建的位置
  • await_suspendawait_resume 里加日志
  • 用 GDB 的 coroutine 命令(GCC 11+)

说实话,协程调试到现在也没有特别好的工具。我一般靠日志和单元测试来保证正确性。

SVG:协程状态流转图

C++20 协程状态流转图 协程创建 initial_suspend 运行中 执行协程体 挂起 等待恢复 完成 final_suspend 立即执行 suspend_always co_await / co_yield handle.resume() return / 异常 handle.destroy() 图例: 创建 运行 挂起 完成 注:协程创建后可能立即执行(suspend_never)或立即挂起(suspend_always)

总结

C++20 协程是一套强大的异步编程工具。它让异步代码写起来像同步代码,大大降低了心智负担。

但也要记住:协程不是万能的。它适合 I/O 密集型场景,不适合 CPU 密集型。它需要配合事件循环使用,不能独立工作。它的调试比较困难,需要良好的日志和测试习惯。

我个人觉得,协程是 C++ 异步编程的未来。虽然现在标准库支持还不够完善,但社区已经在快速跟进。如果你现在开始学习协程,两年后你就是老手了。

一句话总结:协程让你用同步的脑子写异步的代码,用顺序的逻辑处理并发的事件。


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