尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

深入协程:三种实现方式、Hook 与调度器设计

深入协程:三种实现方式、Hook 与调度器设计 一、为什么要协程同步的写法异步的性能1.1 先把概念对齐阻塞 / 非阻塞 / 异步要讲清楚协程的价值得先分清三个容易混淆的词。这里的异步有歧义我们指程序员的编程模型不是底层 I/O 的完成方式。阻塞 I/O调用read后线程一直等数据没来就卡住不动。非阻塞 I/O 多路复用read返回EAGAIN表示现在没数据线程不等待接着去处理别的连接。而什么时候有数据交给 epoll 来通知——epoll 是同步 I/O 多路复用它负责监控一批 fd一旦某个 fd 就绪可读/可写就告诉你有事件了。它不是异步 I/O异步 I/O如 Linux 的 io_uring是你发起读内核把数据准备好了再通知你epoll 只是告诉你什么时候可以发起读真正的read还是你自己同步地去读。编程模型上的同步 vs 异步这层讲的是代码组织方式。同步模型是发个请求、等结果、再继续一行一行顺着写异步模型是发个请求、注册回调、先去干别的、结果回来时回调被调用逻辑被拆散。1.2 两种编程模型的取舍同步模型逻辑清晰但阻塞调用read/write会卡住线程。高并发就得多开线程而线程切换走内核态有开销线程一多调度成本、内存占用每线程一个栈都上去了。int do_service(int fd) { char buf[1024]; read(fd, buf, sizeof(buf)); // 阻塞线程停在这等数据 // 处理请求... write(fd, resp, len); // 阻塞等写完成 }异步模型epoll 回调性能高线程数固定也能撑海量连接但逻辑被切成回调碎片状态一多就乱维护成本高void on_readable(int epfd, struct epoll_event *ev) { // 数据就绪了发起 read…… // 读完了又要 write还得注册下一次回调…… }一句话同步模型 逻辑好懂但阻塞浪费异步模型 并发性能高但逻辑难懂。1.3 协程鱼与熊掌协程要的是用同步的编程方式拿到异步的性能。底层仍然是非阻塞 fd epoll 多路复用但对外暴露的是同步代码int do_service(int fd) { char buf[1024]; read(fd, buf, sizeof(buf)); // 看起来同步阻塞实际遇到 IO 自动让出协程 // 处理请求... write(fd, resp, len); }原理上协程框架把read/write拦截Hook第三节细讲检测到 fd 没就绪就把当前协程挂起、注册进 epoll让出 CPU等 epoll 通知该 fd 就绪再把协程唤醒、恢复执行。对业务代码来说read好像真的阻塞然后返回了——逻辑是同步的性能是异步的。1.4 协程 vs 进程 vs 线程三者最根本的区别在于切换由谁完成、代价多大进程是操作系统资源分配的最小单位拥有独立地址空间切换最重。线程是操作系统调度的最小单位共享进程地址空间切换由内核完成。协程是用户态的轻量级线程切换完全在用户态完成不经过内核所以开销极小单进程可轻松支撑十万级协程。一句话进程管资源线程管内核态调度协程是应用层自己做的用户态调度。二、协程实现的核心函数间跳转协程的本质是执行到一半保存现场、切走以后恢复接着跑。落地到代码层面就是函数间跳转从当前函数跳出去再跳回来。实现有三条路setjmp/longjmp、ucontext、汇编。2.1 setjmp / longjmpsetjmp设置返回点longjmp跳回返回点#include setjmp.h jmp_buf env; void foo() { printf(foo start\n); longjmp(env, 1); // 跳回 setjmp 的位置 printf(never reach\n); } int main() { if (setjmp(env) 0) { // 第一次调用返回 0 foo(); // 进去然后被 longjmp 跳回来 } else { // 被 longjmp 跳回时返回非 0 printf(back in main\n); } }它的本质是记录当前上下文寄存器 栈指针longjmp 时恢复。短板很明显不利于多线程之间跳——上下文和线程绑定跨线程跳转语义混乱工程里不敢用。复杂场景难用——跳转后栈上局部变量、资源清理都难以管理。优点是跨平台性好几乎所有环境都支持。它适合讲清返回点跳转的思想工程上很少直接用。2.2 ucontext最推荐ucontext 用结构体ucontext_t来标识一次跳转把保存上下文、制造新上下文、切换上下文封装成干净 API#include ucontext.h ucontext_t main_ctx, co_ctx; void coroutine() { printf(in coroutine\n); swapcontext(co_ctx, main_ctx); // 切回主函数并保存当前现场 } int main() { char stack[1024]; // 协程自己的栈 getcontext(co_ctx); // 先拿到当前上下文 co_ctx.uc_stack.ss_sp stack; // 指定协程栈 co_ctx.uc_stack.ss_size sizeof(stack); co_ctx.uc_link main_ctx; // 协程结束后回到哪 makecontext(co_ctx, coroutine, 0); // 生成协程上下文 swapcontext(main_ctx, co_ctx); // 从主函数切进协程 printf(back in main\n); }四个核心 APIgetcontext拿当前上下文makecontext造新的执行体swapcontext保存当前并切换过去setcontext直接跳转不保存。优点代码最容易写语义清晰是入门首选最推荐。缺点仅限 Linux其他 OS 不一定支持可移植性差。2.3 汇编借鉴线程与进程ntyco 的做法第三条路回到线程/进程切换的本质——说到底是换栈、换寄存器而换寄存器就是几条mov 指令# 保存现场寄存器压栈保存当前栈指针 push rax push rbx push rcx mov [saved_rsp], rsp # 切换装载目标协程的栈 mov rsp, [target_rsp] pop rcx pop rbx pop rax retntyco 这类框架就是这么干的自己写汇编做上下文切换性能最高。但注意两点性能其实差别不大高出的收益远没有想象中大。真正限制它的是CPU / 体系结构——x86 一套汇编ARM 一套汇编每换一个架构就要重写一遍可移植性最差。2.4 三种方式小结setjmp/longjmp 跨平台最好但多线程间跳转不可靠、复杂场景难用适合讲原理。ucontext 代码最容易、最推荐但只限 Linux。汇编性能最高差别不大限制它的是 CPU/体系结构。工程选型追求可移植、写起来快选 ucontext锁定平台、要极致性能就自己写汇编。不过到这里我们只实现了一个协程能切走再切回来。要让协程落地到网络服务还差关键一环协程遇到read/write时怎么做到自动让出这就是下一节的 Hook 机制。三、协程遇见 IOHook 机制现在的问题业务代码里写的是同步的read(fd, buf, n)协程框架怎么让它没数据就自动让出、有数据再切回来答案是把read换成自己的实现——Hook。3.1 思路让 POSIX API 行为一致要求是对业务代码完全透明该写read还是写read底层自动走协程让出逻辑。做法是拦截系统的read/write/recv/send在进入真正的系统调用前先检查这个 fd 就绪没有没就绪就把协程挂起注册进 epoll让出 CPU等 epoll 通知了就绪再恢复协程继续执行真正的读。3.2 实现手法核心是在自定义函数里保存并调用原始函数。以read为例#include dlfcn.h #include sys/types.h // 定义类型函数指针类型 typedef ssize_t (*read_t)(int fd, void *buf, size_t count); // 用同名函数指针保存原始 read的地址 read_t read_f NULL; // 实现自定义 read内部调用原始 read_f ssize_t read(int fd, void *buf, size_t count) { // 1. 检查/等待 fd 就绪非阻塞就绪再读 // 2. 协程让出与唤醒的调度逻辑 return read_f(fd, buf, count); // 真正去读 } // 初始化把原始 read 的地址取出来 void init_hook() { read_f dlsym(RTLD_NEXT, read); }几个关键点typedef ssize_t (*read_t)(...)是定义一个函数指针类型read_t read_f NULL;是声明一个保存原始函数地址的变量。名字和要 hook 的函数同名但不同标识read_t / read_f不冲突。dlsym(RTLD_NEXT, read)的含义在已加载的动态库里按名字找到下一个同名符号的地址。RTLD_NEXT表示从当前动态库之后开始搜这样找到的是 libc 里真正的read而不是我们自己写的这个避免无限递归。ssize_t的讲究函数签名用ssize_t而不是long因为long 只表示数值而 size_t/ssize_t 表示意义——size_t是非负大小、ssize_t是有符号大小出错时返回 -1。类型名字本身就是给读代码的人看的语义别用long糊弄。3.3 hook 之后的完整 IO 流程一个协程执行到自定义read时先把 fd 设为非阻塞试着读一次读到数据直接返回返回EAGAIN说明没就绪就把当前协程和这个 fd 的关联记下来丢进调度器的 wait 集合再把 fd 挂进 epoll然后切换走。协程自己和调用它的线程转去执行别的协程。等 epoll 回报这个 fd 可读调度器把协程从 wait 挪回 ready下次轮到它时恢复执行此时数据已就绪真正读完返回。于是业务代码看到的效果就是read阻塞了一下然后带着数据返回。同步的写法异步的性能就在这一步成立。四、协程与调度器的数据结构要让上面的机制运转起来得先定义两个核心结构协程和调度器。4.1 协程struct coroutine协程与 fd 一一对应——一个协程正在为哪个 fd 服务这个信息直接存在协程里struct coroutine { int fd; // 本次 IO 关联的 fd协程与 fd 一一对应 ucontext_t ctx; // 跳转上下文切走/切回都靠它 void *arg; // 入口函数的参数 queue_node(coroutine, ) ready_queue; // ready 就绪队列节点 rbtree_node(coroutine, ) wait_rb; // wait 等待红黑树节点 rbtree_node(coroutine, ) sleep_rb; // sleep 睡眠红黑树节点 };三个集合的语义ready 就绪队列协程可以立刻执行了排队等调度器分配 CPU。用队列因为就绪协程先来先跑、没有优先级区分天然就是 FIFO。wait 等待红黑树协程在等某个 fd 就绪等 epoll 的事件。按 fd 作为 key 组织成红黑树epoll 回报某个 fd 就绪时才能 O(log n) 快速找到对应的协程把它挪回 ready。sleep 睡眠红黑树协程主动睡一段时间比如sleep/timeout。按唤醒时间作为 key调度器每次只要看树最左边的节点是否到期即可。4.2 调度器struct scheduler调度器统一管理所有协程结构几乎和协程一一对应struct scheduler { int epfd; // 用于管理所有 IO 的 epoll 句柄 struct epoll_event events[]; // epoll 收获事件的缓冲区 queue_node(coroutine, ) ready_head; // ready 就绪队列头 rbtree_root(coroutine, ) wait; // wait 等待红黑树根 rbtree_root(coroutine, ) sleep; // sleep 睡眠红黑树根 };要点调度器持有一个epfd所有协程的 IO 都挂在这同一个 epoll 上由它统一监听、统一收获事件。wait红黑树和 epoll 实际上是同一批 fd 的两种视角——epoll 负责内核层哪个 fd 就绪了wait 红黑树负责用户层哪个协程在等这个 fd两者配合完成唤醒。协程间互切是不可控的普遍由调度器来切换。业务代码只管写逻辑什么时候切走、切到谁全是调度器说了算——这就是协作式调度的工程落地。五、调度器的执行策略有了两个结构体核心问题来了三个集合ready / wait / sleep先调度谁5.1 一轮典型的调度循环void schedule() { for (;;) { // 1. 先跑就绪队列把每个 ready 协程都执行一遍 while (!ready_empty(sched-ready_head)) { co pop_ready(sched-ready_head); swapcontext(sched-main_ctx, co-ctx); // 切进协程 // 协程主动让出yield / 等 IO / 睡眠后会回到这里 } // 2. 处理睡眠到期sleep 树最左的到期了就挪回 ready while (sleep_earliest(sched-sleep) 到期) { co pop_sleep(sched-sleep); push_ready(sched-ready_head, co); } // 3. epoll 等待并收获事件哪个 fd 就绪对应协程挪回 ready n epoll_wait(sched-epfd, sched-events, MAXEVENTS, timeout); for (i 0; i n; i) { co 事件对应的协程; // 靠 wait 红黑树找回 remove_wait(sched-wait, co); push_ready(sched-ready_head, co); } // 4. 若三者皆空调度结束 } }策略总结优先级顺序是ready 优先 → sleep 到期次之 → epoll 事件驱动继续。本质是先把能跑的跑了再兑现睡醒的然后靠 epoll 把等 IO的变成能跑的形成一个闭环。没有新事件时epoll 里的timeout正好兼顾 sleep 到期的最小唤醒时间不会白白空转。一个关键点swapcontext从调度器切进协程后控制权在协程手里但它遇到 IO被 hook 的read或主动 yield 时又会swapcontext切回调度器——每个协程的切换点都是自己主动交出的调度器永远有兜底这就是协作式。六、协程的多核模式到这儿实现的还是一个单线程调度器——它只能用一个 CPU 核。想跑满多核怎么办6.1 协程做不到 CPU 亲缘性首先要认清协程是应用层的本身无法做到 CPU 亲缘性。进程/线程由内核调度内核可以按亲和性把某个线程钉在某个核上协程的调度完全发生在用户态内核根本不知道有协程存在自然无法给它绑核。所以协程只能依赖多线程或多进程来利用多核。6.2 两种模式对比多线程模式每个线程跑一个调度器线程间共享进程地址空间。问题是要处理共享数据的加锁问题——协程池、全局表、跨线程唤醒处处要锁复杂。多进程模式每个进程一个调度器进程间天然隔离几乎不用加锁更简单性能也更好。各进程自己监听自己的 epoll互不干扰。工程上的结论很直接多进程更简单、性能更好多线程要考虑加锁问题。代价是进程间不共享内存跨进程通信要另想办法管道、共享内存、socket。七、性能测试协程的并发量协程的优势最后要落到数字上核心指标就是并发量。7.1 怎么测用压测工具同时发起海量连接比如 1 万、10 万、100 万并发连接每个连接上做简单的请求-响应。看两个数吞吐与延迟单位时间内完成多少次完整请求平均/尾延迟如何。资源占用进程内存涨多少。这最能体现协程的杀手锏——线程每开一个就要一块独立栈默认几 MB协程的栈可以很小、按需分配所以协程并发量远高于线程单进程线程数撑到几千就吃紧而协程轻松到十万级。7.2 对比基准同样压测下对比线程模型线程模型在连接数上涨后上下文切换开销内核态和内存占用同时暴涨曲线断崖式下跌协程模型切换在用户态完成开销小得多曲线平缓能顶住的数量级高出一大截。7.3 为什么协程并发量高用户态切换开销小不经过内核省掉系统调用和调度器开销。内存占用低栈小而可复用还支持海量协程挂在同一条 epoll 上。
返回列表