【C/C++】从 setjmp 到 ucontext 再到 hook:C 语言协程是怎么跑起来的?

发布时间:2026/6/27 2:00:04

【C/C++】从 setjmp 到 ucontext 再到 hook:C 语言协程是怎么跑起来的? 【C/C】从 setjmp 到 ucontext 再到 hookC 语言协程是怎么跑起来的1. 为什么需要协程网络服务里经常有一个矛盾同步阻塞代码好写send()之后recv()业务逻辑顺着往下走异步事件模型性能好epoll_wait()拿到事件再按 fd 分发处理但纯异步代码容易变成回调、状态机、上下文传递一旦多个请求之间有依赖代码很快变复杂。课程笔记里对这个问题的概括很直接func(){async_send();async_recv();}async_xxx(){if(1poll(fd,0)){switch();// 判断 IO然后切换出去}}协程要解决的就是这个问题业务代码看起来像同步代码底层遇到 IO 不就绪时主动让出 CPU等 fd 就绪后调度器再恢复它。所以协程不是为了“炫技式切栈”而是为高并发 IO 服务写法上接近同步执行上接近异步调度发生在用户态切换成本比线程更轻和epoll组合后可以用少量线程管理大量连接。2. 协程的本质保存现场再恢复现场一个函数普通调用时执行权从调用者进入被调用者函数返回后栈帧销毁不能随便回到中间某一行继续执行。协程不一样。它要支持两个基本动作yield当前协程主动让出执行权保存自己的执行现场resume调度器恢复某个协程让它从上次暂停的位置继续跑。课程里列了三种常见实现路线1. setjmp / longjmp 2. ucontext 3. asm 汇编这三种方法的核心都一样保存 CPU 寄存器、栈信息和下一条执行位置恢复时再把这些状态装回去。3. 第一站setjmp / longjmp 认识“跳回保存点”先看最小示例jmp.c#includesetjmp.h#includestdio.hjmp_buf env;voidfunc(intval){printf(In func, about to longjmp: %d\n,val);longjmp(env,val);}intmain(){intretsetjmp(env);if(ret0){func(ret);}elseif(ret1){func(ret);}elseif(ret2){func(ret);}elseif(ret3){func(ret);}}setjmp(env)做了两件事第一次调用时保存当前执行现场然后返回0后续如果有人longjmp(env, value)程序会跳回这个保存点并让setjmp返回value。运行结果类似In func, about to longjmp:0In func, about to longjmp:1In func, about to longjmp:2In func, about to longjmp:3这已经有一点“切换”的味道了程序不是按普通调用栈返回而是跳回了之前保存的点。但setjmp/longjmp还不够像完整协程因为它默认没有给每个协程准备独立栈。真正的协程需要多个执行流各自拥有自己的栈否则很难让多个任务都停在自己的调用链中间。4. 第二站ucontext 做出真正的用户态切换ucontext.c就是一个非常适合入门的协程雏形。它创建了三个上下文ctx[0]、ctx[1]、ctx[2]再用main_ctx充当最小调度器。关键初始化代码如下ucontext_tctx[3];ucontext_tmain_ctx;getcontext(ctx[0]);char*stack1malloc(SIGSTKSZ);ctx[0].uc_stack.ss_spstack1;ctx[0].uc_stack.ss_sizeSIGSTKSZ;ctx[0].uc_linkmain_ctx;makecontext(ctx[0],func1,0);这里有四个关键点getcontext(ctx[0])初始化一个上下文结构uc_stack给这个上下文配置独立栈uc_link main_ctx当func1执行结束时回到哪里makecontext(ctx[0], func1, 0)指定这个上下文第一次被恢复时执行func1。协程函数里主动切回main_ctxvoidfunc1(){while(count30){printf(In func1\n);swapcontext(ctx[0],main_ctx);printf(Back in func1\n);}}调度器再按顺序恢复某个协程while(count30){printf(In main\n);swapcontext(main_ctx,ctx[count%3]);printf(Back in main\n);}这两句swapcontext分别对应swapcontext(ctx[0], main_ctx)当前协程yield保存ctx[0]切回调度器swapcontext(main_ctx, ctx[i])调度器resume某个协程从它上次暂停的位置继续。我在 Linux/WSL 下重新编译运行过输出开头如下In main In func1 Backinmain In main In func2 Backinmain In main In func3 Backinmain In main Backinfunc1 In func1注意Back in func1它不是重新调用func1而是从上一次swapcontext(ctx[0], main_ctx)后面继续执行。这就是协程“暂停后恢复”的直观证据。5. 从 demo 到协程库需要 coroutine 和 scheduler上面的ucontext.c只有三个固定函数还不是完整协程库。课程笔记里给出了更接近工程实现的抽象。协程对象大致需要保存structcoroutine{intfd;ucontext_tctx;// stack, stack_size, funcvoid*arg;queue_node(coroutine,)ready_queue;rbtree_node(coroutine,)wait_rb;rbtree_node(coroutine,)sleep_rb;};调度器则需要管理structscheduler{intepfd;structepoll_eventevents[];queue_node(coroutine,)ready_head;rbtree_root(coroutine,)wait;rbtree_root(coroutine,)sleep;};这两个结构说明了一件事协程库不是只有“切换上下文”这么简单。真正跑起来时调度器至少要维护三类协程ready已经可以运行等待被恢复wait正在等待某个 fd 的 IO 事件sleep等待定时器到期。一个典型调度循环可以理解成这样while(1){// 1. 运行 ready 队列里的协程while(!queue_empty(ready)){copop_ready();resume(co);}// 2. 计算最近的 sleep 超时时间timeoutnearest_timer_timeout();// 3. 等待 IO 事件nepoll_wait(epfd,events,maxevents,timeout);// 4. fd 就绪把等待该 fd 的协程放回 readyfor(i0;in;i){coevents[i].data.ptr;push_ready(co);}// 5. 定时器到期sleep 协程也放回 readyexpire_sleep_coroutines();}这样yield/resume就和epoll_wait接上了协程不是随机切换而是根据“可运行、IO 就绪、定时器到期”来调度。6. 第三站hook把阻塞 IO 变成可调度 IO如果业务代码里写的是while(1){recv(fd,buffer,sizeof(buffer),0);parser(buffer);send(fd,response,response_len,0);}问题来了recv如果真的阻塞整个线程都会卡住调度器也没机会运行其他协程。所以协程库通常会 hook 常见阻塞调用例如read / write / recv / send / accept / connect / sleep目录下的hook.c是一个最小 hook 示例它用dlsym(RTLD_NEXT, ...)找到真正的 libc 函数#define_GNU_SOURCE#includedlfcn.htypedefssize_t(*read_t)(intfd,void*buf,size_tcount);read_tread_fNULL;typedefssize_t(*write_t)(intfd,constvoid*buf,size_tcount);write_twrite_fNULL;ssize_tread(intfd,void*buf,size_tcount){ssize_tretread_f(fd,buf,count);returnret;}ssize_twrite(intfd,constvoid*buf,size_tcount){returnwrite_f(fd,buf,count);}voidinit_hook(){read_fdlsym(RTLD_NEXT,read);write_fdlsym(RTLD_NEXT,write);}这段代码本身还没有调度逻辑但它证明了一个关键机制我们可以拦截业务代码里的read/write在自己的函数里决定什么时候调用真实系统调用。真正接入协程调度后hook read 的逻辑可以画成这样伪代码如下ssize_tread(intfd,void*buf,size_tcount){structpollfdpfd{.fdfd,.eventsPOLLIN,};intreadypoll(pfd,1,0);if(ready0){// fd 暂时不可读把当前协程挂到 wait 结构里epoll_ctl(scheduler-epfd,EPOLL_CTL_ADD,fd,ev);// 当前协程 yield调度器去跑别的协程coroutine_yield();}// 被 resume 回来时fd 已经可读再调用真实 readreturnread_f(fd,buf,count);}这就是“同步写法、异步执行”的核心。业务层仍然写nread(fd,buf,sizeof(buf));但底层实际发生的是fd 可读直接调用真实read_ffd 不可读注册到epoll当前协程yieldepoll_wait发现 fd 就绪调度器把对应协程放回 ready 队列协程resume再次进入 hook最终完成真实读取。7. 异步 DNS 和 epoll 服务端协程要解决的真实场景扩展目录里还有两个很有代表性的文件。async_dns_client_noblock.c展示了“纯异步”的写法创建非阻塞 UDP socket发送 DNS 请求把 fd 注册到 epoll等响应回来后回调处理结果。核心结构很简单structasync_context{intepfd;};structep_arg{intsockfd;async_result_cb cb;};事件线程里等待 DNS 响应intnreadyepoll_wait(epfd,events,ASYNC_CLIENT_NUM,-1);for(i0;inready;i){structep_arg*data(structep_arg*)events[i].data.ptr;intsockfddata-sockfd;intnrecvfrom(sockfd,buffer,sizeof(buffer),0,(structsockaddr*)addr,(socklen_t*)addr_len);structdns_item*domain_listNULL;intcountdns_parse_response(buffer,domain_list);data-cb(domain_list,count);}这种写法性能很好但业务逻辑会被拆成“提交请求”和“回调处理结果”。协程库想做的就是把这种事件驱动能力藏到 hook 和 scheduler 下面让上层代码重新变回顺序逻辑。server_mulport_epoll.c则展示了高并发网络服务常见结构多端口监听epoll_wait接收大量连接事件客户端 fd 设置非阻塞事件到来后可以直接处理也可以投递到线程池。课程截图里可以看到大量客户端回显输出这类场景正是协程库的用武之地每个连接可以对应一个协程业务代码像处理单连接一样顺序执行调度器在背后负责把不可读、不可写、睡眠中的协程挂起。8. 把整条链路串起来到这里可以把协程实现拆成四层第 1 层上下文切换 setjmp/longjmp、ucontext、汇编保存和恢复 CPU/栈状态。 第 2 层协程对象 每个 coroutine 保存 ctx、stack、入口函数、参数、状态。 第 3 层调度器 维护 ready / wait / sleep使用 epoll_wait 驱动 IO 协程恢复。 第 4 层系统调用 hook 拦截 read/write/recv/send/sleep把阻塞点改造成 yield 点。执行流程可以概括为create coroutine - makecontext / 初始化栈 - 放入 ready 队列 - scheduler resume - 业务代码执行 - 遇到 IO 不就绪 - hook 注册 epoll 并 yield - scheduler 跑其他协程 - epoll_wait 返回事件 - 对应协程回到 ready - resume 后继续执行这也是 ntyco、libco、go runtime 等协程/轻量线程系统的共同味道把“等待”从线程阻塞变成任务挂起把“恢复”交给调度器。9. 编译运行本文示例这些示例依赖 Linux API建议在 Linux 或 WSL 下运行。gcc-Wall-Wextra-O0-g-ojmp jmp.c ./jmp gcc-Wall-Wextra-O0-g-oucontext ucontext.c ./ucontext gcc-Wall-Wextra-O0-g-ohook hook.c-ldl./hook我本地验证到的hook输出为buffer:1234567890这说明顶层hook.c已经成功通过自定义read/write包装函数调用到了真实 libc 系统调用。10. 小结协程可以用一句话理解协程是在用户态保存和恢复执行现场并把 IO 等待交给调度器管理的一种并发执行单元。本文从目录里的代码出发走了一条从浅到深的路线jmp.c理解“跳回保存点”ucontext.c理解“独立栈 yield/resume”hook.c理解“拦截系统调用”server_mulport_epoll.c/async_dns_client_noblock.c理解协程为什么适合高并发 IO。如果继续往下实现一个完整协程库下一步就可以补齐coroutine_create()分配协程对象和栈coroutine_yield()/coroutine_resume()封装上下文切换scheduler_run()实现 ready/wait/sleep 三类任务调度read/recv/send/sleephook把阻塞点接入 epoll多线程多核模式每个线程一个 scheduler连接按线程分片。做到这里一个“看起来同步、跑起来异步”的 C 协程网络库就有了骨架。学习链接: https://github.com/0voice

相关新闻