深夜的“AI 小馆”后厨,只亮着一盏灶灯。主厨老周一个人要同时对付三张订单:左边锅里炖着高汤,中间铁板上的牛排正在收汁,右手边还在切配菜。放在普通餐厅,这得请三个厨师(线程),每人一个灶台,老板(操作系统内核)还得派个领班专门调度——人多、工位杂、成本高。 老周的秘诀是“见缝插针”:高汤要炖 10 分钟,这 10 分钟他不可能干瞪眼——把汤勺放下(保存上下文),火速去切配菜;切到一半听到牛排滋啦响(IO 就绪),又放下菜刀,回到铁板前拿起夹子(恢复上下文)。每道菜之间切换,全靠脑子里记得“刚才切到第几刀、盐罐放在左手边”。协程(Coroutine)就是这么回事:在同一个线程(厨师)内部,多个任务(订单)交替推进,谁遇到等待就主动让出 CPU,等时机到了再回来接着干。 这篇文章要带你走进协程的“后厨工作台”:先看协程是什么,再看它怎么记住“切到第几刀”(上下文切换机制),然后比较两套“记忆工具”——标准化的 ucontext(塑料收纳盒)和极致轻量的 fcontext(定制刀具腰带),最后聊聊调度器、性能账本和真实工程里的坑与药。

岗位关联

理解协程切换原理,是读懂高性能 AI 推理框架(vLLM、LLaMA.cpp、Starlette)内功心法的关键;对 AI 工程师而言,这意味着你能精准定位并发瓶颈、优化数据管线,而不是只会调参。

目录
  1. 可视化演示
  2. 1. 协程基础概念
  3. 2. 上下文切换机制
  4. 3. ucontext 原语库
  5. 4. fcontext 汇编切换
  6. 5. 调度器设计
  7. 6. 性能分析与对比
  8. 7. 工程实践与优化
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。

本节配有交互动效(进入视口后自动加载,约 3KB)。

系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T

1. 协程基础概念

老周的订单本上,每张订单都记着:几号桌、点了什么、做到哪一步。有的订单刚写下来(新建),有的配好料等着下锅(就绪),有的正占着灶台(运行中),有的在炖煮等时间(挂起),有的已经端上桌(完成)。一张订单就是一小节“任务”,它知道自己该干嘛、干到哪了。协程(Coroutine)就是能被挂起又能恢复的“函数型任务”——执行到一半,把现场记下来,先干别的,之后再回来接着干。

在操作系统课程里,线程是内核调度的最小单位,但协程并不被内核认识——它是用户态构造出来的“假线程”。真线程切换要请内核帮忙(陷入内核态做一堆簿记工作),协程切换则是程序自己控制的。协程最常见的形态有两种:有栈协程(stackful,每个协程有自己的调用栈,像每张订单有自己的配料小推车)和无栈协程(stackless,共享一个栈,像大家共用一张大桌,切换时只能留下“做到哪了”的简单记号,Python 的生成器就是这种)。

无论哪一种,协程都要经历五态生命周期:创建(CREATE)→ 就绪(RUNNABLE)→ 运行(RUNNING)→ 挂起(SUSPENDED)→ 死亡(DEAD)。挂起态是关键创新:线程被操作系统抢占时也“挂起”,但那是被迫的;协程的挂起是自愿的。协程模型的总耗时可以写成“干活时间 + 切换损耗”:

理解了订单的旅程,就理解了协程为什么能“一个人干三张订单”。订单越多、等待越多,老周见缝插针的收益就越大;但每次放下菜刀再拿起新刀,总归要花时间,所以订单流转策略(调度)在后面会起到决定性作用。

Ttotal=∑i=1nTexeci+Nswitch×Tswitch\displaystyle T_{\text{total}} = \sum_{i=1}^{n} T_{\text{exec}_i} + N_{\text{switch}} \times T_{\text{switch}}
这个公式是说:n 个协程的总时长 = 每段实际干活的时间加起来,再加上每切换一次就付一次“换刀时间”。它帮你理解为什么协程代码要少切多干。
  1. 总耗时符号T_total 表示完成这 n 个协程所有任务花掉的总时间,单位通常是微秒或毫秒。
    Ttotal\displaystyle T_{\text{total}}
  2. 累加干活时间从 1 到 n 把所有协程真正占用 CPU 执行的时间求和,这是“纯收益”部分。
    ∑i=1nTexeci\displaystyle \sum_{i=1}^{n} T_{\text{exec}_i}
  3. 切换次数的代价N_switch 是运行期间发生的切换总次数,每次切换都要付出 T_switch 的固定成本(保存/恢复现场),所以两者相乘就是要扣掉的“管理费”。
    Nswitch×Tswitch\displaystyle N_{\text{switch}} \times T_{\text{switch}}

2. 上下文切换机制

老周换菜的瞬间,脑子里会自动闪过一个“当前位置快照”:黄瓜切到了第 4 段、刀片距手指 3 厘米、盐罐在左手边、下一步是先拍蒜还是先掂勺。如果没有这个快照,再回到这盘菜时他会呆立 3 秒,甚至把菜炒糊。CPU 的协程切换,同样需要一份“快照”,只不过这份快照不是大脑记忆,而是写在寄存器与内存里的二进制数据。

CPU 执行到任何一行程序,手上都捏着一大把“临场状态”:各个寄存器的值、栈顶指针、下一条要执行的指令地址。切换协程,就是把当前这套状态“存档”,再读取另一套“存档”,让 CPU 仿佛刚才一直在执行另一个任务。这就是上下文切换(context switch)。注意,线程切换和协程切换都做这件事,区别是:线程切换还要经过内核(相当于找老板审批),协程切换只在用户态(相当于厨师自己心里过一遍)。

协程切换需要保存的关键内容分三块:寄存器组(尤其“被调用者保存”的 callee-saved 寄存器,比如 rbx/rbp/r12-r15)、栈指针 RSP(指向协程自己的栈顶)、程序计数器 RIP(下次恢复时从哪条指令继续执行)。保存位置通常是内存缓冲区——ucontext 里的 ucontext_t 结构体,或 fcontext 的 transfer_t 结构。一次上下文保存的成本模型是:

“背得少”恰恰是 fcontext 快于 ucontext 的核心秘密——ucontext 打包了完整的机器上下文(连浮点寄存器、信号掩码都打包),像把整个厨房的状态摄影存档;fcontext 只保存做下一道菜真正要用的那几把刀(callee-saved 寄存器),像教练反复练过的肌肉记忆。下一节我们分别打开这两套工具箱。

Cctx=Rcallee×wword+Ofixed\displaystyle C_{\text{ctx}} = R_{\text{callee}} \times w_{\text{word}} + O_{\text{fixed}}
意思是:切换开销主要由“要背多少个寄存器的数字”决定,每个寄存器 8 字节,再加上一些固定开销。寄存器背得越少,切换越快。
  1. 切换总开销C_ctx 代表一次上下文切换要付出的总时间成本,这在高性能服务里是按纳秒计量的。
    Cctx\displaystyle C_{\text{ctx}}
  2. 寄存器搬运量R_callee 是需要保存的被调用者保存寄存器数量,乘以每个寄存器的位宽 w_word(64 位机器上通常 8 字节),得到存档的内存搬运字节数。
    Rcallee×wword\displaystyle R_{\text{callee}} \times w_{\text{word}}
  3. 固定开销O_fixed 包括栈指针改写的栈切换、指令跳转的流水线刷新、缓存(TLB)失效等不可避免的常数时间。
    Ofixed\displaystyle O_{\text{fixed}}

3. ucontext 原语库

C 标准库的 POSIX 接口里有一套“标准收纳盒”——ucontext。厨房新手培训手册上写得明明白白:把当前状态放进盒子里用 getcontext;打开盒子取出状态穿回身上用 setcontext;给盒子贴上菜谱(函数指针)并配好灶台(栈)用 makecontext;一手收起旧盒子、一手打开新盒子用 swapcontext。四个基础动作,盖起一间标准协程厨房。

ucontext 的核心数据结构是 ucontext_t,它由四部分组成:uc_flags(标志位)、uc_link(当本协程执行完,自动切换到哪个上下文)、uc_stack(指向协程自己的栈空间)、uc_mcontext(机器上下文,保存各种寄存器)。这套 API 由 glibc 提供,Windows 上没有原生支持,需要第三方移植。它最大的优点是标准:只要写 C,到哪都能用。缺点是重:保存的信息全而冗余。

典型用法是三步——先用 getcontext(&ctx) 初始化一个上下文骨架;然后 makecontext 指定入口函数和栈;最后 swapcontext(&from, &to) 完成跳转。第四步的 uc_link 让协程执行完后自动“滚回”指定的继任上下文,避免直接返回空栈。ucontext 单次切换的成本高于专用协程库,因为要维护 uc_link 链、保存浮点寄存器以及可选的信号掩码(sigaltstack)。其成本可分解为:

收纳盒足够规范、有说明书,但包得太厚。老周在快节奏的星级餐厅里,没空每次打开收纳盒还检查“盒子里有没有留下出餐单”——他需要一套更贴身、更少的工具。这就是 fcontext 出现的理由。

Tucontext=Tsave+Trestore+Tlink\displaystyle T_{\text{ucontext}} = T_{\text{save}} + T_{\text{restore}} + T_{\text{link}}
这段话的意思是:ucontext 的切换时间由三部分组成——把寄存器写进盒子、从盒子读出寄存器、检查“你做完后回谁那儿”的链路。第三项是 ucontext 特有的额外账单。
  1. 收纳盒总耗时T_ucontext 表示通过 ucontext 完成一次协程切换的总耗时。
    Tucontext\displaystyle T_{\text{ucontext}}
  2. 存档耗时T_save 是把当前 CPU 寄存器组写入 ucontext_t 的 uc_mcontext 字段的时间,和寄存器数量成正比,ucontext 会保存全部通用寄存器并带上浮点状态。
    Tsave\displaystyle T_{\text{save}}
  3. 读档耗时T_restore 是从目标上下文的 uc_mcontext 里恢复寄存器、栈指针和指令指针的时间。
    Trestore\displaystyle T_{\text{restore}}
  4. 链路维护耗时T_link 是 ucontext 特有开销——切换前要检查 uc_link 是否为空、是否需要在结束后跳转到链接上下文,这部分逻辑让每次切换多几条比较跳转指令。
    Tlink\displaystyle T_{\text{link}}

4. fcontext 汇编切换

定制刀具腰带——一条插着 8 把必备刀的帆布带,绑在腰上,换刀时一摸一抽,不需要打开收纳盒、不需要翻找、不需要核对抽屉标签。Boost.Context 提供的 fcontext 就是这个思路:用最少的寄存器保存量、最精简的汇编指令,完成“放下/拿起”的动作。

fcontext 是 Boost.Context 库的底层上下文切换原语,也是 libco、brpc 等高性能协程库的基石。它对外暴露两个核心函数:make_fcontext(准备一块新栈,在里面布置好上下文帧,返回栈顶地址)和 jump_fcontext(从当前上下文跳到目标上下文,同时各保存/恢复彼此的现场)。整个过程中,只保存 callee-saved 寄存器(非易失寄存器),因为 caller-saved 寄存器(易失寄存器)在编译器生成代码时已经负责压栈保存了,轮不到协程库操心。

在 x86_64 架构下,jump_fcontext 的汇编实现大致为:先把 rbx、rbp、r12-r15 和返回地址压入当前栈;保存 rsp 到旧上下文的栈帧;加载新上下文的 rsp;从新栈顶弹出对应寄存器;最后 ret 跳转到新的返回地址。一进一出,行云流水。不做 syscall、不改页表、不检查信号、不遍历链表,所以它快。与 ucontext 对比可以定义加速比:

当然,定制腰带也有代价:不同平台得单独打版。fcontext 的汇编代码要为 x86_64、ARM64、RISCV、PowerPC 等每种架构各写一套,否则无法运行。每多支持一个平台,就多一份“肌肉记忆”要训练;这正是“快”与“通用”之间的永恒取舍。

Sspeedup=TucontextTfcontext\displaystyle S_{\text{speedup}} = \frac{T_{\text{ucontext}}}{T_{\text{fcontext}}}
它是“收纳盒切换耗时 / 腰带切换耗时”的比值。实测在 x86-64 Linux 上,fcontext 单次切换约 15~30ns,而 ucontext 往往要 50~100ns 甚至更高,加速比大致在 2~5 倍。
  1. 加速比S_speedup 表示 fcontext 相对 ucontext 的提速倍数,它是后面性能分析中的关键指标。
    Sspeedup\displaystyle S_{\text{speedup}}
  2. 分子(收纳盒耗时)T_ucontext 是 ucontext 完成一次完整切换的耗时,包含保存所有寄存器与链路检查。
    Tucontext\displaystyle T_{\text{ucontext}}
  3. 分母(腰带耗时)T_fcontext 是 fcontext 完成切换的耗时,只保存少数非易失寄存器。
    Tfcontext\displaystyle T_{\text{fcontext}}

5. 调度器设计

老周一个人忙不过来的秘诀不只是换刀快,还在于他脑子里有张“传菜轮转表”:哪道菜在等汤、哪道菜快出锅、哪道菜必须马上翻面。他不会等到锅烧干了才想起来。这套“临场安排”的心思,放在程序里就是调度器(scheduler)。

调度器维护一个就绪队列(ready queue),存放所有状态为 RUNNABLE 的协程。调度器按某种策略从中选一个协程运行;该协程运行中主动 yield(交出 CPU),或遇到 IO 阻塞,就挂起自己并让调度器切换到下一个就绪协程。因为切换完全由用户态代码控制,没有内核抢占,所以这种模型叫协作式调度(cooperative scheduling)。

yield 的语义是“从 RUNNING 回到 RUNNABLE(或 WAITING),并且不用保存内核栈”。典型的事件循环(event loop)就是调度器的具体化:一个 while 循环里做三件事——处理就绪队列里的协程、检查 epoll 是否有 IO 事件就绪、把被事件唤醒的协程重新插入就绪队列。调度器设计的目标是最大化系统吞吐:

传菜轮转表的核心是“知道自己何时该让、何时该回来”。协作式调度就像老周完全靠自觉:如果某道菜死占着灶台不撒手(死循环不让出),其他订单全都干瞪眼。所以,用好协程的第一个工程准则是:不要在协程里放太长的纯计算任务。

QPS=1Tˉexec+Nyield×Tswitch\displaystyle \text{QPS} = \frac{1}{\bar{T}_{\text{exec}} + N_{\text{yield}} \times T_{\text{switch}}}
意思是:每秒能处理多少个请求(QPS),等于单个请求的平均干活时间,加上它执行过程中主动让出 N_yield 次所付出的切换开销,加起来取倒数。yield 次数越少、切换越快,吞吐越高。
  1. 吞吐量指标QPS 是每秒可以处理完毕的请求数,是衡量调度器效率最直接的标尺。
    QPS\displaystyle \text{QPS}
  2. 平均干活时长\bar{T} 的下标 exec 表示单个请求从开始到结束实际使用 CPU 的平均时间,不包含等待。
    Tˉexec\displaystyle \bar{T}_{\text{exec}}
  3. 让出次数成本N_yield 是单个请求运行期间主动让出 CPU 的次数,乘以单次切换成本 T_switch,得到请求因切换而额外付出的总时间。
    Nyield×Tswitch\displaystyle N_{\text{yield}} \times T_{\text{switch}}
  4. 倒数操作对总耗时求倒数,把“每个请求耗时”换算成“每秒能完成的请求数”。
    1...\displaystyle \frac{1}{...}

6. 性能分析与对比

开连锁店前,老周要算一笔账:是多请几位厨师(多线程)划算,还是让每位厨师都练就“一人三灶”的本事(协程)划算?请厨师要开工资(内核线程成本)、要扩后厨面积(栈内存)、要找经理协调(内核调度);而让厨师自己切换,只损失换刀的那几十纳秒。账算清了,才能决定什么时候上协程。

两类开销是协程性能分析的核心:切换开销和内存占用。线程切换:用户态保存寄存器 → 陷入内核 → 内核调度器选线程 → 切换地址空间/TLB → 回到用户态恢复寄存器。完整流程大约 1~10μs,且依赖内核锁与调度队列。协程切换:纯用户态,只保存若干寄存器,约 10~30ns,差两个数量级。内存上:线程默认栈 8MB(Linux 为 8MB 虚拟、按需提交),协程可自定义栈 64KB~1MB(典型 256KB),且总内存可精细控制。

以 8GB 内存跑一个 IO 密集型服务为例:每个线程要 8MB 栈加内核 task_struct 等,最多并发数千;每个协程给 256KB 栈,加运行时一个调度器节点约几百字节,可以轻松挂起几十万协程。更通用地写成:

这笔账算完,老周心里有底了:主打“短平快”的订单流转,协程就是省钱利器;可要是碰上需要长时间占用灶台的硬菜(CPU 密集),多请几个厨师反而更快——协程也解决不了 CPU 算力不足的问题。性能优化的本质,是把有限的灶台资源用在刀刃上。

Nmax⁡=MramMstack+Mmeta\displaystyle N_{\max} = \frac{M_{\text{ram}}}{M_{\text{stack}} + M_{\text{meta}}}
意思是:单机最大能同时运行的协程(或线程)数量 = 可用内存除以每个任务占用的栈内存加元数据内存。栈越小、元数据越精简,并发上限越高。
  1. 并发上限N_max 是这台机器最多能同时支撑的并发任务(协程或线程)数量。
    Nmax⁡\displaystyle N_{\max}
  2. 可用内存M_ram 是应用能支配的总内存,单位可以是 MB 或 GB,分子分母单位需一致。
    Mram\displaystyle M_{\text{ram}}
  3. 栈内存M_stack 是单个任务分配到的栈大小,线程通常是 8MB,协程可调至 64KB~1MB。
    Mstack\displaystyle M_{\text{stack}}
  4. 元数据内存M_meta 是除栈外每个任务额外占用的控制块内存(如 ucontext_t 结构体、调度器节点等)。
    Mmeta\displaystyle M_{\text{meta}}

7. 工程实践与优化

老周把后厨秘诀写成《后厨 SOP》后,连锁店遇到一个新问题:外卖平台高峰期,几百单同时进来,怎么保证不糊锅?答案是:把“一人三灶”的功夫用在等待密集型菜品上——炖汤这种要干等的事让给协程排队,牛排这种占锅的硬菜才配专属灶台。协程工程化的精髓,就是分清“等”与“算”。

在工程里,IO 密集型场景(网络请求、磁盘读写、数据库访问)天然适合协程:发起请求后,协程挂起,等 epoll 返回事件再被唤醒,期间不占 CPU。而 CPU 密集型场景(重计算、模型推理纯计算部分)用协程收益很低,甚至会因切换而变慢。现代 AI 推理服务中,GPU 是稀缺“灶台”,而数据加载、tokenizer、HTTP 解析都是典型的 IO/CPU 混合等待,正好用协程搭异步流水线。

工程中的常见组合拳是「epoll + 协程」:用非阻塞 IO 替代阻塞 IO,配合事件循环调度器,把传统为每个连接开一个线程的模式,改成百万连接共享少量线程的模式。在 AI 推理服务中,vLLM 的 continuous batching 就用异步调度把“单请求串行推理”变为“多请求交错推理”,显著提高了 GPU 利用率。总的收益可写为:

SOP 的最后一条写着:再趁手的刀具也怕遇到劣质砧板——千万别在协程里直接调用阻塞式读写(如 read、sleep、mutex 加锁),否则整个事件循环都会被卡住。协程是“将等待变成主动让位”的艺术,所有等待都必须交给调度器来等。理解了这句话,你就算真正入了协程的门。

Tsaved=Nswitch_saved×(Tthread−Tco)\displaystyle T_{\text{saved}} = N_{\text{switch\_saved}} \times \bigl(T_{\text{thread}} - T_{\text{co}}\bigr)
意思是:协程帮我们省下的总时间 = 之前线程切换的总次数,乘以每次从线程切换改成协程切换省下的那部分时间。省下来的都是真金白银的 CPU 周期。
  1. 节省总时长T_saved 表示在一段运行时间内,把线程协作换成协程协作后省出的 CPU 时间总量。
    Tsaved\displaystyle T_{\text{saved}}
  2. 可省切换次数N_switch_saved 是原本要发生的线程切换次数(大约等价于协程主动让出与恢复的次数)。
    Nswitch_saved\displaystyle N_{\text{switch\_saved}}
  3. 单次差值用线程单次切换耗时减去协程单次切换耗时,差值越大收益越明显。
    Tthread−Tco\displaystyle T_{\text{thread}} - T_{\text{co}}

AI 应用场景

大模型推理服务的动态批处理

阶段:推理服务优化(inference serving)

解决的问题:传统同步批处理会 GPU 空转:一批请求必须等最慢的那一个全部生成完才整体返回。逐请求串行推理吞吐又太低。协程把每个请求的 token 生成过程拆成可挂起的步骤:每个 token 生成完就让出 GPU,调度器立刻让下一个请求补上,实现“拼车式”动态批处理。

具体案例:某开源 LLM 服务同时收到 100 个推理请求,每个请求需串行解码 512 个 token。使用基于协程的 continuous batching 后,GPU 利用率从约 60% 提升到 90% 以上,单卡吞吐 QPS 提升约 1.8 倍,用户端平均首 token 延迟(TTFT)反而下降。

训练数据流水线的异步预取

阶段:训练数据加载(data pipeline)

解决的问题:GPU 训练时,数据加载是同步的话,会出现“CPU 等磁盘、GPU 等 CPU”的双重等待。用协程把读取、解码、数据增强等阶段串成异步流水线:读磁盘时挂起协程去处理别的样本,数据就绪后自动恢复,让数据供给永不掉队。

具体案例:某 CV 训练任务每轮 epoch 需要从分布式存储读取 200GB 图片。将 DataLoader 改为协程异步预取后,CPU 等待磁盘 IO 的空闲时间被用于图像增强计算,训练吞吐从 1200 img/s 提升到 1900 img/s,GPU 利用率提高 25 个百分点。

Python 演示

python37 行
"""极简协作式协程调度器: 一个厨师轮流做多道菜"""import time def dish(name, steps, wait_every):    """一道协程任务: 每执行几步就模拟一次 IO 等待并让出 CPU"""    for i in range(1, steps + 1):        print(f"  [{name}] 正在处理第 {i}/{steps} 步...")        time.sleep(0.1)  # 模拟少量计算        if i % wait_every == 0:            print(f"  [{name}] 需要等待, 主动让出 CPU")            yield "waiting"        else:            yield "ready" def scheduler(dishes):    """协作式调度器: 就绪队列 + 轮流执行"""    ready = list(dishes)    switches = 0    while ready:        gen = ready.pop(0)        name = gen.gi_frame.f_locals.get('name', '?')        switches += 1        print(f"[调度器] 切换到协程 {name}")        try:            status = next(gen)            print(f"[调度器] {name} 让出, 状态={status}")            ready.append(gen)        except StopIteration:            print(f"[调度器] {name} 执行完毕, 出锅!")    print(f"[调度器] 全部完成, 总共切换 {switches} 次") if __name__ == "__main__":    scheduler([        dish("宫保鸡丁", 4, 2),        dish("水煮鱼", 5, 3),        dish("麻婆豆腐", 3, 2),    ])
目的:用 Python 生成器模拟协作式协程调度器,直观展示“就绪队列 + 主动让出 + 恢复执行”的完整流程。
  1. def dish(name, steps, wait_every):定义一道菜(协程任务),参数记录菜名、总步数和每几步等待一次。
  2. yield "waiting"协程的核心:执行到 yield 就把控制权交还调度器,自己挂起。
  3. ready = list(dishes)初始化就绪队列,这是调度器的“传菜轮转表”。
  4. gen = ready.pop(0)从队首取一个协程,对应 fcontext 的 jump_fcontext 跳转。
  5. status = next(gen)驱动协程跑到下一个 yield,相当于恢复其上下文运行。
  6. ready.append(gen)主动让出的协程被放回队尾,等下一轮调度。
  7. except StopIteration:生成器耗尽表示协程生命周期进入 DEAD(出餐完成),不再入队。

常见误区

误区学 Python asyncio 时看到它内部会用线程池跑阻塞函数,就以为协程本质就是线程池封装,换汤不换药。

正解协程和线程池是两套完全不同的机制。线程池里每个任务仍需内核调度,线程切换仍要陷入内核态并可能刷新 TLB;协程则在用户态自调度,单个线程内可挂起成千上万协程。asyncio 中部分阻塞调用用线程池只是为了补救阻塞操作,核心的事件循环仍是单线程协程模型。用比喻讲:线程池是“多请几个厨师”,协程是“一个厨师练就一人多灶”。

为何常见:很多文章把“协程解决并发”和“线程池并发”放在一起对比,且某些框架内部确实混用了二者,初学者容易以偏概全。

误区既然都是保存上下文再切换,那 fcontext 可能只是把 ucontext 的参数改得更简洁了,用法差不多。

正解两者设计哲学完全不同。ucontext 是 POSIX 标准的重量级上下文——保存全部通用寄存器、浮点状态、信号掩码,维护 uc_link 链路;fcontext 是手写汇编的轻量原语——只保存 callee-saved 寄存器、栈指针和返回地址,且要求调用者自己管理栈的创建与释放。fcontext 需要配合汇编入口才能使用,ucontext 是纯 C 接口。

为何常见:开发者通常只看到“都能实现协程切换”的结果,没深入到寄存器保存集合和汇编实现的差异,导致低估了两者的使用成本差异。

误区协程切换只是修改几个用户态变量,随手创建几万个协程也不会有性能问题,多多益善。

正解协程切换虽然只有几十纳秒,但也是纯内存计算的开销。如果任务几乎不等待(纯 CPU 密集),协程切换就是纯浪费——N_yield × T_switch 会随让出次数线性增长。同时协程栈也占用内存,创建太多同样会耗尽内存甚至触发 OOM。比喻:老周再怎么熟手,把菜刀放下又拿起十几万次,手腕也会酸。

为何常见:对比线程切换后觉得“太便宜了”,忽略了它们同样是成本,且没有考虑场景适配。CPU 密集任务用协程是负优化。

误区看完原理文章热血沸腾,以为工作里也必须从汇编开始自己实现切换原语,否则不算“会用协程”。

正解绝大多数业务开发用语言级协程(Python asyncio、Golang goroutine、Rust async/await)就够了。ucontext/fcontext 级别的知识主要用于三处:C/C++ 嵌入式环境需要自己造协程库、阅读高性能框架底层(如 Boost.Context 驱动的 libco)、排查调度器级性能问题。理解底层是为了会用、会调,不必亲自造轮子。比喻:你不用会打铁也能用好菜刀,但懂一点锻造原理,选刀时不会把砍骨刀当菜刀用。

为何常见:技术文章层次跨度大,入门读者容易被底层细节吓到,误以为所有协程开发都要手写汇编。

面试题

Q1. 协程与线程最本质的区别是什么?

单选题 · easy

  • A.用户态自调度,不经过内核← 正确答案
  • B.运行速度快十倍以上
  • C.不需要 CPU 参与执行
  • D.只能在单核机器上跑

答案:A。协程在用户态构造与调度,切换不上内核;线程的创建、调度、切换都由内核负责。B 太笼统(速度取决于场景),C 错误(协程也要 CPU),D 错误(每个核可跑一个调度器管若干协程)。就像一人三灶的厨师依然是人,只是他把换菜动作练到极致。

Q2. ucontext 中负责“创建新协程入口和栈”的 API 是哪个?

单选题 · easy

  • A.getcontext
  • B.setcontext
  • C.makecontext← 正确答案
  • D.swapcontext

答案:C。makecontext 修改由 getcontext 初始化的上下文,指定入口函数和栈空间;getcontext 只负责保存当前状态,setcontext 是整体切换过去,swapcontext 是保存旧上下文并加载新上下文。比喻上,makecontext 是给收纳盒贴菜谱并配好灶台。

Q3. fcontext 的 jump_fcontext 只保存非易失寄存器,原因是?

单选题 · mid

  • A.编译器已将易失寄存器存栈← 正确答案
  • B.非易失寄存器数量少保存快
  • C.内核会自动保存所有寄存器
  • D.fcontext 无需保存任何寄存器

答案:A。易失寄存器(caller-saved)由编译器在函数边界负责压栈保存,函数返回时自然恢复;真正的上下文切换只需保存 callee-saved 寄存器,这是函数调用约定决定的。B 是结果不是原因,C 的内核只在线程切换时保存,D 完全错误。

Q4. 在协作式调度中,一个协程执行了死循环,会发生什么?

单选题 · mid

  • A.操作系统时间片到期强制切换
  • B.其他协程永远得不到执行← 正确答案
  • C.内核会自动杀死该协程
  • D.其他协程跳到别的 CPU 核

答案:B。协作式调度全靠协程自觉让出。死循环不让出,就绪队列里其他协程全部饿死,整个线程卡死。这是与抢占式线程的本质区别:线程有内核时间片强拆,协程没有。老周的轮转表遇到一个倔厨师占灶不撒手,其他菜只能干等。

Q5. 下列哪些是 fcontext 相比 ucontext 的优势?

多选题 · mid

  • A.切换一次耗时更低← 正确答案
  • B.需要保存的寄存器更少← 正确答案
  • C.跨平台无需单独移植
  • D.内存占用更小更紧凑← 正确答案

答案:ABD。fcontext 只保存非易失寄存器+栈指针+返回地址,因此切换快、内存占用小;但它是手写汇编,每种 CPU 架构都要单独实现,C 说法错误。好比定制刀具腰带省去收纳盒的翻找,但每条腰带都得按身材打版。

Q6. Python 的生成器(generator)属于哪种协程模型?

单选题 · easy

  • A.有栈协程(stackful)
  • B.无栈协程(stackless)← 正确答案
  • C.内核线程
  • D.用户态进程

答案:B。生成器基于栈帧保存,共享调用栈,不能在嵌套函数里随意 yield,属于无栈协程。有栈协程每个协程有独立调用栈,可以在深层嵌套中挂起。后者像每张订单有独立小推车,前者像大家共用一张大桌。

Q7. 关于协程栈内存,下列说法正确的有?

多选题 · hard

  • A.有栈协程可自定义栈大小← 正确答案
  • B.无栈协程共享调用栈← 正确答案
  • C.协程栈越大能创建的协程越少← 正确答案
  • D.协程栈只能由内核分配

答案:ABC。有栈协程通常在创建时指定栈大小(如 256KB),无栈协程共享调用栈。可用内存固定时,单栈越大,可创建的协程数越少。协程栈由用户态分配(malloc/mmap),不需要内核参与,D 错误。

Q8. 线程上下文切换比协程切换慢的主要原因是什么?

单选题 · mid

  • A.必须经过内核并可能刷新 TLB← 正确答案
  • B.线程的寄存器数量比协程多
  • C.线程的代码比协程更复杂
  • D.线程切换需要压缩磁盘文件

答案:A。线程切换要陷入内核、内核调度器选线程、切换地址空间导致 TLB 和缓存失效,再回到用户态。协程切换纯用户态只换寄存器与栈指针。两者的寄存器数量本质一样,差异在“要不要找老板审批”。

Q9. 下列哪些场景适合用协程?

多选题 · hard

  • A.高并发的网络 IO 请求← 正确答案
  • B.纯 CPU 密集的科学计算
  • C.大量短连接的消息推送← 正确答案
  • D.长时间独占 CPU 的模型训练

答案:AC。高并发网络 IO 和短连接推送都是等待密集型,协程收益大;纯 CPU 计算和独占 GPU 训练几乎没有等待,协程切换反而增加负担。判断标准是“等得越多,协程越划算”——老周的见缝插针只对等待多的菜有效。

Q10. 在 fcontext 的 x86_64 汇编实现中,跳转核心使用哪条指令?

单选题 · extreme

  • A.用 ret 指令弹出返回地址跳转← 正确答案
  • B.用 int 0x80 陷入内核切换
  • C.用 syscall 触发系统调用
  • D.用 jmp 指令直接跳转目标

答案:A。fcontext 在保存/恢复寄存器后,目标栈顶已经预置了返回地址,最后用 ret 把它弹入 RIP 完成无痕跳转。int 0x80 与 syscall 是系统调用,会陷入内核,与协程用户态切换的定位背道而驰。这是 fcontext 汇编级优化的点睛之笔。