首页 / 资讯中心 / 文章详情

Linux手写协程:从ucontext到完整调度器

Linux手写协程:从ucontext到完整调度器 ★ FEATURED ARTICLE
Linux 下想手写一个简单协程大部分人第一时间找到的就是ucontext.h里那四个老面孔getcontext、setcontext、makecontext、swapcontext。这套接口在 POSIX 标准里已经被标记为 obsolete但直到今天依然是理解协程最初级、最直观的一扇门。很多框架早期版本就是拿它做的协程底座后来才慢慢换成汇编级上下文切换方案。本文就围绕“C 利用 makecontext 等实现简单协程”这件事从原理、设计、代码到一路踩过的坑完整梳理一遍。我会用一段尽量精简但完整的 C17 代码实现一个单线程协作式调度器支持创建多个协程、通过yield()主动让出 CPU、再由调度器恢复运行。适合刚接触协程、或者想搞清楚“协程切换到底切了什么”的读者。你不需要有协程库使用经验只要熟悉基本 C 和 Linux 编译就能跟着跑通。1. 协程调度模型与 ucontext 三兄弟1.1 协程到底在做什么协程本质上是一种“可暂停可恢复”的函数。它与线程最大的区别是线程的调度由内核负责抢占随时可能发生协程的调度通常由用户自己控制切换点只存在于你主动让出 CPU 的地方。用生活化的方式理解你在办公室处理两份方案看到 A 方案一半时把当前思路、翻到哪一页、笔停在什么地方全部记下然后收起 A 方案展开 B 方案继续写B 写了一会又停下来恢复 A 方案之前的状态继续。这里的“记下当前状态”对应保存上下文“换一份方案继续”对应恢复另一个上下文。对 CPU 来说一个函数运行到一半需要保存的状态包括指令寄存器、栈指针、通用寄存器、浮点寄存器、信号掩码等。把这些状态打包成一个结构体再通过某种机制从“当前状态”切换到“目标状态”这就完成了协程切换最关键的动作。1.2 getcontext 和它的朋友们都是干嘛的Linux 的ucontext.h提供了四个核心函数它们合在一起就够搭一个最简单的协程框架。函数作用通俗比喻getcontext(ucp)把当前执行现场保存到ucp给当前进度拍一张快照setcontext(ucp)直接切换到ucp所代表的现场通常不返回回到某一张快照makecontext(ucp, func, argc, ...)基于已有的上下文结构体组装一个全新的执行流给一个新人准备好简历和工作台swapcontext(oucp, ucp)先保存当前现场到oucp再切换到ucp保存旧任务、换上新任务一步完成它们操作的核心数据结构是ucontext_t。这个结构体里最关键的两个字段是uc_stack和uc_linkuc_stack指定该上下文使用哪一块栈内存栈的起始地址、栈大小都在这里。uc_link当该上下文对应的函数正常返回后CPU 跳回哪个上下文。如果这里置空函数返回后整个程序就会退出。swapcontext是日常调度最常用的函数。你只需要它就能完成“当前协程暂停让调度器恢复执行”的效果。2. 调度器设计状态、栈和切换链路2.1 一个协程需要哪些私有数据在动手写代码前先想清楚每个协程对象需要保存什么。我把协程定义成下面这样几个字段struct Coroutine { ucontext_t ctx; // CPU 现场 std::vectorchar stack; // 私有栈内存 std::functionvoid() fn; // 协程要执行的函数 bool done; // 是否已运行结束 };ctx是核心切换时保存和恢复的就是它。stack是这块协程独自使用的运行时栈。为什么必须私有因为多个协程如果共用同一个栈A 协程恢复时会把 B 协程的栈数据覆盖掉函数局部变量彻底乱套。协程的几个局部变量、函数调用链、中间结果都依赖这块独立栈。fn用来保存用户传入的协程任务体。done用来标记这个协程是否已经结束调度器在轮转时如果看到done就不再把 CPU 交给它。调度器本身还需要维护几样东西std::dequesize_t ready_; // 等待执行的协程索引队列 std::vectorCoroutine coros_; // 所有协程对象 size_t current_; // 当前正在运行的协程索引 ucontext_t sched_ctx_; // 调度器自己的上下文ready_是典型的就绪队列里面放的是协程在coros_中的下标。sched_ctx_保留了调度器的“出生点”每次协程让出 CPU 后CPU 都会回到这个调度器上下文继续跑调度循环。2.2 spawn怎么生成一个新协程创建协程时常见的步骤是分配一块栈内存给协程调用getcontext(co.ctx)先把一个合法的上下文骨架拿到手设置co.ctx.uc_stack的栈指针和大小设置co.ctx.uc_link sched_ctx_让协程函数返回后自动跳回调度器调用makecontext(co.ctx, coroutine_trampoline, 0)绑定入口函数。这里有一个容易忽略的点makecontext之前必须先给uc_stack配好栈。如果没有栈makecontext虽然不会立刻报错但实际运行时会直接踩到未定义内存。uc_link的作用尤其重要。假设不设置它协程函数一旦return整个进程可能直接结束调度器也就没有机会再去执行其他协程了。把它指向调度器上下文相当于告诉 CPU这个协程的活干完了你按之前保存的调度器快照继续。2.3 yield 与 resume 的完整链路调度器的主循环是这样的void run() { while (!ready_.empty()) { current_ ready_.front(); ready_.pop_front(); if (coros_[current_].done) continue; swapcontext(sched_ctx_, coros_[current_].ctx); } }当调度器第一次把某个协程换进来时CPU 跳到了由makecontext构建的新执行流里跑协程的入口函数然后进入用户任务体。协程调用yield()时void yield() { ready_.push_back(current_); swapcontext(coros_[current_].ctx, sched_ctx_); }这里先把自己重新放回就绪队列尾部再把自己的当前现场保存进ctx同时恢复调度器的保存现场。调度器从swapcontext调用的下一条指令继续跑继续从就绪队列拿下一个协程。整个生命周期就像一个接力棒调度器跑一下把 CPU 交给协程协程跑一阵把 CPU 还给调度器调度器继续分配。由于所有切换都在用户态完成不涉及内核调度所以比线程切换轻量很多。3. 最小可用实现完整 C 代码3.1 能直接编译运行的版本下面这份代码是我实际测试过的最小调度器功能上足够演示协程创建、主动让出、自动回收。#include ucontext.h #include cstdint #include deque #include functional #include iostream #include string #include vector static constexpr size_t kStackSize 1 16; // 64KB struct Coroutine { ucontext_t ctx; std::string name; std::functionvoid() fn; std::vectorchar stack; bool done false; }; class Scheduler { public: static Scheduler instance() { static Scheduler s; return s; } void spawn(std::string name, std::functionvoid() fn) { Coroutine co; co.name std::move(name); co.fn std::move(fn); co.stack.resize(kStackSize); getcontext(co.ctx); co.ctx.uc_stack.ss_sp co.stack.data(); co.ctx.uc_stack.ss_size co.stack.size(); co.ctx.uc_link sched_ctx_; makecontext(co.ctx, coroutine_trampoline, 0); coros_.push_back(std::move(co)); ready_.push_back(coros_.size() - 1); } void run() { while (!ready_.empty()) { current_ ready_.front(); ready_.pop_front(); if (coros_[current_].done) continue; swapcontext(sched_ctx_, coros_[current_].ctx); } } void yield() { ready_.push_back(current_); swapcontext(coros_[current_].ctx, sched_ctx_); } private: static void coroutine_trampoline() { Scheduler::instance().run_current(); } void run_current() { coros_[current_].fn(); coros_[current_].done true; } Scheduler() default; std::vectorCoroutine coros_; std::dequesize_t ready_; ucontext_t sched_ctx_ {}; size_t current_ 0; }; int main() { auto sched Scheduler::instance(); sched.spawn(co1, [] { for (int i 0; i 3; i) { std::cout co1 step i \n; Scheduler::instance().yield(); } }); sched.spawn(co2, [] { for (int i 0; i 3; i) { std::cout co2 step i \n; Scheduler::instance().yield(); } }); sched.spawn(co3, [] { std::cout co3 runs once\n; }); sched.run(); return 0; }编译命令也顺手给出来g -stdc17 -O2 -D_GNU_SOURCE co_sched.cpp -o co_sched如果你用的是比较老的 glibc 环境也可以加上-pthread以防某个辅助符号没挂载。3.2 关键路径逐行拆解整个程序最值得琢磨的地方是uc_link和swapcontext的配合。coroutine_trampoline()是每个协程的统一入口。makecontext把入口设成它之后协程第一次被调度时就从这里开始执行。run_current()里取了当前协程的fn()来执行。协程函数返回后done被置为true然后coroutine_trampoline这个普通函数直接返回。由于uc_link指向调度器上下文函数返回的动作会自动触发一次上下文切换CPU 回到调度器的swapcontext之后调度器看到done true就知道这个协程不用再管了。yield()里有个小细节先把current_放回就绪队列再进行切换。如果顺序反过来调度器可能还没来得及拿到这个协程的索引当前现场已经切走了逻辑就容易错乱。先入队后切换是很多简单调度器的通用写法和习惯。3.3 运行结果与预期验证直接运行上面这份代码输出大致是co1 step 0 co2 step 0 co3 runs once co1 step 1 co2 step 1 co1 step 2 co2 step 2顺序很对称三个协程都先跑第一轮co3没有调用yield跑完一轮就结束了co1和co2则交替让出、交替恢复直到循环结束。这正好验证了调度器按队列轮转分派 CPU 的过程。如果在输出前加上线程号和 PID你会发现整个调度全程都在同一个线程里没有任何真正的并行。这就是协程和线程最大的区别单线程内多任务协作只有逻辑上的并发没有物理上的并行。4. 实际动手最容易踩的坑4.1 makecontext 传参陷阱指针被截断的惨案我第一次自己写类似代码时为了把外部的参数传进协程函数直接写成了makecontext(co.ctx, (void (*)())some_func, 1, local_data);在 x86_64 上跑偶尔正常偶尔拿到一个完全错误的地址。查了很久才发现问题出在makecontext的可变参数上。看头文件里的声明void makecontext(ucontext_t *ucp, void (*func)(void), int argc, ...);它把后续可变参数按整数宽度保存。如果直接把指针传进去在某些平台或者某些高位宽环境下指针的高 32 位可能被截断。稳妥的做法是先把指针转换成uintptr_t或unsigned long再传uintptr_t arg reinterpret_castuintptr_t(some_data); makecontext(co.ctx, (void (*)())some_func, 1, arg);在入口函数里再把uintptr_t转回指针。不过更干脆的方法和我上面实现一样根本不给makecontext传参数用单例调度器去取当前协程索引和函数体。参数传递问题直接绕开逻辑也清爽很多。4.2 栈空间、栈方向和越界问题uc_stack.ss_sp这一项给的是栈区的低地址起始位置。x86_64 上运行时栈是向下增长的所以真正的栈顶在高地址端。makecontext内部会根据ss_sp ss_size计算出初始栈指针。这意味着你分配的栈起始地址必须是合理对齐的。std::vectorchar的内存通常足够对齐到alignof(max_align_t)但如果你用裸数组或手动malloc一个大区块最好也确认下对齐情况。kStackSize我设成 64KB。对绝大多数协程任务这个大小足够但协程函数里如果有很深递归那就是另一回事。ucontext这套机制不会自动扩展栈也不会像线程栈那样有成熟的虚拟内存保护页。栈一旦溢出不是崩溃就是悄然踩到相邻内存排查起来相当痛苦。如果想处理深度不确定的栈可以在mmap时考虑MAP_GROWSDOWN或者自己封装动态栈顶检测。不过在“简单协程”的教学场景里固定 64KB 是够用的重点是先把调度机制跑通。4.3 信号、调试器和工具链的兼容问题swapcontext本质上是一种非局部跳转。它不像普通函数调用那样有规范的调用帧调试器对它的支持比较尴尬。实际运行中容易遇到下面几类情况问题现象处理建议信号处理函数信号会跑在当前协程栈上可能干扰栈数据用sigaltstack单独准备信号栈调试器切换后backtrace经常看不到完整调用链切换时加断点配合寄存器信息慢慢看编译宏之前版本某些 glibc 需要特定宏才暴露符号编译时加-D_GNU_SOURCE官方状态POSIX 已标记 obsolete可用于学习生产环境考虑更现代的替代方案另外协程切换会保存和恢复大量寄存器。如果你想用 AddressSanitizer 这类动态检测工具它通常认为线程栈是固定不变的对自定义栈的识别很不充分。我试过给这段代码加-fsanitizeaddress经常误报栈溢出。不是代码真有 bug而是 Sanitizer 无法理解 ucontext 带来的栈切换。遇到误报时不要第一时间怀疑协程逻辑先关掉 ASan 用普通编译跑一遍。4.4 性能水平比线程快但别期待极限ucontext切换会在用户态保存不少寄存器状态还会处理浮点寄存器和信号掩码相关的内容。实测下来一次切换的成本大概在几十纳秒到一微秒级别比系统级线程上下文切换快不少但又比不上直接用汇编手写的上下文切换。我个人的看法是这种性能对于学习和多数中等规模的协作式调度完全够用。如果哪天你发现自己需要更极致、更可控的上下文切换再去看汇编级方案也不迟。5. 这套实现的适用边界5.1 它擅长什么不擅长什么这套基于makecontext的协程实现适合的任务是单线程内多个独立逻辑块需要轮转且这些逻辑块都能自己主动让出 CPU。典型场景包括网络事件循环中的连接状态机、简单的任务分包处理、或者想快速验证某个“协作式调度”想法。它不适合所有“一气呵成”的阻塞操作。比如某个协程里直接调用sleep(2)整个调度器都会卡住因为sleep会阻塞当前线程没有机会切到下一个协程。处理网络 I/O 时也要注意只有用非阻塞 I/O 和事件循环配合协程才真正有用武之地。它也不是多线程协程。多个线程里同时跑这个调度器等于每个线程一份自己的就绪队列和调度上下文协程之间不共享调度器状态。要做跨线程协程调度还得在调度器上加锁和并发控制复杂度会上升不少。5.2 与 C20 协程的关系C20 标准引入了真正的语言级协程。它和ucontext方案最大的区别在于C20 协程通常是无栈协程挂起时不需要保留一整块独立栈状态直接压缩到堆上的帧结构中。这样做内存占用更可控切换开销也更低。但正因为它是无栈协程遇到函数调用链里需要挂起的场景时需要通过标准库封装的机制来传递状态。相比之下makecontext的模型反而更接近传统有栈协程理解起来更直观。两条路线没有绝对优劣。C20 协程更适合新项目makecontext适合教学和深入理解上下文切换本质。把 ucontext 这套吃透之后再去读 C20 协程的实现或各类协程库源码会轻松很多。5.3 还能往哪个方向扩展目前这个简单实现只有创建、让出、结束三件事。如果想让它更实用可以做几个顺手的小扩展增加sleep(ms)接口调度器维护一个按时间排序的睡眠队列统一用 epoll 或 timerfd 实现低精度延时增加协程返回值让协程结束时把结果写入一个回传通道增加协程销毁在done之后主动释放栈降低内存占用增加错误处理用setjmp或异常封装入口捕获协程内未处理异常。这些扩展都不需要推翻现有骨架只是在调度循环上加状态和队列。我个人在实际写这类代码时最大的体会是看清uc_link和swapcontext的每次配对等于看清了“谁在什么时候把 CPU 交给谁”。后来我虽然不再手写 ucontext 调度器但排查协程库相关问题、理解挂起恢复机制时这套底层知识依然非常有用。如果你也打算从零写一个协程先用这个最小实现跑通再逐步加功能会是一个稳且快的路径。
阅读完成 · 觉得有帮助?
咨询建站