想象一间开放办公室:程序员小张和小李各自有一张工位,正在处理不同的需求。桌上摊着一摞文件,最上面那份就是正在读的;手边的便利贴记着几个关键数字。线程切换,像是要让整个办公室重排——操作系统管理员出面,把所有文件、便利贴、桌椅全部搬走归档,再搬进另一位的全部家当,动静极大。协程切换,则像是同事之间的小默契:小张把自己的便利贴抄进随身小本子,记住文件堆读到哪一份、哪一行,然后把小李的文件堆推过来,继续帮他处理到约定位置。整个过程不惊动管理员,几纳秒就已换岗完毕。这篇文章要讲的,就是“协程换岗”背后的机制:栈是什么、切换时到底保存什么、有栈与无栈两种协程各怎么设计,以及它为什么能支撑起十万级并发。

岗位关联

理解协程切换与栈保存机制,能让你在高并发 AI 服务中正确选择协程模型,看懂底层调度原理,不再被“协程比线程快”这句结论的黑盒所困扰。

目录
  1. 可视化演示
  2. 协程基础概念:办公室里的自觉换岗
  3. 栈与函数调用:工位上的一摞文件
  4. 上下文切换:换岗前记三样东西
  5. 有栈协程:每人一张专属工位
  6. 无栈协程:共享长桌上的一页待办清单
  7. 协程与线程对比:换岗的账本
  8. 实践应用:让换岗制度落地
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

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

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

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

协程基础概念:办公室里的自觉换岗

想象一间开放办公室,十几位同事共用几张工位。没有管理员指挥,大家自觉遵守一条规矩:谁坐上去,做完一小段就起身,让下一位接着做。甲写两行方案,乙填三行表格,丙只处理一步审批……看起来零散,整体进度却稳步推进。这种“自觉让位、轮流推进”的分工方式,就是协程的雏形。

协程(coroutine)是一种由程序员自己控制调度、在用户态完成的并发单元。它不依赖操作系统时钟中断来强行打断,而是主动调用 yield(让位)挂起自己,等时机成熟再由调度器 resume(恢复)。yield 与 resume,就是协程世界的呼吸:一呼一吸之间,执行权在多个协程之间交接,谁都不霸占工位。

相比线程的抢占式调度,协程的协作式调度有两大优势。其一,切换时机可控——协程只在明确的挂起点让位,不会在任意指令处被打断,因此保存现场的成本是固定的;其二,切换成本极低——不必陷入内核,保存恢复的只是少量寄存器。直接结果是并发规模的数量级提升:一台 8GB 内存的机器,线程因每个默认 8MB 栈很快耗尽资源,只能开几千个;协程却能开到几十万甚至上百万个。

小步换岗法为什么能容纳更多人?因为每个人占用的家当太少——一张待办卡片就能排队,而线程这“大桌子”摆不下几套。

N协程∼105∼106,N线程∼103∼104N_{\text{协程}} \sim 10^5 \sim 10^6, \qquad N_{\text{线程}} \sim 10^3 \sim 10^4
这个不等式说明:同样一台机器,能容纳的协程数量比线程多出两个数量级——办公室能挤出多少张“待办卡片”,决定了能容纳多少人同时排队干活。
  1. 协程数量 N 下限与上限表示单机可同时创建的协程数量,对应办公室能容纳的“待办卡片”数。
    N协程∼105∼106N_{\text{协程}} \sim 10^5 \sim 10^6
  2. 约等于号 \sim读作“约等于”,表示数量级估计而非精确数值。
    ∼\sim
  3. 十万到百万量级协程切换轻、占用内存小,所以能支撑海量并发。
    105∼10610^5 \sim 10^6
  4. 线程数量受内核资源、调度器与 8MB 默认栈限制,通常只有几千到几万。
    N线程∼103∼104N_{\text{线程}} \sim 10^3 \sim 10^4

栈与函数调用:工位上的一摞文件

每张工位上都有那么一摞文件:正在处理的项目 A 在最上面,下面压着之前没看完的项目 B、C。处理任何一件事时,你总是从最上面那份开始;做完合上放一边,继续处理下一份。这一摞“后进先出”的文件,就是程序世界的栈(stack)。

函数调用,就是往这摞文件上放一份新文件。每次调用都会在栈上开辟一块区域,叫栈帧(stack frame),里面装着:参数、返回地址、局部变量、上一层帧指针。返回地址最是关键——它记录“这份文件看完后,回到上一份文件的哪一行继续”。调用约定(ABI)则规定了这些文件栏目的格式:参数怎么传、谁来清理、返回地址放哪一格。call 指令把返回地址压栈并跳转,ret 指令弹出返回地址放回 PC(程序计数器)。栈指针 SP 永远指向栈顶——就像你的手指,始终停在那份正在读的文件上。

理解了文件堆的规则,协程切换的秘密就揭开一半了:当你想从工位起身去隔壁帮忙,必须保证这摞文件原封不动——这正是“保存栈”的含义。

栈帧=(参数, 返回地址, 局部变量, 帧指针);SP←SP−帧大小;PC←弹栈的返回地址\text{栈帧} = (\text{参数},\ \text{返回地址},\ \text{局部变量},\ \text{帧指针}); \qquad\text{SP} \leftarrow \text{SP} - \text{帧大小}; \qquad\text{PC} \leftarrow \text{弹栈的返回地址}
三个式子连起来就是一个完整的函数来回:调用时往下摞一叠新文件(SP 下移、返回地址留档),返回时凭最后一格里记的返回地址,翻回上一份文件继续读。
  1. 栈帧结构一次函数调用的“档案袋”,装着参数、返回地址、局部变量和上一层帧指针。
    栈帧=(参数, 返回地址, 局部变量, 帧指针)\text{栈帧} = (\text{参数},\ \text{返回地址},\ \text{局部变量},\ \text{帧指针})
  2. SP 下移调用发生时栈向下增长,SP 指向新栈顶。
    SP←SP−帧大小\text{SP} \leftarrow \text{SP} - \text{帧大小}
  3. 返回地址的来源call 指令自动把下一条指令地址压栈保存。
    返回地址=call 的下一条指令地址\text{返回地址} = \text{call 的下一条指令地址}
  4. PC 恢复ret 指令弹出栈顶的返回地址,写回 PC 继续执行。
    PC←弹栈的返回地址\text{PC} \leftarrow \text{弹栈的返回地址}

上下文切换:换岗前记三样东西

在小步换岗制度下,起身之前你必须完成三件事:把手边便利贴上的关键数字抄进随身小本子(寄存器);记住手指停在文件堆的哪一份(栈指针);记住正看到那一页的第几行(程序计数器)。三样记完,才算“保存现场”。

这三样东西合起来,叫执行上下文(context)。寄存器是 CPU 内部的高速暂存单元,保存当前运算的中间值,像便利贴上随手写的关键数字;栈指针 SP 指向当前栈顶帧,像停在第几份文件;程序计数器 PC 指向下一条要执行的指令,像读到第几行。把三者写入一个上下文结构体,是保存现场;反向从结构体加载回寄存器、设置好 SP 与 PC,是恢复现场。一次协程 switch 切换,本质就是“保存旧协程现场 + 恢复新协程现场”——在汇编层面只有几十条指令。

“小本子”就是全部家当。正因为要抄写的东西极少,协程换岗才能快到让人毫无察觉。

ctx=(R0, R1, …, Rn−1, SP, PC);switch(A→B): ctxA←现场,  现场←ctxB\text{ctx} = (R_0,\ R_1,\ \dots,\ R_{n-1},\ \text{SP},\ \text{PC}); \qquad\text{switch}(A \to B):\ \text{ctx}_A \leftarrow \text{现场},\ \ \text{现场} \leftarrow \text{ctx}_B
上下文对象就是那本随身小本子:把 A 的便利贴和书签页码抄进去,再把 B 本子上的内容摆回桌面。谁的小本子记得全,谁就能无缝续演。
  1. 上下文集合把恢复执行所需的最小状态打包成一个对象。
    ctx=(R0, R1, …, Rn−1, SP, PC)\text{ctx} = (R_0,\ R_1,\ \dots,\ R_{n-1},\ \text{SP},\ \text{PC})
  2. 通用寄存器 R_i保存当前运算的中间值,像手边便利贴上的数字。
    Ri(i=0, 1, …, n−1)R_i \quad (i = 0,\ 1,\ \dots,\ n-1)
  3. 栈指针 SP指向当前栈顶帧,像手指停在文件堆的位置。
    SP=当前栈顶地址\text{SP} = \text{当前栈顶地址}
  4. switch 的完整动作先抄下 A 的现场,再把 B 的现场摆出来。
    ctxA←现场;现场←ctxB\text{ctx}_A \leftarrow \text{现场}; \qquad \text{现场} \leftarrow \text{ctx}_B

有栈协程:每人一张专属工位

有栈协程的方案很直白:给每个协程配一张独立的小工位。工位不用大,够放下自己的文件堆就行;再配一个小本子(上下文对象)。换岗时,把旧工位的文件堆原封不动留好,揣上小本子走向下一张工位——名下那张桌子永远等着你回来。

有栈协程(stackful coroutine)在创建时通过 malloc 分配一块独立栈,常见 64KB 到 1MB。切换流程分两步:switch 把当前寄存器组、SP(旧栈帧位置)、PC(挂起位置)写入协程 A 的上下文对象;然后让 SP 指向协程 B 的栈顶、PC 指向 B 上次的恢复点,直接跳转执行。resume 本质是一次目标为 B 的 switch。因为拥有完整调用栈,有栈协程可以在任意深度的嵌套函数中挂起——哪怕在第三层函数里 yield,整条调用链都安然保存。

换桌子的速度取决于本子有多厚——而有栈协程的“本子”只有几十个字,所以再深的调用链也能瞬间换岗。

ctxi=(寄存器组i, SPi, PCi);switch: SP←SPB,  PC←PCB\text{ctx}_i = (\text{寄存器组}_i,\ \text{SP}_i,\ \text{PC}_i); \qquad\text{switch}:\ \text{SP} \leftarrow \text{SP}_B,\ \ \text{PC} \leftarrow \text{PC}_B
切换的核心只有两个赋值:换栈顶、换执行点。桌子还是那张桌子,只是坐的人换了,桌上的文件堆却是另一个人的。
  1. 上下文对象每个有栈协程都持有自己的小本子,记录寄存器、SP、PC。
    ctxi=(寄存器组i, SPi, PCi)\text{ctx}_i = (\text{寄存器组}_i,\ \text{SP}_i,\ \text{PC}_i)
  2. 独立栈分配协程创建时申请一块私有栈区,相当于自己的文件抽屉。
    Si=malloc(栈大小)S_i = \text{malloc}(\text{栈大小})
  3. switch 换栈把 SP 换成目标协程的栈顶,从此压栈落在 B 的工位上。
    SP←SPB\text{SP} \leftarrow \text{SP}_B
  4. resume 恢复执行把 PC 设为 B 上次挂起的地址,B 从断点继续。
    PC←PCB\text{PC} \leftarrow \text{PC}_B

无栈协程:共享长桌上的一页待办清单

无栈协程换了个思路:大家共用一张长桌(调用者的栈),每人只拿一张待办卡片,上面写着“做到第几步”。轮到你时,按卡片上的步骤号从清单里找出对应的步骤继续走——谁也不需要一张专属桌子。

无栈协程(stackless coroutine)在编译期被编译器改写成一台状态机:函数体被拆成若干代码段,每个 yield 对应一个状态分支;原先的局部变量全部变成状态结构体的字段,不再依赖调用栈保存。运行时它复用调用者的栈,在别人的栈帧上执行,因此不需要独立栈内存。代价是挂起只能发生在协程函数体自身的 yield 处——被调用的普通函数里不能中途挂起整条协作链。换来的是极小的内存占用:一个状态对象往往只有几十字节。

一张卡片 vs 一抽屉文件——无栈协程把省字发挥到极致,代价是不能随意在嵌套函数深处挂起。

协程函数→编译状态机: s=(k, v1, v2, …, vm);resume: 按 s.k 跳到对应代码段执行\text{协程函数} \xrightarrow{\text{编译}} \text{状态机}:\ s = (k,\ v_1,\ v_2,\ \dots,\ v_m); \qquad\text{resume}:\ \text{按 } s.k \text{ 跳到对应代码段执行}
一页待办清单取代了一抽屉文件:状态变量 k 就是“进行到第几步”的那个勾,局部变量 v 就是清单背面的备注栏。
  1. 状态机改写编译器把协程函数拆成若干带编号的代码段。
    协程函数→编译状态机\text{协程函数} \xrightarrow{\text{编译}} \text{状态机}
  2. 状态结构体挂起需要的信息全部浓缩进一个对象。
    s=(k, v1, v2, …, vm)s = (k,\ v_1,\ v_2,\ \dots,\ v_m)
  3. 状态变量 k记录当前执行到第几个挂起点,相当于待办清单的“打完勾的序号”。
    k=挂起点编号k = \text{挂起点编号}
  4. resume 跳转按编号找到对应代码段继续执行,并推进到下一个状态。
    switch(s.k): 执行第 s.k 段代码\text{switch}(s.k):\ \text{执行第 } s.k \text{ 段代码}

协程与线程对比:换岗的账本

现在给办公室算一笔账。线程切换像惊动管理员的大搬家:系统调用陷入内核、调度器选人、特权级切换、刷新缓存,动静大、时间长;协程切换像同事间递个眼色的小换岗:纯用户态几十条指令,保存恢复寄存器、换 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}
账本上,协程在“耗时”和“占座”两列都漂亮:换岗快一个数量级,占位小两个数量级——小步换岗制度胜出了。
  1. 线程切换耗时内核调度涉及特权级切换与调度算法,微秒级。
    T线程切换≈1∼10 μsT_{\text{线程切换}} \approx 1{\sim}10\,\mu s
  2. 协程切换耗时用户态几十条指令,纳秒级完成。
    T协程切换≈20∼200 nsT_{\text{协程切换}} \approx 20{\sim}200\,ns
  3. 线程栈内存每个线程默认 8MB 栈,内存是并发规模的天花板。
    M线程栈=8 MBM_{\text{线程栈}} = 8\,\text{MB}
  4. 协程栈内存有栈协程 64KB,无栈协程更是只需要几十字节状态。
    M协程栈=64 KBM_{\text{协程栈}} = 64\,\text{KB}

实践应用:让换岗制度落地

小步换岗从办公室默契变成公司制度,靠的是调度员和排班表。落到工程里,这个“调度员”就是协程库:从 libco 的 switch 汇编、Go 的 goroutine 调度器,到 Python asyncio 的事件循环,内核都是同一件事——在恰当的时机 yield,在条件满足时 resume。

最典型的落地是异步 IO 结合:一个网络服务有上万个连接在等数据,传统线程模型让每个线程傻等 recv 返回;协程模型则让协程在等待时挂起,把 CPU 让给其他就绪协程,数据到达再由事件循环(epoll/kqueue)唤醒。性能调优的要点也随之明确:不要让协程里出现阻塞调用(如同步磁盘 IO),否则整条协作链被卡死;控制协程数量不要无脑开满;用有栈协程应对需要嵌套挂起的复杂调用链,用无栈协程压缩内存开销。

好制度让每个人各司其职、不空等。理解了栈与协程的底层原理,你再看框架的并发模型时,就不再是黑盒——而是那间办公室里,一次漂亮的换岗。

吞吐量=NN×T切换+∑jT执行,j\text{吞吐量} = \frac{N}{N \times T_{\text{切换}} + \sum_j T_{\text{执行},j}}
吞吐量 = 干完的活 ÷(切换开销 + 干活时间)。协调换岗制度后,切换开销被压到极小,决定吞吐量的只剩下“真正干活”的时间——没人再空等。
  1. 总吞吐量单位时间内完成 N 个任务的平均速率。
    吞吐量=NT总\text{吞吐量} = \frac{N}{T_{\text{总}}}
  2. 切换总损耗每个任务都要经历若干次换岗,成本与次数成正比。
    N×T切换N \times T_{\text{切换}}
  3. 有效执行时间所有任务真正处理业务的时间之和,这是无法压缩的下限。
    ∑jT执行,j\sum_j T_{\text{执行},j}

AI 应用场景

大模型推理网关的万级并发

阶段:模型服务部署与在线推理

解决的问题:大模型 API 网关需要同时支撑成千上万并发请求。若每请求占一个线程,几千个线程就把内存吃光,且频繁陷入内核调度让 GPU 等待空转。改用协程后,请求在等待上游推理或网络响应时挂起,把 CPU 让给其他请求,最大化吞吐。

具体案例:某 LLM 推理网关用 Python asyncio + 连接池实现,协程在等待模型服务返回时 yield,单节点稳定支撑 1 万并发,P99 延迟无明显劣化;同样的请求量,线程池模型需要 3 倍机器。

特征流水线的并行拉取

阶段:数据采集与特征工程

解决的问题:推荐系统特征工程要并行拉取上百个上游特征服务的数据,传统“一线程一连接”方式线程数量爆炸、等待时间线性累加。用协程让所有网络等待重叠在一起,流水线总耗时被大幅压缩。

具体案例:某推荐团队用协程并发请求 200 个特征源,总耗时从串行的 800ms 降到并发等待的 80ms,且只占用 4 个线程承载调度,内存占用下降 90%。

Python 演示

python58 行
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()
目的:用两个类分别模拟有栈协程(独立栈+上下文对象)与无栈协程(生成器状态机),一键运行即可直观看到 switch/resume 切换与栈保存的核心行为。
  1. self.stack = []每个有栈协程的“独立栈”,用列表模拟,切换时原样保留
  2. self.stack.append("frame" + str(self.pc))调用函数即向自己的栈压入一个栈帧
  3. def switch(a, b)仿 switch:打印 A 的现场被保存、B 的现场被恢复
  4. yield k无栈协程的挂起点,状态 k 由生成器对象内部保存,不占独立栈
  5. next(g1)一次 resume 操作,让协程从上次挂起的位置继续执行
  6. 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. 协程切换与线程切换最本质的区别是什么?

单选题 · easy

  • A.协程切换必须陷入内核完成
  • B.协程切换完全在用户态完成← 正确答案
  • C.线程切换不需要保存任何数据
  • D.协程切换不需要切换执行流

答案:B。协程是用户态协作式切换,由程序自己的调度器控制,不陷入内核;线程是内核态抢占式切换。B 正确。D 错误,协程切换当然要改变执行流;A 说反了;C 说反了,线程切换恰恰要保存大量现场。

Q2. 有栈协程的最大特征是什么?

单选题 · easy

  • A.所有协程共享同一块调用栈
  • B.每个协程拥有独立调用栈← 正确答案
  • C.协程运行时不保存程序计数器
  • D.有栈协程必须依赖多核 CPU

答案:B。有栈协程创建时为每个协程分配独立栈,切换时更换 SP 指向,这就是“每人一张专属工位”。A 是无栈协程的特征;C 错误,任何协程都要保存 PC;D 无中生有,协程单核也能运行。

Q3. 协程执行 switch 切换时,栈指针 SP 的作用是什么?

单选题 · mid

  • A.把新协程栈顶地址载入 SP← 正确答案
  • B.清空旧协程的全部栈空间
  • C.把局部变量复制到全局常量区
  • D.让新协程重跑函数第一条语句

答案:A。switch 的核心动作之一就是把 SP 换成目标协程的栈顶地址,此后压栈退栈都发生在 B 的独立栈上。B 错误,旧协程栈要原样保留待恢复;C 与栈无关;D 混淆了 SP 与 PC 的职责。

Q4. 无栈协程挂起后,执行到哪一步保存在哪里?

单选题 · mid

  • A.保存在内核线程控制块中
  • B.保存在状态机的状态变量里← 正确答案
  • C.保存在协程私有的独立栈顶
  • D.保存在操作系统页表项中

答案:B。无栈协程由编译器改写成状态机,状态变量 k 记录当前到达第几个挂起点,局部变量也是状态结构体字段。它不持有独立栈,所以不存在于 C;也不经过内核,排除 A、D。

Q5. 协程上下文切换通常需要保存哪些内容?

多选题 · mid

  • A.通用寄存器组的当前值← 正确答案
  • B.栈指针 SP 的当前指向← 正确答案
  • C.程序计数器 PC 的数值← 正确答案
  • D.显示器等外设的状态

答案:ABC。上下文 = 寄存器组 + SP + PC,这是恢复执行所需的最小状态集合,对应便利贴、手指位置、阅读行数三样东西。外设状态由操作系统管理,与协程切换无关。

Q6. 协程相比线程在并发规模上更优的主要原因是?

单选题 · mid

  • A.协程能利用多核 CPU 并行运算
  • B.协程切换轻量且占用内存更小← 正确答案
  • C.协程的调度由内核抢占完成
  • D.协程运行时会额外分配大块内存

答案:B。协程切换只在用户态保存少量寄存器,栈也只有几十字节到 64KB,所以单机能开十万级协程,而线程受 8MB 栈和内核资源限制只有几千。A 是并行,协程不做并行;C 说反了;D 说反了。

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

多选题 · hard

  • A.有栈协程切换时需要更换栈指针← 正确答案
  • B.无栈协程复用调用方的调用栈← 正确答案
  • C.无栈协程无法在嵌套函数中挂起← 正确答案
  • D.有栈协程切换不保存任何寄存器

答案:ABC。有栈协程有独立栈、切换换 SP 并保存寄存器组组成上下文,故 A 对、D 错;无栈协程复用调用者栈、状态机记录执行点,故 B 对;其挂起只能发生在协程函数体自身的 yield 处,被调用的普通函数无法中途挂起整条链,故 C 对。

Q8. 为什么线程切换通常比协程切换开销大?

单选题 · hard

  • A.线程切换需要保存更多数量寄存器
  • B.线程切换通常需要陷入内核态完成← 正确答案
  • C.线程切换需要重新编译全部源码
  • D.线程切换会删除进程全部内存页

答案:B。线程切换涉及系统调用、陷入内核、调度器选人与特权级切换,还要处理缓存失效,约微秒级;协程切换是用户态几十条指令,约纳秒级。寄存器数量差异很小(A 不成立),C、D 与事实不符。

Q9. 以下哪些场景更适合采用协程方案?

多选题 · hard

  • A.高并发网络 IO 请求处理服务← 正确答案
  • B.海量轻量任务的并发调度场景← 正确答案
  • C.需要无限利用多个 CPU 核并行
  • D.必须由内核强制抢占的实时系统

答案:AB。协程天生适合 IO 密集、任务量大且切换频繁的场景,让等待时间重叠。C 需要的是多线程并行而非协程;D 的硬实时要求需要内核抢占式调度保证时限,协作式协程无法满足。

Q10. 一次函数调用在栈上开辟的区域叫什么?

单选题 · easy

  • A.进程控制块
  • B.函数栈帧← 正确答案
  • C.中断向量表
  • D.内存页表项

答案:B。每次函数调用在栈上分配一块区域存放参数、返回地址、局部变量,这就是栈帧,相当于文件堆上新放的那份文件。A 是内核管理进程的数据结构,C 是中断处理入口表,D 是虚拟内存映射表,都与函数调用无关。