想象一间开放办公室:程序员小张和小李各自有一张工位,正在处理不同的需求。桌上摊着一摞文件,最上面那份就是正在读的;手边的便利贴记着几个关键数字。线程切换,像是要让整个办公室重排——操作系统管理员出面,把所有文件、便利贴、桌椅全部搬走归档,再搬进另一位的全部家当,动静极大。协程切换,则像是同事之间的小默契:小张把自己的便利贴抄进随身小本子,记住文件堆读到哪一份、哪一行,然后把小李的文件堆推过来,继续帮他处理到约定位置。整个过程不惊动管理员,几纳秒就已换岗完毕。这篇文章要讲的,就是“协程换岗”背后的机制:栈是什么、切换时到底保存什么、有栈与无栈两种协程各怎么设计,以及它为什么能支撑起十万级并发。
理解协程切换与栈保存机制,能让你在高并发 AI 服务中正确选择协程模型,看懂底层调度原理,不再被“协程比线程快”这句结论的黑盒所困扰。
目录
可视化演示
展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。
本节配有交互动效(进入视口后自动加载,约 3KB)。
系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T
协程基础概念:办公室里的自觉换岗
想象一间开放办公室,十几位同事共用几张工位。没有管理员指挥,大家自觉遵守一条规矩:谁坐上去,做完一小段就起身,让下一位接着做。甲写两行方案,乙填三行表格,丙只处理一步审批……看起来零散,整体进度却稳步推进。这种“自觉让位、轮流推进”的分工方式,就是协程的雏形。
协程(coroutine)是一种由程序员自己控制调度、在用户态完成的并发单元。它不依赖操作系统时钟中断来强行打断,而是主动调用 yield(让位)挂起自己,等时机成熟再由调度器 resume(恢复)。yield 与 resume,就是协程世界的呼吸:一呼一吸之间,执行权在多个协程之间交接,谁都不霸占工位。
相比线程的抢占式调度,协程的协作式调度有两大优势。其一,切换时机可控——协程只在明确的挂起点让位,不会在任意指令处被打断,因此保存现场的成本是固定的;其二,切换成本极低——不必陷入内核,保存恢复的只是少量寄存器。直接结果是并发规模的数量级提升:一台 8GB 内存的机器,线程因每个默认 8MB 栈很快耗尽资源,只能开几千个;协程却能开到几十万甚至上百万个。
小步换岗法为什么能容纳更多人?因为每个人占用的家当太少——一张待办卡片就能排队,而线程这“大桌子”摆不下几套。
- 协程数量 N 下限与上限表示单机可同时创建的协程数量,对应办公室能容纳的“待办卡片”数。
- 约等于号 \sim读作“约等于”,表示数量级估计而非精确数值。
- 十万到百万量级协程切换轻、占用内存小,所以能支撑海量并发。
- 线程数量受内核资源、调度器与 8MB 默认栈限制,通常只有几千到几万。
栈与函数调用:工位上的一摞文件
每张工位上都有那么一摞文件:正在处理的项目 A 在最上面,下面压着之前没看完的项目 B、C。处理任何一件事时,你总是从最上面那份开始;做完合上放一边,继续处理下一份。这一摞“后进先出”的文件,就是程序世界的栈(stack)。
函数调用,就是往这摞文件上放一份新文件。每次调用都会在栈上开辟一块区域,叫栈帧(stack frame),里面装着:参数、返回地址、局部变量、上一层帧指针。返回地址最是关键——它记录“这份文件看完后,回到上一份文件的哪一行继续”。调用约定(ABI)则规定了这些文件栏目的格式:参数怎么传、谁来清理、返回地址放哪一格。call 指令把返回地址压栈并跳转,ret 指令弹出返回地址放回 PC(程序计数器)。栈指针 SP 永远指向栈顶——就像你的手指,始终停在那份正在读的文件上。
理解了文件堆的规则,协程切换的秘密就揭开一半了:当你想从工位起身去隔壁帮忙,必须保证这摞文件原封不动——这正是“保存栈”的含义。
- 栈帧结构一次函数调用的“档案袋”,装着参数、返回地址、局部变量和上一层帧指针。
- SP 下移调用发生时栈向下增长,SP 指向新栈顶。
- 返回地址的来源call 指令自动把下一条指令地址压栈保存。
- PC 恢复ret 指令弹出栈顶的返回地址,写回 PC 继续执行。
上下文切换:换岗前记三样东西
在小步换岗制度下,起身之前你必须完成三件事:把手边便利贴上的关键数字抄进随身小本子(寄存器);记住手指停在文件堆的哪一份(栈指针);记住正看到那一页的第几行(程序计数器)。三样记完,才算“保存现场”。
这三样东西合起来,叫执行上下文(context)。寄存器是 CPU 内部的高速暂存单元,保存当前运算的中间值,像便利贴上随手写的关键数字;栈指针 SP 指向当前栈顶帧,像停在第几份文件;程序计数器 PC 指向下一条要执行的指令,像读到第几行。把三者写入一个上下文结构体,是保存现场;反向从结构体加载回寄存器、设置好 SP 与 PC,是恢复现场。一次协程 switch 切换,本质就是“保存旧协程现场 + 恢复新协程现场”——在汇编层面只有几十条指令。
“小本子”就是全部家当。正因为要抄写的东西极少,协程换岗才能快到让人毫无察觉。
- 上下文集合把恢复执行所需的最小状态打包成一个对象。
- 通用寄存器 R_i保存当前运算的中间值,像手边便利贴上的数字。
- 栈指针 SP指向当前栈顶帧,像手指停在文件堆的位置。
- switch 的完整动作先抄下 A 的现场,再把 B 的现场摆出来。
有栈协程:每人一张专属工位
有栈协程的方案很直白:给每个协程配一张独立的小工位。工位不用大,够放下自己的文件堆就行;再配一个小本子(上下文对象)。换岗时,把旧工位的文件堆原封不动留好,揣上小本子走向下一张工位——名下那张桌子永远等着你回来。
有栈协程(stackful coroutine)在创建时通过 malloc 分配一块独立栈,常见 64KB 到 1MB。切换流程分两步:switch 把当前寄存器组、SP(旧栈帧位置)、PC(挂起位置)写入协程 A 的上下文对象;然后让 SP 指向协程 B 的栈顶、PC 指向 B 上次的恢复点,直接跳转执行。resume 本质是一次目标为 B 的 switch。因为拥有完整调用栈,有栈协程可以在任意深度的嵌套函数中挂起——哪怕在第三层函数里 yield,整条调用链都安然保存。
换桌子的速度取决于本子有多厚——而有栈协程的“本子”只有几十个字,所以再深的调用链也能瞬间换岗。
- 上下文对象每个有栈协程都持有自己的小本子,记录寄存器、SP、PC。
- 独立栈分配协程创建时申请一块私有栈区,相当于自己的文件抽屉。
- switch 换栈把 SP 换成目标协程的栈顶,从此压栈落在 B 的工位上。
- resume 恢复执行把 PC 设为 B 上次挂起的地址,B 从断点继续。
无栈协程:共享长桌上的一页待办清单
无栈协程换了个思路:大家共用一张长桌(调用者的栈),每人只拿一张待办卡片,上面写着“做到第几步”。轮到你时,按卡片上的步骤号从清单里找出对应的步骤继续走——谁也不需要一张专属桌子。
无栈协程(stackless coroutine)在编译期被编译器改写成一台状态机:函数体被拆成若干代码段,每个 yield 对应一个状态分支;原先的局部变量全部变成状态结构体的字段,不再依赖调用栈保存。运行时它复用调用者的栈,在别人的栈帧上执行,因此不需要独立栈内存。代价是挂起只能发生在协程函数体自身的 yield 处——被调用的普通函数里不能中途挂起整条协作链。换来的是极小的内存占用:一个状态对象往往只有几十字节。
一张卡片 vs 一抽屉文件——无栈协程把省字发挥到极致,代价是不能随意在嵌套函数深处挂起。
- 状态机改写编译器把协程函数拆成若干带编号的代码段。
- 状态结构体挂起需要的信息全部浓缩进一个对象。
- 状态变量 k记录当前执行到第几个挂起点,相当于待办清单的“打完勾的序号”。
- resume 跳转按编号找到对应代码段继续执行,并推进到下一个状态。
协程与线程对比:换岗的账本
现在给办公室算一笔账。线程切换像惊动管理员的大搬家:系统调用陷入内核、调度器选人、特权级切换、刷新缓存,动静大、时间长;协程切换像同事间递个眼色的小换岗:纯用户态几十条指令,保存恢复寄存器、换 SP、跳 PC,干净利落。
账本上有两组数字最能说明差距。耗时方面,线程切换约 1~10 微秒,协程切换约 20~200 纳秒,相差一到两个数量级;内存方面,线程默认栈 8MB,几千个就把内存吃光,有栈协程栈 64KB、无栈协程只要几十字节,十万并发轻松拿下。这也决定了选型:IO 密集、请求量大、等待多的场景(网络服务、推理网关)选协程;CPU 密集、要吃满多核并行的计算场景,仍要用线程做并行,再配合协程处理每核上的高并发。
账本翻到最后,结论清晰:协程解决的是“等得多”的问题,线程解决的是“算得狠”的问题,两者常常手拉手出现。
T_{\text{线程切换}} \approx 1{\sim}10\,\mu s, \quad T_{\text{协程切换}} \approx 20{\sim}200\,ns; \qquadM_{\text{线程栈}} = 8\,\text{MB}, \quad M_{\text{协程栈}} = 64\,\text{KB}- 线程切换耗时内核调度涉及特权级切换与调度算法,微秒级。
- 协程切换耗时用户态几十条指令,纳秒级完成。
- 线程栈内存每个线程默认 8MB 栈,内存是并发规模的天花板。
- 协程栈内存有栈协程 64KB,无栈协程更是只需要几十字节状态。
实践应用:让换岗制度落地
小步换岗从办公室默契变成公司制度,靠的是调度员和排班表。落到工程里,这个“调度员”就是协程库:从 libco 的 switch 汇编、Go 的 goroutine 调度器,到 Python asyncio 的事件循环,内核都是同一件事——在恰当的时机 yield,在条件满足时 resume。
最典型的落地是异步 IO 结合:一个网络服务有上万个连接在等数据,传统线程模型让每个线程傻等 recv 返回;协程模型则让协程在等待时挂起,把 CPU 让给其他就绪协程,数据到达再由事件循环(epoll/kqueue)唤醒。性能调优的要点也随之明确:不要让协程里出现阻塞调用(如同步磁盘 IO),否则整条协作链被卡死;控制协程数量不要无脑开满;用有栈协程应对需要嵌套挂起的复杂调用链,用无栈协程压缩内存开销。
好制度让每个人各司其职、不空等。理解了栈与协程的底层原理,你再看框架的并发模型时,就不再是黑盒——而是那间办公室里,一次漂亮的换岗。
- 总吞吐量单位时间内完成 N 个任务的平均速率。
- 切换总损耗每个任务都要经历若干次换岗,成本与次数成正比。
- 有效执行时间所有任务真正处理业务的时间之和,这是无法压缩的下限。
AI 应用场景
大模型推理网关的万级并发
阶段:模型服务部署与在线推理
解决的问题:大模型 API 网关需要同时支撑成千上万并发请求。若每请求占一个线程,几千个线程就把内存吃光,且频繁陷入内核调度让 GPU 等待空转。改用协程后,请求在等待上游推理或网络响应时挂起,把 CPU 让给其他请求,最大化吞吐。
具体案例:某 LLM 推理网关用 Python asyncio + 连接池实现,协程在等待模型服务返回时 yield,单节点稳定支撑 1 万并发,P99 延迟无明显劣化;同样的请求量,线程池模型需要 3 倍机器。
特征流水线的并行拉取
阶段:数据采集与特征工程
解决的问题:推荐系统特征工程要并行拉取上百个上游特征服务的数据,传统“一线程一连接”方式线程数量爆炸、等待时间线性累加。用协程让所有网络等待重叠在一起,流水线总耗时被大幅压缩。
具体案例:某推荐团队用协程并发请求 200 个特征源,总耗时从串行的 800ms 降到并发等待的 80ms,且只占用 4 个线程承载调度,内存占用下降 90%。
Python 演示
class StackfulCoroutine: """有栈协程:每个协程拥有独立栈与上下文对象""" def __init__(self, name, max_dep): self.name = name self.stack = [] # 独立栈:保存自己的栈帧 self.pc = 0 # 程序计数器:已执行的调用数 self.max_dep = max_dep def call(self, n=1): """模拟 n 次函数调用,每次压入一个栈帧""" for _ in range(n): if self.pc >= self.max_dep: return False self.pc += 1 self.stack.append("frame" + str(self.pc)) return True def status(self): top = self.stack[-1] if self.stack else "空" return f"[{self.name}] pc={self.pc} 栈深={len(self.stack)} 栈顶={top}" def switch(a, b): """仿 switch:保存 a 现场(栈留在对象里),恢复 b 现场""" print(f"{a.status()} -> 保存现场") print(f"{b.status()} -> 恢复现场,继续执行") def stackless_gen(name, steps): """无栈协程:生成器被编译器改写成状态机""" k = 0 while k < steps: k += 1 yield k # 挂起点:状态 k 保存在生成器对象内部 return k def main(): print("=== 有栈协程:switch 切换 ===") a = StackfulCoroutine("A", 4) b = StackfulCoroutine("B", 3) a.call(2) b.call(1) switch(a, b) # A 挂起,B 恢复 a.call(1) print(a.status(), "-> A 的栈原样保留,继续压栈") print("\n=== 无栈协程:状态机推进 ===") g1 = stackless_gen("C", 3) g2 = stackless_gen("D", 2) print("C 执行一步:", next(g1)) print("D 执行一步:", next(g2)) print("C 再从挂起点恢复:", next(g1)) if __name__ == "__main__": main()
self.stack = []每个有栈协程的“独立栈”,用列表模拟,切换时原样保留self.stack.append("frame" + str(self.pc))调用函数即向自己的栈压入一个栈帧def switch(a, b)仿 switch:打印 A 的现场被保存、B 的现场被恢复yield k无栈协程的挂起点,状态 k 由生成器对象内部保存,不占独立栈next(g1)一次 resume 操作,让协程从上次挂起的位置继续执行if __name__ == "__main__": main()可执行入口,直接运行观察两种协程模型
常见误区
误区我觉得协程就是线程的轻量版,怎么切换、什么时候切,都是操作系统说了算,只是占的资源少一点而已。
正解协程与线程的本质区别在调度权归属。线程是内核态抢占式调度,时钟中断一到,操作系统强制换人;协程是用户态协作式调度,由程序自己的调度器决定,切换只发生在 yield/resume 时,是一段不进入内核的用户态汇编代码。用办公室比喻:线程切换是大搬家要惊动管理员,协程切换是同事递个眼色就换了工位。
为何常见:从使用者视角,两者都能实现“多个任务交替执行”,很容易把“看起来像”当成“本质相同”。
误区无栈协程既然叫“无栈”,切换时肯定啥都不用保存,直接跳走就行。
正解“无栈”指的是不持有独立调用栈,运行时复用调用者的栈;但执行状态仍然要保存——只是量小得多:程序计数器对应状态机的分支编号 k,局部变量变成状态结构体的字段,整体只有一个几十字节的状态对象。它要带走的不是一抽屉文件,而是一页写满步骤号的待办清单。
为何常见:字面理解“无栈”二字,把“没有独立栈”误解为“没有状态需要保存”。
误区反正都要保存寄存器、换栈,有栈协程切换应该和线程切换一样耗时。
正解线程切换要陷入内核、执行调度算法、刷新 TLB 与 cache,约 1~10 微秒;有栈协程切换只是用户态十几到几十条指令:保存恢复寄存器组、换 SP、跳 PC,约 20~200 纳秒,相差一到两个数量级。它不需要操作系统介入,也就没有特权级切换和内核数据结构加锁。
为何常见:人们容易把“都要保存现场”等价为“成本一样”,忽略了内核态与用户态的鸿沟。
误区协程又快又省,以后写并发全用协程,线程可以淘汰了。
正解协程是并发(单核多任务交替)的工具,不是并行(多核同时计算)的工具。CPU 密集型计算想利用多核,仍需要线程/进程分配到不同核心;IO 密集且高并发的场景才是协程的主场。实际工程常用“线程池 + 协程”的混合模型:少量线程承载并行,协程在用户态完成高并发。
为何常见:各种宣传都强调协程的“快”与“轻”,让人误以为它全面优于线程,忽略了并行能力这个维度。
误区每次 resume 协程,都是从头开始执行它的函数体,就像重新调用一次函数。
正解resume 不是重跑,而是从上次 yield 的挂起点继续。yield 时已经把 PC(或状态机编号 k)保存下来,恢复现场后直接跳回挂起点的下一行。用办公室比喻:离开工位时本子上记了“看到第 37 行”,回来后翻开第 37 行继续读,而不是从头读文件。
为何常见:日常编程中“重新调用一个函数”是最常见的心智模型,而“中途继续”这种执行方式需要新的直觉,容易套用旧模型。
面试题
Q1. 协程切换与线程切换最本质的区别是什么?
- A.协程切换必须陷入内核完成
- B.协程切换完全在用户态完成← 正确答案
- C.线程切换不需要保存任何数据
- D.协程切换不需要切换执行流
答案:B。协程是用户态协作式切换,由程序自己的调度器控制,不陷入内核;线程是内核态抢占式切换。B 正确。D 错误,协程切换当然要改变执行流;A 说反了;C 说反了,线程切换恰恰要保存大量现场。
Q2. 有栈协程的最大特征是什么?
- A.所有协程共享同一块调用栈
- B.每个协程拥有独立调用栈← 正确答案
- C.协程运行时不保存程序计数器
- D.有栈协程必须依赖多核 CPU
答案:B。有栈协程创建时为每个协程分配独立栈,切换时更换 SP 指向,这就是“每人一张专属工位”。A 是无栈协程的特征;C 错误,任何协程都要保存 PC;D 无中生有,协程单核也能运行。
Q3. 协程执行 switch 切换时,栈指针 SP 的作用是什么?
- A.把新协程栈顶地址载入 SP← 正确答案
- B.清空旧协程的全部栈空间
- C.把局部变量复制到全局常量区
- D.让新协程重跑函数第一条语句
答案:A。switch 的核心动作之一就是把 SP 换成目标协程的栈顶地址,此后压栈退栈都发生在 B 的独立栈上。B 错误,旧协程栈要原样保留待恢复;C 与栈无关;D 混淆了 SP 与 PC 的职责。
Q4. 无栈协程挂起后,执行到哪一步保存在哪里?
- A.保存在内核线程控制块中
- B.保存在状态机的状态变量里← 正确答案
- C.保存在协程私有的独立栈顶
- D.保存在操作系统页表项中
答案:B。无栈协程由编译器改写成状态机,状态变量 k 记录当前到达第几个挂起点,局部变量也是状态结构体字段。它不持有独立栈,所以不存在于 C;也不经过内核,排除 A、D。
Q5. 协程上下文切换通常需要保存哪些内容?
- A.通用寄存器组的当前值← 正确答案
- B.栈指针 SP 的当前指向← 正确答案
- C.程序计数器 PC 的数值← 正确答案
- D.显示器等外设的状态
答案:ABC。上下文 = 寄存器组 + SP + PC,这是恢复执行所需的最小状态集合,对应便利贴、手指位置、阅读行数三样东西。外设状态由操作系统管理,与协程切换无关。
Q6. 协程相比线程在并发规模上更优的主要原因是?
- A.协程能利用多核 CPU 并行运算
- B.协程切换轻量且占用内存更小← 正确答案
- C.协程的调度由内核抢占完成
- D.协程运行时会额外分配大块内存
答案:B。协程切换只在用户态保存少量寄存器,栈也只有几十字节到 64KB,所以单机能开十万级协程,而线程受 8MB 栈和内核资源限制只有几千。A 是并行,协程不做并行;C 说反了;D 说反了。
Q7. 关于有栈协程与无栈协程,下列说法正确的有?
- A.有栈协程切换时需要更换栈指针← 正确答案
- B.无栈协程复用调用方的调用栈← 正确答案
- C.无栈协程无法在嵌套函数中挂起← 正确答案
- D.有栈协程切换不保存任何寄存器
答案:ABC。有栈协程有独立栈、切换换 SP 并保存寄存器组组成上下文,故 A 对、D 错;无栈协程复用调用者栈、状态机记录执行点,故 B 对;其挂起只能发生在协程函数体自身的 yield 处,被调用的普通函数无法中途挂起整条链,故 C 对。
Q8. 为什么线程切换通常比协程切换开销大?
- A.线程切换需要保存更多数量寄存器
- B.线程切换通常需要陷入内核态完成← 正确答案
- C.线程切换需要重新编译全部源码
- D.线程切换会删除进程全部内存页
答案:B。线程切换涉及系统调用、陷入内核、调度器选人与特权级切换,还要处理缓存失效,约微秒级;协程切换是用户态几十条指令,约纳秒级。寄存器数量差异很小(A 不成立),C、D 与事实不符。
Q9. 以下哪些场景更适合采用协程方案?
- A.高并发网络 IO 请求处理服务← 正确答案
- B.海量轻量任务的并发调度场景← 正确答案
- C.需要无限利用多个 CPU 核并行
- D.必须由内核强制抢占的实时系统
答案:AB。协程天生适合 IO 密集、任务量大且切换频繁的场景,让等待时间重叠。C 需要的是多线程并行而非协程;D 的硬实时要求需要内核抢占式调度保证时限,协作式协程无法满足。
Q10. 一次函数调用在栈上开辟的区域叫什么?
- A.进程控制块
- B.函数栈帧← 正确答案
- C.中断向量表
- D.内存页表项
答案:B。每次函数调用在栈上分配一块区域存放参数、返回地址、局部变量,这就是栈帧,相当于文件堆上新放的那份文件。A 是内核管理进程的数据结构,C 是中断处理入口表,D 是虚拟内存映射表,都与函数调用无关。