如何实现简单协程?
什么是异步高并发,说白了,它就是个可自己控制挂起和继续的函数.
接着100多行代码,让你彻底看清楚,协程到底是怎么回事?
一,为何协程?
写过网络编程的,谁又没被回调地狱折磨过呢?
简单业务,用异步来写,性能是上去了,代码就变成了一坨.
cpp
void on_request(Request req) {
db_query(req.id, [](DBResult res) {
if (res.ok) {
rpc_call(res.data, [](RPCResult rpc_res) {
if (rpc_res.ok) { /*...*/ } else { /*...*/ }
});
} else { /*...*/ }
});
}
逻辑被撕得粉碎,可读性烂到家.
想要的是既有异步的性能,又有同步代码的清爽.
协程,就是为了让你可用同步的写法,干出异步的活儿.
它允许你在耗时操作上挂起,把CPU让给别人,等完成操作了,再从挂起的地方恢复,继续往下走.
二,从玩具到工具,手撸一个分发器
要实现一个可挂起和恢复的函数,只需要三样东西:
1,保存状态,知道执行到哪了,
2,恢复入口,显式从哪继续,
3,及一个分发器,也就是谁来调用.
思路就是,用一个状态变量当PC,用switchcase当跳转表.这套组合拳就是无栈协程的精髓.
现在,直接从该思路出发,搭一个可模拟网络分发事件的架子.
需要一个Scheduler分发器,它知道哪些协程在就绪队列里,哪些在等待队列中.
协程需要IO时,就从就绪队列移动到等待队列里去,然后让出CPU.
当IO事件来了,分发器再把它挪回就绪队列.看代码:
cpp
#include <cstdio>
#include <queue>
#include <map>
//`协程`基类和核心宏
struct Coroutine {
int state = 0;
bool finished = false;
virtual ~Coroutine() = default;
void resume() { if (!finished) step(); }
protected:
virtual void step() = 0;
};
#define CORO_BEGIN() switch (state) { case 0:
#define CORO_YIELD() do { state = __LINE__; return; case __LINE__:; } while (0)
#define CORO_END() } finished = true;
//分发器
class Scheduler {
std::queue<Coroutine*> ready_queue;
std::map<int, std::queue<Coroutine*>> wait_map;
public:
void spawn(Coroutine* c) { ready_queue.push(c); }
//`协程`调用此函数以等待事件
void wait_for(Coroutine* c, int event) {
wait_map[event].push(c);
}
//外部`事件源`调用此函数以通知事件
void notify(int event) {
if (wait_map.count(event)) {
auto& q = wait_map[event];
while (!q.empty()) {
ready_queue.push(q.front());
q.pop();
}
wait_map.erase(event);
}
}
void run() {
while (!ready_queue.empty()) {
Coroutine* c = ready_queue.front();
ready_queue.pop();
c->resume();
if (c->finished) delete c;
}
}
};
//等待事件的宏
#define SCHED_YIELD_WAIT(sched, event) do { \
(sched)->wait_for(this, event); \
state = __LINE__; return; case __LINE__:; \
} while (0)
//模拟网络请求的`协程`
struct NetReader : Coroutine {
Scheduler* sched;
const char* name;
NetReader(Scheduler* s, const char* n) : sched(s), name(n) {}
protected:
void step() override {
CORO_BEGIN();
printf("[%s] 开始,等待IO事件...\n", name);
SCHED_YIELD_WAIT(sched, 1);
//等待事件1(模拟IO)
printf("[%s] IO事件到达,处理完成.\n", name);
CORO_END();
}
};
int main() {
Scheduler scheduler;
scheduler.spawn(new NetReader(&scheduler, "请求A"));
scheduler.spawn(new NetReader(&scheduler, "请求B"));
printf("分发器启动,所有`协程`将挂起等待IO...\n");
scheduler.run();
//`协程`执行到`SCHED_YIELD_WAIT`后挂起
printf("\n--- 外部IO事件到达 ---\n");
scheduler.notify(1);
//唤醒所有等待事件1的`协程`
printf("\n分发器再次运行,处理已就绪的`协程`...\n");
scheduler.run();
//执行后续逻辑
return 0;
}
这段代码就是一套缩微的事件驱动的协程框架.主函数模拟外部世界,先运行,挂起协程;然后通知,模拟IO事件;再运行,唤醒协程继续执行.
这,就是epoll/kqueue这类IO多路复用结合协程的底层原理.
三,宏的坑与C++20的春天
上面的这套宏,虽然讲清了原理,但有坑.
比如不能在子函数里yield,RAII也容易失效.
幸好C++20带来了语言级的co_await.上面的NetReader,用C++20如下写出来.
cpp
#include <iostream>
#include <coroutine>
#include <thread>
struct Task { /*...样板...*/ };
struct awaitable { /*...样板...*/ };
Task net_reader_cpp20(const char* name) {
std::cout << "[" << name << "] Started, waiting for IO..." << std::endl;
co_await awaitable{};
//`编译器`在此生成挂起点
std::cout << "[" << name << "] IO received, finished." << std::endl;
}
//`主`函数示意
int main() {
net_reader_cpp20("Reader C++20 A");
... 实际需要一个分发器来驱动
}
一个co_await``关键字,编译器就帮你做了之前用宏做的所有脏活累活,而且做得更好.但你只有亲手用宏撸一遍,才能真正理解co_await背后,编译器到底帮你省了多大的事.
软件干到头就是硬件,但把软件自身压榨到极限,你就是别人口中的大神.
今天从一个最简单的宏,一步步构建出一个有模有样的协程分发器,最后看到了C++20的方案.这整个过程,比你直接去背C++20``协程的八股文要有值得多.
我把协程的底裤给你扒了,但怎么穿上它,在什么场景下穿,就是你的修行了.
在你的项目里,哪个环节最适合用协程来改造?
是网络IO,还是文件读写,或是一些复杂的业务流程状态机呢?