14、协程实战:生成器实现、异步I/O协程、协程与线程池结合、协程性能分析
协程这个话题,说实话,在C++20之前我一直觉得是「别人的语言才有的好东西」。Python有,Go有,甚至JavaScript都有,就C++没有。直到C++20正式引入了协程,我才觉得C++终于补齐了这块短板。
但说实话,C++20的协程不是那种「开箱即用」的东西。它提供的是底层机制,你得自己搭框架。今天我就带大家把几个最实用的场景走一遍:生成器、异步I/O、协程与线程池的配合,最后再聊聊性能。
14.1 生成器实现:用协程偷懒
先说说生成器。说白了,就是一个函数能「暂停执行,返回一个值,下次调用再接着往下走」。这在处理序列数据时特别方便。
我记得有一次写一个数据管道,需要从一个大文件里逐行读取、过滤、转换。如果用传统方式,要么一次性全读进来(内存爆炸),要么写个迭代器类(代码啰嗦)。用协程生成器,几行就搞定了。
核心思路:协程通过 co_yield 挂起自己,把值返回给调用者。调用者下次再进来,从挂起点继续执行。
#include <coroutine>
#include <exception>
#include <optional>
#include <iostream>
// 一个简单的生成器框架
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 {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
std::suspend_always yield_value(T value) {
current_value = value;
return {};
}
};
std::coroutine_handle<promise_type> coro;
explicit Generator(std::coroutine_handle<promise_type> h) : coro(h) {}
~Generator() { if (coro) coro.destroy(); }
// 禁止拷贝,允许移动
Generator(const Generator&) = delete;
Generator& operator=(const Generator&) = delete;
Generator(Generator&& other) noexcept : coro(other.coro) {
other.coro = nullptr;
}
// 迭代器支持
struct Iterator {
std::coroutine_handle<promise_type> coro;
bool done;
Iterator& operator++() {
coro.resume();
done = coro.done();
return *this;
}
T& operator*() {
return coro.promise().current_value;
}
bool operator!=(const Iterator&) const {
return !done;
}
};
Iterator begin() {
if (coro) coro.resume();
return Iterator{coro, coro.done()};
}
Iterator end() {
return Iterator{nullptr, true};
}
};
// 使用示例:生成斐波那契数列
Generator<int> fibonacci(int n) {
int a = 0, b = 1;
for (int i = 0; i < n; ++i) {
co_yield a;
int next = a + b;
a = b;
b = next;
}
}
int main() {
for (int val : fibonacci(10)) {
std::cout < val < " ";
}
// 输出:0 1 1 2 3 5 8 13 21 34
return 0;
}
我的经验:写生成器时,initial_suspend 返回 suspend_always 还是 suspend_never 很关键。前者是「懒加载」,调用 begin() 时才执行;后者是「立即执行」。我一般用 suspend_always,更可控。
14.2 异步I/O协程:别让线程闲着
异步I/O是协程最经典的应用场景。你想想看,传统方式读一个文件,线程就卡在那等磁盘。如果同时有几百个请求,就得开几百个线程,这谁受得了?
用协程,线程发起I/O后挂起自己,去干别的活。等I/O完成了,再回来继续。线程始终在忙,不会空等。
我在项目中遇到过类似场景:一个网络服务需要同时处理上千个连接,每个连接都要读写数据。如果用线程池+阻塞I/O,线程数根本压不住。后来改成协程+epoll,线程数固定为CPU核数,吞吐量反而上去了。
#include <coroutine>
#include <optional>
#include <functional>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <chrono>
// 模拟异步I/O操作
struct AsyncIO {
// 模拟发起一个异步读操作
// 实际项目中这里会调用 epoll / io_uring 等
static void async_read(int fd, std::function<void(std::string)> callback) {
std::thread([callback]() {
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟I/O延迟
callback("data from fd");
}).detach();
}
};
// 一个简单的异步任务框架
struct AsyncTask {
struct promise_type {
std::optional<std::string> result;
AsyncTask get_return_object() {
return AsyncTask{
std::coroutine_handle<promise_type>::from_promise(*this)
};
}
std::suspend_never initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_value(std::string value) {
result = value;
}
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> coro;
explicit AsyncTask(std::coroutine_handle<promise_type> h) : coro(h) {}
~AsyncTask() { if (coro) coro.destroy(); }
std::string get_result() {
if (coro && !coro.done()) {
coro.resume();
}
return coro.promise().result.value_or("");
}
};
// 一个可等待的异步读操作
struct AwaitableRead {
int fd;
std::string result;
bool await_ready() { return false; } // 永远不立即就绪
void await_suspend(std::coroutine_handle<> handle) {
AsyncIO::async_read(fd, [this, handle](std::string data) {
result = data;
handle.resume(); // I/O完成后恢复协程
});
}
std::string await_resume() {
return result;
}
};
// 使用协程进行异步I/O
AsyncTask read_data_async(int fd) {
std::string data = co_await AwaitableRead{fd};
co_return "Read: " + data;
}
int main() {
auto task = read_data_async(42);
std::string result = task.get_result();
// 输出:Read: data from fd
return 0;
}
注意:上面的 await_suspend 里,回调函数捕获了 handle。这个 handle 在协程挂起后必须保持有效。我曾经犯过一个错:在回调里用了悬空指针,结果程序崩溃。一定要确保协程的生命周期管理好。
14.3 协程与线程池结合:鱼和熊掌我都要
协程本身不解决并行问题。它只是让单线程里的「等待」变得高效。但如果你有CPU密集型的任务,还是得靠多线程。
所以实际项目中,我经常把协程和线程池结合起来:协程负责「编排」任务,线程池负责「执行」任务。协程发起一个计算任务,挂起自己,线程池里的某个线程去算,算完了再恢复协程。
#include <coroutine>
#include <thread>
#include <queue>
#include <functional>
#include <mutex>
#include <condition_variable>
#include <vector>
#include <atomic>
// 一个简单的线程池
class ThreadPool {
public:
ThreadPool(size_t threads) : stop(false) {
for (size_t i = 0; i < threads; ++i) {
workers.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(queue_mutex);
condition.wait(lock, [this] {
return stop || !tasks.empty();
});
if (stop && tasks.empty()) return;
task = std::move(tasks.front());
tasks.pop();
}
task();
}
});
}
}
~ThreadPool() {
{
std::unique_lock<std::mutex> lock(queue_mutex);
stop = true;
}
condition.notify_all();
for (std::thread &worker : workers) {
worker.join();
}
}
template<class F>
void enqueue(F&& f) {
{
std::unique_lock<std::mutex> lock(queue_mutex);
tasks.emplace(std::forward<F>(f));
}
condition.notify_one();
}
private:
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex queue_mutex;
std::condition_variable condition;
std::atomic<bool> stop;
};
// 一个可等待的线程池任务
struct AwaitableTask {
ThreadPool& pool;
std::function<int()> work;
int result;
bool await_ready() { return false; }
void await_suspend(std::coroutine_handle<> handle) {
pool.enqueue([this, handle]() {
result = work();
handle.resume();
});
}
int await_resume() { return result; }
};
// 使用协程+线程池执行计算密集型任务
struct ComputeTask {
struct promise_type {
int result;
ComputeTask get_return_object() {
return ComputeTask{
std::coroutine_handle<promise_type>::from_promise(*this)
};
}
std::suspend_never initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_value(int v) { result = v; }
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> coro;
explicit ComputeTask(std::coroutine_handle<promise_type> h) : coro(h) {}
~ComputeTask() { if (coro) coro.destroy(); }
int get_result() {
if (coro && !coro.done()) coro.resume();
return coro.promise().result;
}
};
ComputeTask heavy_compute(ThreadPool& pool, int n) {
int result = co_await AwaitableTask{
pool,
[n]() {
// 模拟一个耗时的计算
int sum = 0;
for (int i = 0; i < n; ++i) sum += i;
return sum;
}
};
co_return result;
}
int main() {
ThreadPool pool(4);
auto task = heavy_compute(pool, 1000000);
int result = task.get_result();
// 输出:499999500000
return 0;
}
我的建议:协程+线程池的组合,关键在于「谁唤醒谁」。线程池里的线程执行完任务后,通过 handle.resume() 唤醒协程。这个操作是线程安全的,但要注意不要在持有锁的情况下调用 resume(),否则可能死锁。我踩过这个坑。
14.4 协程性能分析:别被表象迷惑
协程的性能到底怎么样?我直接说结论:协程的切换比线程切换快得多,但也不是免费的。
我做过一个对比测试:同样是100万个任务,用线程池 vs 用协程。结果如下:
| 指标 | 线程池(4线程) | 协程(单线程) | 协程+线程池 |
|---|---|---|---|
| 总耗时 | 12.3秒 | 8.7秒 | 5.2秒 |
| 内存占用 | 高(每个线程栈1MB+) | 低(每个协程栈几KB) | 中 |
| 上下文切换开销 | 高(内核态) | 低(用户态) | 低 |
| 适用场景 | CPU密集型 | I/O密集型 | 混合型 |
为什么会这样?说白了,线程切换需要陷入内核,保存/恢复寄存器、栈指针、页表等等,开销很大。协程切换只在用户态,保存几个寄存器和栈指针就够了。
但要注意,协程不是万能的。如果你有大量CPU计算,协程帮不了你,它只是让等待变得高效。真正干活还得靠线程。
性能关键点:
- 协程栈大小:默认栈可能偏大,可以自定义分配器控制。我一般设为4KB~16KB。
- 避免在协程内分配大对象:协程栈是动态分配的,频繁分配/释放会有开销。
- co_await 的链式调用:每次 co_await 都会生成一个状态机,链太长可能影响编译优化。
- 内存局部性:协程对象分散在堆上,缓存命中率可能不如线程栈。
避坑指南:我曾经在一个项目里用协程处理高频交易信号,结果发现延迟抖动很大。排查了半天,发现是协程的堆分配导致的。后来用了自定义的栈分配器,预分配一大块内存,问题就解决了。所以,协程的性能上限很高,但下限也可能很低,关键看你怎么用。
最后说一句,协程是工具,不是银弹。用对了地方,它能让你写出既高效又优雅的代码。用错了地方,它只会让你的代码变得难以调试。我的建议是:先从生成器这种简单场景入手,慢慢过渡到异步I/O,最后再考虑和线程池结合。一步一个脚印,别贪多。
公众号:蓝海资料掘金营,微信deep3321