资讯动态

用ucontext从零实现协程框架:原理、代码与踩坑记录

发布时间:2026/10/14 1:31:29 来源:尧图企业网站定制
协程Coroutine这个概念这几年随着 C20 正式引入协程语法、Go 的 goroutine 大规模普及已经算得上人尽皆知了。但很多人不知道早在几十年前的 UNIX 系统里就有四个函数可以让我们在用户态手动完成协程的创建与切换getcontext、makecontext、swapcontext、setcontext。我之前为了彻底搞懂 C20 协程和 Go goroutine 底层的调度逻辑专门用这组老接口从零实现了一个简单协程框架跑通了创建、让出、恢复、回收的完整流程也踩了不少文档里根本不会写的坑。这篇文章就是完整记录代码量不大但理解透了你对各种协程方案的理解都会不一样。1. 先想清楚协程到底在解决什么问题1.1 从阻塞 I/O 到回调地狱服务端程序最常见的场景就是等 I/O读网络数据、写文件、等定时器。传统的同步写法很直观但一个线程堵在read()上时整个线程的 CPU、栈、内核调度实体全部白费。为了扛住成千上万的并发连接大家开始用事件循环加非阻塞 I/O数据到了就回调。问题也随之而来业务逻辑被拆成一块一块的状态散落在结构体里一碰上“先读 A 再读 B 再写 C”的流程代码就变成嵌套的回调地狱调试起来比看文言文还累。协程要解决的就是这对矛盾既想要同步代码的直白又想要非阻塞的高并发。协程在用户态自己管理调度一个线程上可以跑成千上万个逻辑流每个逻辑流看起来还是“从上往下写”的普通函数。你主动让出 CPU 等数据数据好了再回来接着跑现场和局部变量都还完整。本质上就是把“被内核打断”改成“自己主动切走”成本低得多。1.2 从同步阻塞到异步回调的痛苦协程和线程最核心的区别在于调度权在哪里。线程由内核调度是抢占式的你根本不知道它什么时候被切走协程由用户态代码调度是协作式的只有协程自己调用yield之类操作时才切走。这个差异带来了两个直接好处。第一个是开销。线程切换要陷入内核保存一整组寄存器、换内核栈、处理调度队列一次下来微秒级别而协程切换只是用户态函数调用级别的操作保存和恢复一组寄存器而已通常亚微秒。第二个是内存。线程的默认用户栈通常 8MB加上内核栈一万个线程的内存开销你根本扛不住协程的栈是你自己分配的64KB 起步完全够用所以“百万协程”在理论上是可行的这也就是 Go 敢一个进程开几十万个 goroutine 的底气。1.3 为什么选 makecontext 而不是 C20 协程有人会问现在写 C 为什么不直接用 C20 的co_await答案很简单C20 协程是无栈协程编译器生成的框架帮你处理了挂起、恢复和状态保存但它把底层机制封装得太好你很难直观看到切换瞬间发生了什么。而makecontext这套是纯 C 的系统接口每个函数干什么、切换时寄存器怎么保存、栈怎么摆放全部暴露在你面前。用最低层的东西实现一遍调度器你才能真正理解什么叫上下文什么叫栈切换后续看 C20 协程、看 goroutine 的运行时都会有种“原来是这么回事”的通透感。2. ucontext 函数族把执行现场玩明白2.1 getcontext给当前现场拍快照getcontext做一件事把当前线程的执行现场完整保存到一个ucontext_t里。执行现场包括哪些东西CPU 通用寄存器、指令指针、栈指针、信号掩码。换句话说程序此刻跑到了哪条指令、栈在什么位置、哪些信号被屏蔽全部被封进一个结构体里。它不改变执行流只是“拍照”。需要注意的是getcontext保存的是调用它那一刻的程序状态。所以标准的makecontext用法第一步就是先拿一个干净的ucontext_t调一次getcontext拿到合法的寄存器快照后面才谈得上去改它。直接定义一个ucontext_t变量不初始化就塞给makecontext是未定义行为很多初学的人在这里踩坑。2.2 makecontext制造一个全新的执行现场makecontext是这套接口里最神奇的一个它在已有的ucontext_t上“改装”出一个全新的执行现场指定一块栈、指定一个入口函数、指定若干参数。整体分为三步先把uc_stack.ss_sp和uc_stack.ss_size填好再把uc_link设成“这个函数跑完之后自动去哪个上下文”最后调用makecontext把入口函数绑进去。这里有一个关键的直觉普通的函数调用是从当前调用栈上压栈而makecontext创建的是一个独立的新栈。等这个上下文被激活时CPU 的栈指针会切到这块新栈上那个入口函数就像从一个全新的程序开始运行一样它的局部变量、函数调用链都在这块新栈上展开。这正是协程的基础——每个协程一条独立的执行链。2.3 swapcontext 与 setcontext切换的两种姿势swapcontext(old, new)是“保存当前现场 跳转新现场”的组合操作先把当前执行现场保存进old然后激活new。最关键的是它能保证对称性——你从 A 切到 B当 B 里的某个操作再调用swapcontext(b, a)时会沿着保存好的现场回到 A 里当初调用点的那条指令之后继续跑A 的局部变量、栈状态和离开时一模一样。setcontext则只管激活新现场不保存当前现场。它更像一个不需要返回的goto一旦调用当前上下文就再也没有机会从这条路径恢复了。所以在协程框架里setcontext用得很少大家几乎都是靠swapcontext两个上下文之间来回换。记住一个比喻swapcontext是导演喊“换机位”两个摄像机来回切setcontext是直接拔掉当前机位的电源只留另一个画面。2.4 ucontext_t 结构体里都有什么ucontext_t定义在ucontext.h核心字段有四个逻辑角色uc_link指向当前上下文自然结束后自动切换去的上下文uc_stack是stack_t类型包含栈底指针ss_sp、栈大小ss_size和标志ss_flagsuc_mcontext保存机器相关的上下文寄存器现场就在里面uc_sigmask是信号掩码。uc_link的作用怎么强调都不为过。当经由makecontext构造出来的上下文里那个入口函数执行到return程序不会像普通函数一样返回到调用者而是自动把现场切到uc_link指向的上下文。如果uc_link是NULL函数一返回整个进程就结束了连main都救不回来。我第一次写的时候就忘记设置它协程函数返回直接把进程给退出排查了半天才意识到是这个原因。3. 协程框架核心设计状态机与调度器3.1 生命周期必须用状态机管起来有了ucontext的底层能力协程的每个实例本质上就是两样东西一个ucontext_t保存切换现场一块独立栈。但要成为一个完整框架还得跟踪每个协程处于什么阶段否则调度器连“谁能跑、谁在等”都不知道。我用的状态机很简单READY还没跑过、RUNNING正在跑、SUSPENDED主动让出等待恢复、DONE函数跑完。执行流是这样转的调度器选中一个 READY 的协程把它切成 RUNNING协程内部调用yield状态变成 SUSPENDED切回调度器调度器之后再次选中它状态回到 RUNNING直到它的入口函数执行完状态改成 DONE调度器以后就不再碰它。这个四状态的模型朴素但足够用复杂框架里无非再加 BLOCKED、DEAD 之类本质都一样。3.2 调度器round-robin 轮转就够了调度器是一个用户态循环逻辑非常简单把所有 READY / SUSPENDED 的协程挨个恢复一遍每恢复一个就跑一段代码直到它主动 yield 或者结束再恢复下一个。一轮下来如果没有任何协程可以推进说明全部结束或者全部在等待外部事件此时就该退出调度循环了。为什么选最简单的 round-robin因为没有优先级、没有 I/O 就绪状态时“先到先得”的公平轮转已经能保证不饥饿每个协程每次都能分到一个执行片。后续要加优先级其实就是在选协程的条件处加权重判断和协程本身的切换机制无关。真正有意思的问题是协程之间通过什么来通信和同步这要用到通道channel但那是更后面的内容了。3.3 栈从哪里来分配、对齐与防护栈是协程最烧脑的资源。每个协程我给它分配 128KB用malloc就够。但有两个细节必须处理。第一是栈对齐uc_stack.ss_sp要满足架构的对齐要求比如 x86-64 下至少 16 字节对齐。malloc在 64 位系统上默认返回 16 字节对齐的地址所以直接用它没问题但如果你是从一个大缓冲区里手动切给多个协程用就得自己算对齐这个坑早晚会踩到。第二是栈溢出防护。真实项目里 128KB 可能不够深递归或一个大局部数组就能写爆。生产级做法是mmap一块栈区域在栈底向低地址方向溢出的那一端用mprotect置为PROT_NONE一旦压栈越界立刻触发SIGSEGV你就能在崩溃栈里准确定位是哪个协程爆栈。使用malloc的堆栈没有这种保护溢出了大概率是踩到相邻内存崩溃位置完全随机排查极其痛苦。4. 代码实现一个能跑的简单协程4.1 完整框架代码下面的代码是我按上文思路写出来的完整版本编译方式很简单g -O2 -o coro main.cpp不需要链接任何额外库ucontext是 glibc 直接提供的。#include ucontext.h #include cstdio #include cstdlib #include functional #include vector constexpr int STACK_SIZE 128 * 1024; enum class State { READY, RUNNING, SUSPENDED, DONE }; struct Coroutine; static ucontext_t g_main_ctx; static Coroutine* g_current nullptr; static std::vectorCoroutine* g_coro; struct Coroutine { ucontext_t ctx; State state; std::functionvoid() fn; char* stack; int id; Coroutine(std::functionvoid() f) : fn(std::move(f)), state(State::READY) { stack static_castchar*(std::malloc(STACK_SIZE)); if (!stack) std::abort(); id static_castint(g_coro.size()); g_coro.push_back(this); getcontext(ctx); ctx.uc_stack.ss_sp stack; ctx.uc_stack.ss_size STACK_SIZE; ctx.uc_link g_main_ctx; makecontext(ctx, reinterpret_castvoid (*)()(coro_entry), 1, id); } ~Coroutine() { std::free(stack); } }; static void coro_entry(int id) { try { g_coro[id]-fn(); } catch (...) { // 协程里绝不能让异常跨上下文逃逸这里兜底 } g_coro[id]-state State::DONE; } void coro_yield() { if (!g_current) return; g_current-state State::SUSPENDED; swapcontext(g_current-ctx, g_main_ctx); g_current-state State::RUNNING; } void coro_resume(Coroutine* c) { if (c-state ! State::READY c-state ! State::SUSPENDED) return; g_current c; swapcontext(g_main_ctx, c-ctx); g_current nullptr; } static void scheduler_run() { while (true) { bool progressed false; for (auto* c : g_coro) { if (c-state State::READY || c-state State::SUSPENDED) { coro_resume(c); progressed true; } } if (!progressed) break; } }整个框架核心就四个函数构造函数负责创建一个协程coro_yield主动让出coro_resume恢复一个协程scheduler_run就是那个轮转调度循环。是不是比想象中简单但越简单的地方越容易埋雷下面几个关键点逐个拆开讲。4.2 关键点一makecontext 的变参陷阱makecontext的签名是void makecontext(ucontext_t *ucp, void (*func)(void), int argc, ...)注意它的变参在 POSIX 规范里只保证int类型可靠。这意味着你想把一个std::function、this指针直接塞进去从规范角度讲是未定义行为虽然 x86-64 的 ABI 下寄存器传参偶尔能“碰巧”跑通但绝不值得依赖。我的做法是维护一个全局g_coro数组把协程对象的下标作为int参数传进去入口函数coro_entry(int id)再从数组里把真正的对象捞出来执行它的std::function。这个方案安全、清晰还顺带解决了“入口函数怎么找到自己对应对象”的问题。如果你强行在变参里传 64 位指针碰上 32 位int的实现或者换了编译平台编译器可能不会帮你做正确扩展翻车概率极高。另一个细节是函数指针类型makecontext期望void(*)(void)而coro_entry是void(*)(int)。标准做法就是reinterpret_cast一次。这在 glibc 的既有实现上没有问题因为参数本来就已经通过寄存器按int传了但你要清楚这是工程实践上的妥协不是语言标准替你保证的事情。4.3 关键点二yield 的前后两段逻辑coro_yield()里有意思的是swapcontext前后两段代码。第一段在协程自己的上下文中执行把状态置为 SUSPENDED然后保存当前协程的现场、切回主调度器。swapcontext返回的那一刻其实已经发生在“未来”——也就是调度器再次恢复这个协程的时候了此时第二段代码执行把状态从 SUSPENDED 改回 RUNNING。我刚开始写的时候差点在yield之后把状态设成别的值因为没想明白这段代码什么时候才会继续。你必须建立这样的时间感swapcontext不是返回而是暂时“冻结”所有写在swapcontext之后的代码都要等到这个协程下一次被恢复才会执行。理解了这一点整个协程的切换机制就通了。4.4 关键点三函数返回后到底去了哪里协程的std::function跑完后coro_entry里把状态置为 DONE然后执行到函数末尾的return。这个return不会像普通函数那样回到swapcontext的调用点——因为当前函数根本不是在那次交换的调用栈上被调起来的它是在一块独立栈上被激活的。此刻程序会看uc_link也就是g_main_ctx自动把整个现场切回主调度器上下文。所以这里的现场流是调度器调用swapcontext(g_main_ctx, c-ctx)若协程中途 yield则过了一会儿它会从swapcontext后面继续跑若协程已经跑完则它会沿着uc_link回到主上下文落在swapcontext返回之后。两种回归方式代码路径不同但最终都能让调度器的下一行代码继续往下走我这种for循环写法对两种情况都兼容。下面把这个框架用起来写三个互相让出的小任务来验证比较直观。static void demo_task(const char* name, int rounds) { for (int i 0; i rounds; i) { std::printf(%s step %d\n, name, i); coro_yield(); } } int main() { Coroutine a([] { demo_task(A, 3); }); Coroutine b([] { demo_task(B, 3); }); Coroutine c([] { demo_task(C, 3); }); scheduler_run(); std::printf(all coroutines done\n); return 0; }预期输出是 A step 0、B step 0、C step 0、A step 1、B step 1 这样交错打印每轮都按 A、B、C 顺序被调度器各恢复一次。这里有个值得体会的点虽然 A、B、C 看起来是三个“并发”的任务但同一时刻永远只有一个在执行其他都冻在自己的栈里等自己被再次选中的那一刻。5. 编译、运行与验证5.1 编译参数和运行现象我用g -O2 -stdc17 -o coro coro.cpp编译没有任何第三方依赖。第一次运行时如果没有任何输出就退出九成是uc_link的问题导致了进程提前结束正常情况会看到三段交错打印进程最后正常退出状态码 0。验证逻辑有没有跑对我习惯在每个协程的入口和出口各打印一行带名字的日志比如[A enter]、[A done]。这样能清楚看到每个协程被恢复了几次、谁先结束。实测结果里 A、B、C 都会各被打断三次再结束因为每个协程在最后一次 yield 之后下一次被恢复时循环条件已经不满足了函数直接走完状态置为 DONE。5.2 用 valgrind 查栈和内存问题协程框架最容易在内存上出幺蛾子所以上线之前我用valgrind --toolmemcheck ./coro跑了一遍。如果makecontext构造的栈没有正确初始化或者uc_stack的指针偏移错了valgrind 会报 invalid read/write。我第一次实现时犯过一个错把ss_sp当成栈“顶”用结果往里写导致越界valgrind 在 stack frame 里明确指出了非法地址一下就定位了。有一点要提醒自己valgrind 对协程切换的感知并不完美它有时会把切换点误认为栈指针跳变报一些“跳到了未映射地址”之类的假阳性。遇到这种报告先看它指向的地址是不是你给某个协程分配的栈范围内是的话基本就是误报可以在 suppressions 文件里排除不必惊慌。5.3 粗测一下切换开销协程的卖点是“切换快”但到底多快我写了个微基准两个协程互相yield一百万次用clock_gettime测总耗时再除一下。在普通 x86-64 机器上一个swapcontext大约 0.2 到 0.5 微秒量级比线程切换确实快一个量级但比 C20 无栈协程的实现差不少——毕竟每次切换要完整保存和恢复寄存器现场、信号掩码还有函数调用成本。这个数字说清楚了一件事ucontext 的协程适合用来理解原理和做原型不适合追求极致的切换性能的场景。6. 常见问题与经验速查6.1 协程里绝对别碰 longjmpsetjmp/longjmp是另一种保存现场的手段但它在多个协程之间混用非常危险。longjmp要求目标jmp_buf对应的调用栈仍然存活而协程切换意味着多个不连续栈在轮流活跃一个不小心就会跳回到一块“已经不属于这个执行流甚至已经覆盖”的栈上崩溃得毫无规律。我自己的心得是协程栈和调用栈的关系最好当成“每次恢复都是从一个新起点重新开始”不要尝试用旧机制去抄近路swapcontext才是和这个模型匹配的原生操作。6.2 信号掩码的隐藏行为swapcontext不仅换寄存器还会换信号掩码因为信号掩码是ucontext_t的一部分。单线程里的协程如果不碰信号这个问题不明显但一旦你在协程里调用sigprocmask屏蔽信号再切到另一个协程那个协程看到的掩码状态可能和你预料的完全不一样。更要注意的是swapcontext和makecontext都不是 async-signal-safe 函数在信号处理函数里切换协程属于未定义行为轻则丢信号重则直接卡死。结论是信号要么统一在调度器里处理要么干脆不在协程方案里用否则调试能调到怀疑人生。6.3 errno 和线程局部存储是所有协程共享的单线程跑的多个协程共享同一个线程的所有线程局部存储包括errno。所以协程 A 做系统调用后切走B 接着做了一次失败的调用等 A 再恢复时去读errno拿到的是 B 的。这不是 bug是这种模型的固有特性。解决办法很简单系统调用返回后立刻把errno存进协程自己的局部变量别等到切回来再读。类似的还有strtok、localtime这类依赖静态状态的函数协程间要么串行使用要么用线程安全版本不然数据串味了很难查。6.4 一个模型别让它跨线程跑前面所有逻辑都是围绕“单线程”设计的因为g_main_ctx、g_current全是全局变量。如果你在多个线程里各创建一套调度器每个线程必须有自己独立的一份这些全局变量否则两个线程同时操作同一个上下文纯粹是数据竞争。我试过把g_current改成线程局部变量再把调度器状态封装成对象勉强能多线程各玩各的但每增加一个线程就要整体检查一遍状态流转复杂度上升非常明显。真实项目里如果要多线程通常更合理的设计是每个线程一个调度器协程通过通道消息跨线程迁移这已经超出 ucontext 简单框架的范畴。6.5 异常和资源回收std::function抛异常的问题前面提过异常不能跨越swapcontext传播到调度器上周目的老代码里没有try/catch包裹协程函数抛异常直接触发 terminate整个进程没了。我在coro_entry里加了一层兜底捕获但这只是防御不是让你依赖它。真正要注意的是资源回收如果协程里malloc之后不再 yield 就到return析构函数和普通函数一样正常执行但如果协程被强制销毁比如调度器决定取消它它的析构链条不会自然走完栈上的对象析构是缺失的。要在框架层面上做到“协程取消也能安全释放资源”需要给每个协程加析构钩子这是比状态机更麻烦的设计点。7. 这套代码还能往哪里扩展跑通基础框架后最有价值的扩展方向是事件驱动。把每个协程和一个非阻塞 fd 绑定调用read返回EAGAIN时协程把自己挂到epoll上并yield调度器在主循环里跑epoll_wait某个 fd 可读时再去恢复对应的协程。这样异步回调的代码物理上被改成了同步流水账可读性大幅提升。定时器同理给每个协程记一个唤醒时间点调度器用epoll_wait的超时参数统一处理“等两秒再执行”就只是协程睡在定时器上不占线程。另一个方向是做一个栈池协程结束后不立刻 free 栈而是放回池里供新协程复用省掉来回mmap的开销。再往后想做通道channel也要趁早它是协程间通信的基础设施实现上无非就是“发数据时唤醒等待中的协程收数据时如果没有数据就 yield”。把这些全做完你已经实现了一个迷你版 goroutine 运行时的雏形再回头看 C20 协程或者 Go 的调度器源码很多当初抽象的概念都会落地。我个人在把整套流程走完后最大的体会是协程的难点从来不在makecontext的调用方式而在你对“挂起”和“恢复”这两个时刻的空间感。写代码时你要不断提醒自己一行swapcontext之后紧跟着的代码可能是一微秒后执行也可能是一天后才执行。只有时刻带着这种时间感去设计状态和资源协程代码才能写得稳定。这套老接口性能算不上顶但它把整个机制的骨架毫无保留地展示给你作为理解并发模型的一课性价比极高。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑