深夜的“AI 小馆”后厨,只亮着一盏灶灯。主厨老周一个人要同时对付三张订单:左边锅里炖着高汤,中间铁板上的牛排正在收汁,右手边还在切配菜。放在普通餐厅,这得请三个厨师(线程),每人一个灶台,老板(操作系统内核)还得派个领班专门调度——人多、工位杂、成本高。 老周的秘诀是“见缝插针”:高汤要炖 10 分钟,这 10 分钟他不可能干瞪眼——把汤勺放下(保存上下文),火速去切配菜;切到一半听到牛排滋啦响(IO 就绪),又放下菜刀,回到铁板前拿起夹子(恢复上下文)。每道菜之间切换,全靠脑子里记得“刚才切到第几刀、盐罐放在左手边”。协程(Coroutine)就是这么回事:在同一个线程(厨师)内部,多个任务(订单)交替推进,谁遇到等待就主动让出 CPU,等时机到了再回来接着干。 这篇文章要带你走进协程的“后厨工作台”:先看协程是什么,再看它怎么记住“切到第几刀”(上下文切换机制),然后比较两套“记忆工具”——标准化的 ucontext(塑料收纳盒)和极致轻量的 fcontext(定制刀具腰带),最后聊聊调度器、性能账本和真实工程里的坑与药。
理解协程切换原理,是读懂高性能 AI 推理框架(vLLM、LLaMA.cpp、Starlette)内功心法的关键;对 AI 工程师而言,这意味着你能精准定位并发瓶颈、优化数据管线,而不是只会调参。
目录
可视化演示
展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。
本节配有交互动效(进入视口后自动加载,约 3KB)。
系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T
1. 协程基础概念
老周的订单本上,每张订单都记着:几号桌、点了什么、做到哪一步。有的订单刚写下来(新建),有的配好料等着下锅(就绪),有的正占着灶台(运行中),有的在炖煮等时间(挂起),有的已经端上桌(完成)。一张订单就是一小节“任务”,它知道自己该干嘛、干到哪了。协程(Coroutine)就是能被挂起又能恢复的“函数型任务”——执行到一半,把现场记下来,先干别的,之后再回来接着干。
在操作系统课程里,线程是内核调度的最小单位,但协程并不被内核认识——它是用户态构造出来的“假线程”。真线程切换要请内核帮忙(陷入内核态做一堆簿记工作),协程切换则是程序自己控制的。协程最常见的形态有两种:有栈协程(stackful,每个协程有自己的调用栈,像每张订单有自己的配料小推车)和无栈协程(stackless,共享一个栈,像大家共用一张大桌,切换时只能留下“做到哪了”的简单记号,Python 的生成器就是这种)。
无论哪一种,协程都要经历五态生命周期:创建(CREATE)→ 就绪(RUNNABLE)→ 运行(RUNNING)→ 挂起(SUSPENDED)→ 死亡(DEAD)。挂起态是关键创新:线程被操作系统抢占时也“挂起”,但那是被迫的;协程的挂起是自愿的。协程模型的总耗时可以写成“干活时间 + 切换损耗”:
理解了订单的旅程,就理解了协程为什么能“一个人干三张订单”。订单越多、等待越多,老周见缝插针的收益就越大;但每次放下菜刀再拿起新刀,总归要花时间,所以订单流转策略(调度)在后面会起到决定性作用。
- 总耗时符号T_total 表示完成这 n 个协程所有任务花掉的总时间,单位通常是微秒或毫秒。
- 累加干活时间从 1 到 n 把所有协程真正占用 CPU 执行的时间求和,这是“纯收益”部分。
- 切换次数的代价N_switch 是运行期间发生的切换总次数,每次切换都要付出 T_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 寄存器),像教练反复练过的肌肉记忆。下一节我们分别打开这两套工具箱。
- 切换总开销C_ctx 代表一次上下文切换要付出的总时间成本,这在高性能服务里是按纳秒计量的。
- 寄存器搬运量R_callee 是需要保存的被调用者保存寄存器数量,乘以每个寄存器的位宽 w_word(64 位机器上通常 8 字节),得到存档的内存搬运字节数。
- 固定开销O_fixed 包括栈指针改写的栈切换、指令跳转的流水线刷新、缓存(TLB)失效等不可避免的常数时间。
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 出现的理由。
- 收纳盒总耗时T_ucontext 表示通过 ucontext 完成一次协程切换的总耗时。
- 存档耗时T_save 是把当前 CPU 寄存器组写入 ucontext_t 的 uc_mcontext 字段的时间,和寄存器数量成正比,ucontext 会保存全部通用寄存器并带上浮点状态。
- 读档耗时T_restore 是从目标上下文的 uc_mcontext 里恢复寄存器、栈指针和指令指针的时间。
- 链路维护耗时T_link 是 ucontext 特有开销——切换前要检查 uc_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 等每种架构各写一套,否则无法运行。每多支持一个平台,就多一份“肌肉记忆”要训练;这正是“快”与“通用”之间的永恒取舍。
- 加速比S_speedup 表示 fcontext 相对 ucontext 的提速倍数,它是后面性能分析中的关键指标。
- 分子(收纳盒耗时)T_ucontext 是 ucontext 完成一次完整切换的耗时,包含保存所有寄存器与链路检查。
- 分母(腰带耗时)T_fcontext 是 fcontext 完成切换的耗时,只保存少数非易失寄存器。
5. 调度器设计
老周一个人忙不过来的秘诀不只是换刀快,还在于他脑子里有张“传菜轮转表”:哪道菜在等汤、哪道菜快出锅、哪道菜必须马上翻面。他不会等到锅烧干了才想起来。这套“临场安排”的心思,放在程序里就是调度器(scheduler)。
调度器维护一个就绪队列(ready queue),存放所有状态为 RUNNABLE 的协程。调度器按某种策略从中选一个协程运行;该协程运行中主动 yield(交出 CPU),或遇到 IO 阻塞,就挂起自己并让调度器切换到下一个就绪协程。因为切换完全由用户态代码控制,没有内核抢占,所以这种模型叫协作式调度(cooperative scheduling)。
yield 的语义是“从 RUNNING 回到 RUNNABLE(或 WAITING),并且不用保存内核栈”。典型的事件循环(event loop)就是调度器的具体化:一个 while 循环里做三件事——处理就绪队列里的协程、检查 epoll 是否有 IO 事件就绪、把被事件唤醒的协程重新插入就绪队列。调度器设计的目标是最大化系统吞吐:
传菜轮转表的核心是“知道自己何时该让、何时该回来”。协作式调度就像老周完全靠自觉:如果某道菜死占着灶台不撒手(死循环不让出),其他订单全都干瞪眼。所以,用好协程的第一个工程准则是:不要在协程里放太长的纯计算任务。
- 吞吐量指标QPS 是每秒可以处理完毕的请求数,是衡量调度器效率最直接的标尺。
- 平均干活时长\bar{T} 的下标 exec 表示单个请求从开始到结束实际使用 CPU 的平均时间,不包含等待。
- 让出次数成本N_yield 是单个请求运行期间主动让出 CPU 的次数,乘以单次切换成本 T_switch,得到请求因切换而额外付出的总时间。
- 倒数操作对总耗时求倒数,把“每个请求耗时”换算成“每秒能完成的请求数”。
6. 性能分析与对比
开连锁店前,老周要算一笔账:是多请几位厨师(多线程)划算,还是让每位厨师都练就“一人三灶”的本事(协程)划算?请厨师要开工资(内核线程成本)、要扩后厨面积(栈内存)、要找经理协调(内核调度);而让厨师自己切换,只损失换刀的那几十纳秒。账算清了,才能决定什么时候上协程。
两类开销是协程性能分析的核心:切换开销和内存占用。线程切换:用户态保存寄存器 → 陷入内核 → 内核调度器选线程 → 切换地址空间/TLB → 回到用户态恢复寄存器。完整流程大约 1~10μs,且依赖内核锁与调度队列。协程切换:纯用户态,只保存若干寄存器,约 10~30ns,差两个数量级。内存上:线程默认栈 8MB(Linux 为 8MB 虚拟、按需提交),协程可自定义栈 64KB~1MB(典型 256KB),且总内存可精细控制。
以 8GB 内存跑一个 IO 密集型服务为例:每个线程要 8MB 栈加内核 task_struct 等,最多并发数千;每个协程给 256KB 栈,加运行时一个调度器节点约几百字节,可以轻松挂起几十万协程。更通用地写成:
这笔账算完,老周心里有底了:主打“短平快”的订单流转,协程就是省钱利器;可要是碰上需要长时间占用灶台的硬菜(CPU 密集),多请几个厨师反而更快——协程也解决不了 CPU 算力不足的问题。性能优化的本质,是把有限的灶台资源用在刀刃上。
- 并发上限N_max 是这台机器最多能同时支撑的并发任务(协程或线程)数量。
- 可用内存M_ram 是应用能支配的总内存,单位可以是 MB 或 GB,分子分母单位需一致。
- 栈内存M_stack 是单个任务分配到的栈大小,线程通常是 8MB,协程可调至 64KB~1MB。
- 元数据内存M_meta 是除栈外每个任务额外占用的控制块内存(如 ucontext_t 结构体、调度器节点等)。
7. 工程实践与优化
老周把后厨秘诀写成《后厨 SOP》后,连锁店遇到一个新问题:外卖平台高峰期,几百单同时进来,怎么保证不糊锅?答案是:把“一人三灶”的功夫用在等待密集型菜品上——炖汤这种要干等的事让给协程排队,牛排这种占锅的硬菜才配专属灶台。协程工程化的精髓,就是分清“等”与“算”。
在工程里,IO 密集型场景(网络请求、磁盘读写、数据库访问)天然适合协程:发起请求后,协程挂起,等 epoll 返回事件再被唤醒,期间不占 CPU。而 CPU 密集型场景(重计算、模型推理纯计算部分)用协程收益很低,甚至会因切换而变慢。现代 AI 推理服务中,GPU 是稀缺“灶台”,而数据加载、tokenizer、HTTP 解析都是典型的 IO/CPU 混合等待,正好用协程搭异步流水线。
工程中的常见组合拳是「epoll + 协程」:用非阻塞 IO 替代阻塞 IO,配合事件循环调度器,把传统为每个连接开一个线程的模式,改成百万连接共享少量线程的模式。在 AI 推理服务中,vLLM 的 continuous batching 就用异步调度把“单请求串行推理”变为“多请求交错推理”,显著提高了 GPU 利用率。总的收益可写为:
SOP 的最后一条写着:再趁手的刀具也怕遇到劣质砧板——千万别在协程里直接调用阻塞式读写(如 read、sleep、mutex 加锁),否则整个事件循环都会被卡住。协程是“将等待变成主动让位”的艺术,所有等待都必须交给调度器来等。理解了这句话,你就算真正入了协程的门。
- 节省总时长T_saved 表示在一段运行时间内,把线程协作换成协程协作后省出的 CPU 时间总量。
- 可省切换次数N_switch_saved 是原本要发生的线程切换次数(大约等价于协程主动让出与恢复的次数)。
- 单次差值用线程单次切换耗时减去协程单次切换耗时,差值越大收益越明显。
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 演示
"""极简协作式协程调度器: 一个厨师轮流做多道菜"""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), ])
def dish(name, steps, wait_every):定义一道菜(协程任务),参数记录菜名、总步数和每几步等待一次。yield "waiting"协程的核心:执行到 yield 就把控制权交还调度器,自己挂起。ready = list(dishes)初始化就绪队列,这是调度器的“传菜轮转表”。gen = ready.pop(0)从队首取一个协程,对应 fcontext 的 jump_fcontext 跳转。status = next(gen)驱动协程跑到下一个 yield,相当于恢复其上下文运行。ready.append(gen)主动让出的协程被放回队尾,等下一轮调度。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. 协程与线程最本质的区别是什么?
- A.用户态自调度,不经过内核← 正确答案
- B.运行速度快十倍以上
- C.不需要 CPU 参与执行
- D.只能在单核机器上跑
答案:A。协程在用户态构造与调度,切换不上内核;线程的创建、调度、切换都由内核负责。B 太笼统(速度取决于场景),C 错误(协程也要 CPU),D 错误(每个核可跑一个调度器管若干协程)。就像一人三灶的厨师依然是人,只是他把换菜动作练到极致。
Q2. ucontext 中负责“创建新协程入口和栈”的 API 是哪个?
- A.getcontext
- B.setcontext
- C.makecontext← 正确答案
- D.swapcontext
答案:C。makecontext 修改由 getcontext 初始化的上下文,指定入口函数和栈空间;getcontext 只负责保存当前状态,setcontext 是整体切换过去,swapcontext 是保存旧上下文并加载新上下文。比喻上,makecontext 是给收纳盒贴菜谱并配好灶台。
Q3. fcontext 的 jump_fcontext 只保存非易失寄存器,原因是?
- A.编译器已将易失寄存器存栈← 正确答案
- B.非易失寄存器数量少保存快
- C.内核会自动保存所有寄存器
- D.fcontext 无需保存任何寄存器
答案:A。易失寄存器(caller-saved)由编译器在函数边界负责压栈保存,函数返回时自然恢复;真正的上下文切换只需保存 callee-saved 寄存器,这是函数调用约定决定的。B 是结果不是原因,C 的内核只在线程切换时保存,D 完全错误。
Q4. 在协作式调度中,一个协程执行了死循环,会发生什么?
- A.操作系统时间片到期强制切换
- B.其他协程永远得不到执行← 正确答案
- C.内核会自动杀死该协程
- D.其他协程跳到别的 CPU 核
答案:B。协作式调度全靠协程自觉让出。死循环不让出,就绪队列里其他协程全部饿死,整个线程卡死。这是与抢占式线程的本质区别:线程有内核时间片强拆,协程没有。老周的轮转表遇到一个倔厨师占灶不撒手,其他菜只能干等。
Q5. 下列哪些是 fcontext 相比 ucontext 的优势?
- A.切换一次耗时更低← 正确答案
- B.需要保存的寄存器更少← 正确答案
- C.跨平台无需单独移植
- D.内存占用更小更紧凑← 正确答案
答案:ABD。fcontext 只保存非易失寄存器+栈指针+返回地址,因此切换快、内存占用小;但它是手写汇编,每种 CPU 架构都要单独实现,C 说法错误。好比定制刀具腰带省去收纳盒的翻找,但每条腰带都得按身材打版。
Q6. Python 的生成器(generator)属于哪种协程模型?
- A.有栈协程(stackful)
- B.无栈协程(stackless)← 正确答案
- C.内核线程
- D.用户态进程
答案:B。生成器基于栈帧保存,共享调用栈,不能在嵌套函数里随意 yield,属于无栈协程。有栈协程每个协程有独立调用栈,可以在深层嵌套中挂起。后者像每张订单有独立小推车,前者像大家共用一张大桌。
Q7. 关于协程栈内存,下列说法正确的有?
- A.有栈协程可自定义栈大小← 正确答案
- B.无栈协程共享调用栈← 正确答案
- C.协程栈越大能创建的协程越少← 正确答案
- D.协程栈只能由内核分配
答案:ABC。有栈协程通常在创建时指定栈大小(如 256KB),无栈协程共享调用栈。可用内存固定时,单栈越大,可创建的协程数越少。协程栈由用户态分配(malloc/mmap),不需要内核参与,D 错误。
Q8. 线程上下文切换比协程切换慢的主要原因是什么?
- A.必须经过内核并可能刷新 TLB← 正确答案
- B.线程的寄存器数量比协程多
- C.线程的代码比协程更复杂
- D.线程切换需要压缩磁盘文件
答案:A。线程切换要陷入内核、内核调度器选线程、切换地址空间导致 TLB 和缓存失效,再回到用户态。协程切换纯用户态只换寄存器与栈指针。两者的寄存器数量本质一样,差异在“要不要找老板审批”。
Q9. 下列哪些场景适合用协程?
- A.高并发的网络 IO 请求← 正确答案
- B.纯 CPU 密集的科学计算
- C.大量短连接的消息推送← 正确答案
- D.长时间独占 CPU 的模型训练
答案:AC。高并发网络 IO 和短连接推送都是等待密集型,协程收益大;纯 CPU 计算和独占 GPU 训练几乎没有等待,协程切换反而增加负担。判断标准是“等得越多,协程越划算”——老周的见缝插针只对等待多的菜有效。
Q10. 在 fcontext 的 x86_64 汇编实现中,跳转核心使用哪条指令?
- A.用 ret 指令弹出返回地址跳转← 正确答案
- B.用 int 0x80 陷入内核切换
- C.用 syscall 触发系统调用
- D.用 jmp 指令直接跳转目标
答案:A。fcontext 在保存/恢复寄存器后,目标栈顶已经预置了返回地址,最后用 ret 把它弹入 RIP 完成无痕跳转。int 0x80 与 syscall 是系统调用,会陷入内核,与协程用户态切换的定位背道而驰。这是 fcontext 汇编级优化的点睛之笔。