想象你是后厨里唯一的大厨,面前只有一口灶。午市高峰,十桌客人的订单像雪花般涌进来。如果按部就班——把一道菜从头做到尾再开下一道——你会眼睁睁看着水烧开又烧干、锅里的油凉了又热,而客人们早已不耐烦地敲起了桌子。 真正的大厨有一套自己的“并发心法”:把水烧上,趁它还没开,去切葱;把排骨下锅焖着,转身去包饺子;蒸锅里的鱼到了时间,掐着点回来端盘。等待,不是发呆,而是切换的暗号。一口灶、一个人,十桌菜照样流水般上桌。 这套心法,就是协程与 async/await 的世界观。传统并发模型里,每个任务都霸道地想要一个专属“灶台”(线程),等待时就干烧着;而协程让单个线程像那位大厨一样,在“等待”处挂起当前任务、转头推进另一个,等条件就绪再回来续上。一个线程管成千上万个并发任务,靠的就是这种“见缝插针”的调度魔法。

岗位关联

对 AI 从业者而言,理解协程与 async/await,意味着你能用一份服务器资源扛住上万路并发请求——无论是多路 LLM 聚合调用、流式推送到客户端,还是高并发推理网关,这都是架构设计绕不开的核心基本功。

目录
  1. 可视化演示
  2. 异步编程痛点
  3. 协程内核原理
  4. async与await语法
  5. 事件循环调度
  6. 取消与异常传播
  7. 高并发实践
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示协程事件循环调度、async/await编译器转换为状态机,对比内核线程与用户协程差异,滑块调节并发协程数量。

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

事件循环调度 事件循环维护就绪队列+等待队列 IO完成后协程就绪 协作式用户态调度轻量并发

异步编程痛点

在“见缝插针”的心法被发明之前,后厨的管法朴素而昂贵:一人一灶。十桌客人同时点菜?那就雇十位大厨,每人守着一口灶,从切菜到装盘,一道菜做完再做下一道。听起来很可靠,代价却惊人——大多数时候,大厨们都在盯着没烧开的水发呆。

传统高并发服务正是这么“雇人”的:一个线程伺候一个连接(thread-per-connection)。这个模式写起来简单,可每个线程都要预分配一块调用栈(常见 8MB),还要占用一个内核调度控制块。并发一上来,内存先崩为敬。更别说线程切换要陷入内核,保存寄存器、刷新缓存,几微秒的开销在万级并发下被放大成灾难。

于是有人尝试“不阻塞”:发完请求就注册一个回调,等数据到了再调回来。结果回调套回调,代码一层层往右缩进,执行顺序和阅读顺序完全脱节——回调地狱由此得名。一个请求的生命周期被拆成七八段,散落在不同回调里,出问题根本无从查起。这正是协程登场的理由:既想不阻塞,又想让代码像同步一样好读。

一人一灶的模式,只有财力惊人的餐厅才玩得起。要服务万桌客人,必须回到单灶大厨的思路:一个人、一口灶,靠“等待时切换”去赢。代价就是,传统的并发模型在万级并发面前,连第一轮内存账单都付不起。

M线程=N×(8 MB+2 KB)⇒104 并发≈80 GB\displaystyle M_{\text{线程}} = N \times (8\,\text{MB} + 2\,\text{KB}) \quad\Rightarrow\quad 10^4\ \text{并发} \approx 80\,\text{GB}
线程方案的总内存开销大约是“并发连接数 × 每条线程的成本”,1 万并发就要 80GB 量级,单机根本无法承受。
  1. 并发数 NN 表示同时接入的连接数。线程方案里,一个连接基本对应一个线程,所以 N 就是线程总数。
    N=并发连接数N = \text{并发连接数}
  2. 栈空间 8MB每个线程要预分配一段调用栈,用来存放函数调用现场数据,这是内存大头。
    栈=8 MB / 线程\text{栈} = 8\,\text{MB / 线程}
  3. 内核控制块 2KB操作系统还要为每个线程维护调度元数据,虽小,但积少成多。
    内核块=2 KB / 线程\text{内核块} = 2\,\text{KB / 线程}
  4. 总量估算1 万并发线程光栈空间就要约 80GB 虚拟内存,单机根本扛不住。
    104×8 MB=80 GB10^4 \times 8\,\text{MB} = 80\,\text{GB}

协程内核原理

单灶大厨的秘密武器,是一张张写满状态的菜谱卡。红烧肉焖上了,他在卡片上记:“第 7 步,大火收汁,还剩 2 分钟,锅里的汤收到一半”;然后放心去包饺子。等时间一到,扫一眼卡片,无缝续上。程序世界里,这张菜谱卡就叫协程——一个可以暂停、可以恢复的函数。

普通函数一旦调用,就得一口气跑到 return;协程却在特定位置(挂起点)主动刹车:把现场保存好,控制权交还调度者;下次被唤醒,从刹车点继续。现场要保存什么?最核心就两样:程序计数器 PC(下次从哪行续跑)和所有局部变量的当前值。因为挂起时不占 CPU、只占一点内存,所以理论上可以同时挂起成千上万个协程。

实现上有两大流派。栈式协程(stackful)给每个协程分配独立调用栈,可以在任意深度的嵌套调用中挂起,像自带备料台的助手,灵活但内存开销大;无栈协程(stackless)不拥有自己的栈,挂起时把状态整体拷贝到堆上的对象里,Python 的 asyncio、JavaScript 的 async 都属于这一派。代价是:只有标记了 async 的函数里才能 await,普通函数深处不能直接挂起。

一张卡片只记几行字,却能管一口锅。无栈协程的“轻薄”,正是高并发需要的——状态越小,能挂起的任务就越多。栈式协程像带助理的大厨,强大却昂贵;无栈协程则像口袋里的一沓菜谱卡,十万张也放得下。

S协程=(PC, V1, V2, …, Vn)\displaystyle S_{\text{协程}} = (PC,\ V_1,\ V_2,\ \dots,\ V_n)
协程挂起时需要保存的最小现场就两样:程序计数器和局部变量。无栈协程把这些状态塞进堆上的对象,轻到只有 2KB 量级。
  1. PC 程序计数器记下协程下次要执行哪一行代码,相当于菜谱读到第几行,是恢复执行的锚点。
    PC=下一条指令地址PC = \text{下一条指令地址}
  2. 局部变量组V₁ 到 Vₙ 是当前函数作用域里所有局部变量的值,比如循环下标、中间计算结果。
    Vi=第 i 个局部变量的当前值V_i = \text{第 } i \text{ 个局部变量的当前值}
  3. 无栈协程的存储挂起时把这些状态从共享栈搬到堆对象里,恢复时再还原,因此实例极轻。
    S协程→堆上对象S_{\text{协程}} \to \text{堆上对象}

async与await语法

有了菜谱卡,大厨还需要一套在菜谱上标记“等待点”的符号。async/await 就是这套符号系统。async def 声明“这页菜谱可以随时翻页走人”;await 是具体的“⚠ 等水开”标记——结果没就绪?保存现场,人先走。

编译器会把 async 函数悄悄改造成一台状态机:每遇到一个 await,函数就被切成一段;段与段之间就是挂起点。代码的书写顺序和同步代码一模一样——从上到下、一步步来——但执行时,它会在每个 await 处弹出,跳到别的协程里去,等条件满足再回来。

几个关键规则:只有 async 函数里能写 await;调用一个 async 函数只会返回协程对象,函数体一行都没跑,必须 await、create_task 或 gather 调度后才真正执行;await 后面跟的通常是“可等待对象”——协程、Future 或 Task。等待期间,控制权完全交还事件循环。于是,异步逻辑从“倒着读的菜谱”变成了“正着读的菜谱”。

大厨再也不用在菜谱上写“做完这段记得回来”的手势暗号——翻页逻辑内建在每个 await 里。这就是 async/await 的魔法:用同步的笔法,写异步的节奏。

async f(⋅)⇒fs:{s0→await1 s1→await2 … →awaitk sk}\displaystyle \text{async}\ f(\cdot) \Rightarrow f_s: \big\{ s_0 \xrightarrow{\text{await}_1}\ s_1 \xrightarrow{\text{await}_2}\ \dots\ \xrightarrow{\text{await}_k}\ s_k \big\}
async 函数在编译器眼里就是一台状态机:一个 await 切一段,段与段之间是挂起点,协程恢复时从当前段接着往下走。
  1. async 改造编译器把普通函数重写成一个协程状态机,返回协程对象。
    f(⋅)→fsf(\cdot) \to f_s
  2. 切段每遇到一个 await,函数就被切成一段,s_i 代表第 i 个 await 点的现场。
    si=第 i 段挂起点状态s_i = \text{第 } i \text{ 段挂起点状态}
  3. 状态迁移协程被恢复调度时,从当前状态 s_i 继续执行,直到撞上下一个 await 再挂起。
    si→继续执行si+1s_i \xrightarrow{\text{继续执行}} s_{i+1}
  4. 终止状态跑完最后一段,s_k 里封存的就是 return 的最终结果。
    sk=return 值s_k = \text{return 值}

事件循环调度

菜谱卡备齐了,大厨怎么知道哪口锅好了?答案是“订单板加铃铛”。事件循环就是这块板:它维护一张就绪任务列表,只收录“现在可以继续做”的菜;某个等待条件满足(水开了、鱼蒸透了),就把那张卡移到列表上。大厨的节奏是:板上有什么做什么,做完一批,就等下一声铃。

事件循环本质上是一个单线程调度器,不断重复三个动作:先把积压的微任务清空,再取一个就绪协程跑一步(直到它又 await 挂起),最后进入内核等待(poll)——没有就绪任务时干脆睡过去,等 I/O 就绪事件或定时器到期再醒。整个过程不空转、不忙等,CPU 利用率全花在真正要算的活上。

队列内部的优先级也很有讲究。微任务(microtask)优先于普通宏任务:像“菜一好就要立刻撒葱花”的收尾动作,不能排到下一道大菜后面。协程的恢复(continuation)在很多实现里就走微任务通道,保证 await 一满足立刻续跑。定时器则被组织成最小堆,事件循环每轮 poll 时把“最近一个定时器还剩多久”当作超时时间——铃铛和定时器互不抢戏。

大厨从不靠猜来管锅,他只信铃铛和订单板。事件循环也一样:与其轮询空转,不如睡到有事再醒——单线程扛住万级并发的秘密,就藏在这套“知道什么该等、什么该做”的秩序里。

L: 清空微任务⏟先收尾⇒跑一个就绪协程⏟做一道菜⇒poll 就绪事件⏟等铃铛\displaystyle \mathcal{L}: \ \underbrace{\text{清空微任务}}_{\text{先收尾}} \Rightarrow \underbrace{\text{跑一个就绪协程}}_{\text{做一道菜}} \Rightarrow \underbrace{\text{poll 就绪事件}}_{\text{等铃铛}}
事件循环一轮的标准动作:先清微任务收尾,再跑一个就绪协程,最后进内核等事件;没有活干就睡,有事才醒。
  1. 清空微任务微任务是“出锅后马上撒葱花”级别的收尾动作,必须赶在开新菜之前全部做完。
    microtask 队列→∅\text{microtask 队列} \to \emptyset
  2. 跑一个就绪协程从就绪队列取出一个协程,执行到下一个 await 处挂起,然后把控制权还给循环。
    current=ready_queue.pop()\text{current} = \text{ready\_queue.pop()}
  3. poll 就绪事件没有活干时进入内核等待,超时时间取“最近一个定时器还剩多久”,有事件才被唤醒。
    epoll_wait(timeout=min⁡Δttimer)\text{epoll\_wait}(\text{timeout} = \min \Delta t_{\text{timer}})

取消与异常传播

厨房最怕什么?菜做到一半,客人说“这道不要了”。撤单不是喊一嗓子就完事:大厨得关掉火、把半成品倒掉、把锅洗干净,否则下一道菜会串味儿。协程世界里,这记撤单铃就是 Task.cancel(),而砸在当下执行点上的,是一个叫 CancelledError 的通知。

取消是协作式的,不是强杀。cancel() 不会让任务瞬间蒸发,它只是往 Task 里注入一个 CancelledError——任务当前停在哪个 await 点,异常就从哪里抛出来。如果协程不去理会这个异常(用 except 吞掉),任务会继续跑下去。所以“撤单成功”需要大厨的配合:他得在 finally 里关火洗锅,并让异常继续向上传播。

异常传播同样沿着 await 链来。子协程内部抛出的任何异常,会从它的 await 处原样抛出,逐层上报;asyncio.gather 在默认行为下,遇到第一个子任务异常,会取消其余还没完成的兄弟任务,再把异常抛给调用方。写生产代码时,一定要用 finally 或 async with 包住资源释放——无论是正常完成、中途异常,还是被取消,清理代码都必须跑。

成熟的厨房对撤单和烧糊都有预案。理解了取消与异常如何传播,你写的异步服务才能在用户秒退、上游超时的惊涛骇浪中安稳落地——每一口锅都有人关火,每一张单都有交代。

await T={rT 正常返回raise ET 内部抛异常raise CancelledErrorT 被取消\displaystyle \text{await } T = \begin{cases} r & T \text{ 正常返回}\\ \text{raise } E & T \text{ 内部抛异常}\\ \text{raise } \text{CancelledError} & T \text{ 被取消} \end{cases}
await 一个任务只有三种结局:拿到结果、收到异常、被告知取消。写代码时要为三种结局都留好后路。
  1. 正常返回子协程跑完(菜上桌),await 表达式拿到返回值 r,继续往下走。
    T→rT \to r
  2. 异常上抛子任务抛出的异常 E 会原样穿过 await,逐层向调用链上方传播。
    T→raise ET \to \text{raise } E
  3. 取消通知任务被取消时,在当前挂起点抛 CancelledError,finally 里的清理代码负责收尾,然后异常继续上抛。
    T→raise CancelledErrorT \to \text{raise } \text{CancelledError}

高并发实践

回到午市高峰:为什么单灶大厨能赢?因为一顿饭的功夫里,真正“动手做菜”的时间极少,绝大多数时间在等——等水开、等蒸透、等入味。计算机世界亦然:一个典型 Web 请求里,数据库查询、网络调用、缓存读写全是 I/O 等待,CPU 真正干活的时间往往不到 10%。等待占比越高,大厨的“见缝插针”就越值钱。

实践中的数据很有说服力。线程方案:一个连接一个线程,每个线程 8MB 栈,1 万并发就是 80GB;协程方案:一个连接一个协程对象,每个只占 2KB 左右,一台普通 8GB 服务器扛 5 万并发绰绰有余。但要注意两个前提:协程解决的是 I/O 密集,不是 CPU 密集;单线程事件循环用不满多核,得靠多进程(如 uvicorn 的多个 worker)横向吃满 CPU。

还有一个大坑:在协程里调用同步阻塞函数。time.sleep、requests.get 这类调用会把整个事件循环卡死,就像大厨真的站在锅前发呆——所有订单全部停摆。正确姿势是统一用 await asyncio.sleep、aiohttp 等原生异步库。生产案例:某 AI 推理网关,单机 2C4G,用 asyncio 同时维护 3 万条 WebSocket 推送连接,还并发聚合多个上游模型服务的响应;换成线程模型,光是内存就爆掉了。

大厨的心法最终变成了餐厅的竞争力:同样的房租(服务器)、同样的一个人(单线程),翻台率却高了好几个量级。高并发的本质,从来不是雇更多的大厨,而是让每一位大厨都不再对着锅发呆。

η=N⋅tCPUtCPU+tI/O,M线程M协程≈4000\displaystyle \eta = \frac{N\cdot t_{\text{CPU}}}{t_{\text{CPU}} + t_{\text{I/O}}}, \qquad \frac{M_{\text{线程}}}{M_{\text{协程}}} \approx 4000
并发协程数 N 越大,CPU 利用率越接近满载;而协程对线程的内存优势约 4000 倍,这是万级并发的物理基础。
  1. CPU 利用率N 个协程交错推进,把原本空转的 I/O 等待时间填满,利用率随 N 上升。
    η=N⋅tCPUtCPU+tI/O\eta = \frac{N \cdot t_{\text{CPU}}}{t_{\text{CPU}} + t_{\text{I/O}}}
  2. 内存倍率线程栈 8MB 对协程对象 2KB,差距约 4000 倍,这是能扛住万级并发的物质基础。
    8 MB2 KB≈4000\frac{8\,\text{MB}}{2\,\text{KB}} \approx 4000
  3. 可承载并发单机并发上限从上千(线程)提升到数万(协程),量级来自内存与切换开销的双重解放。
    Q并发≈O(104)Q_{\text{并发}} \approx O(10^4)

AI 应用场景

LLM Agent 的多路聚合调用

阶段:Agent 编排与工具调用层

解决的问题:RAG 问答 Agent 一次回答往往要串行调用多个服务:向量库检索、rerank 重排、主模型生成、缓存查询。同步写法下,四个 300ms 的调用串起来就是 1.2s。用 asyncio.gather 并发发出,总耗时约等于最慢的那个,延迟直接砍半——这正是“大厨同时蒸三锅菜,总时间只看最慢一锅”。

具体案例:某智能客服 Agent 收到问题后,并行执行四路调用:向量检索(80ms)、标题缓存(30ms)、主 LLM 生成(900ms)、引用校验(120ms)。用 asyncio.gather 聚合后,端到端延迟从 1.1s 压到约 900ms;再用 asyncio.Semaphore(8) 限制并发上限,避免瞬时流量把上游模型服务打爆。

LLM 流式推送网关

阶段:推理服务网关层

解决的问题:大模型流式输出需要为每个客户端维持一条长连接(WebSocket/SSE)。如果用线程模型,5 万在线用户就是 5 万线程,光栈内存就 400GB;改成 asyncio 后,一个事件循环线程承载 5 万个协程,每个连接只占 2KB 状态——厨房还是那个厨房,一口灶管了五万桌。

具体案例:某内部 LLM 网关单节点 2C4G,用 uvicorn(asyncio)承接 3 万条并发 WebSocket 连接,逐 token 向客户端转发推理结果,高峰 CPU 稳定在 60%,内存占用不足 2GB;此前用线程模型在 3000 连接时内存已告急。

离线批处理与超时重试

阶段:离线推理与数据管线

解决的问题:批量 prompt 推理用多线程串行池时,线程切换和同步阻塞让吞吐上不去;asyncio 结合 wait_for 做超时控制、结合重试协程做失败补偿,代码量少且状态可观测——这是大厨把切菜、炖肉、摆盘排成流水线的同款本领。

具体案例:某算法团队每天离线对 20 万条样本做 LLM 打分,用 asyncio 控制 50 路并发,对超时任务自动 cancel 并重试 2 次,跑批时间从 9 小时压缩到 2.5 小时。

Python 演示

python64 行
import asyncioimport time  async def call_service(name, delay):    """模拟一次 I/O 请求: 发出请求后让出 CPU, 等内核通知回来"""    await asyncio.sleep(delay)    return f"{name}:ok"  async def demo_gather():    """并发聚合: 5 个请求同时发, 总耗时约等于最慢的那个"""    start = time.time()    results = await asyncio.gather(        call_service("redis", 0.3),        call_service("mysql", 0.5),        call_service("llm-api", 1.0),        call_service("vector-db", 0.4),        call_service("cache", 0.1),    )    print(f"gather 结果: {results}")    print(f"并发耗时: {time.time() - start:.2f}s (串行需要 2.3s)")  async def demo_cancel():    """取消: Task 被 cancel 后在 await 点抛 CancelledError"""    async def slow_job():        try:            await asyncio.sleep(10)        except asyncio.CancelledError:            print("收到取消通知, 正在关闭数据库连接池...")            raise        finally:            print("清理完成, 任务退出")     task = asyncio.create_task(slow_job())    await asyncio.sleep(0.2)    task.cancel()    try:        await task    except asyncio.CancelledError:        print("外部确认: 任务已取消")  async def demo_exception():    """异常传播: 某个子任务抛错, gather 会取消兄弟任务并上抛异常"""    async def bad_job():        await asyncio.sleep(0.2)        raise RuntimeError("上游服务返回 500")     try:        await asyncio.gather(call_service("good", 0.1), bad_job())    except RuntimeError as e:        print(f"异常捕获: {e}")  async def main():    await demo_gather()    await demo_cancel()    await demo_exception()  if __name__ == "__main__":    asyncio.run(main())
目的:用最小可运行示例演示 asyncio 三大核心行为——并发聚合(gather)、任务取消(cancel)与异常传播,输出结果直观展示协程如何“见缝插针”。
  1. await asyncio.sleep(delay)这一行是“等水开”的挂起点,协程在此把 CPU 让出去
  2. results = await asyncio.gather(...)把 5 个协程一起交给事件循环并发调度,总耗时只取最慢一个
  3. task = asyncio.create_task(slow_job())把协程包装成 Task 挂上订单板,交给事件循环管理
  4. task.cancel()发出撤单铃:向任务注入 CancelledError
  5. except asyncio.CancelledError:协程在 await 点收到取消通知,可以决定配合清理还是无视
  6. finally:无论正常、异常还是取消,清理代码必跑,如同撤单也要关火洗锅
  7. asyncio.run(main())创建事件循环、跑主协程、结束后关闭,是 asyncio 程序的统一入口

常见误区

误区我以为 `coro = my_async_func()` 之后函数就开始跑了,返回的对象就是执行结果。结果等了半天啥也没发生,我一度怀疑是电脑坏了。

正解my_async_func() 只是生成一张写好的菜谱卡——返回协程对象,函数体一行都没执行。要让大厨动手,必须 await coro(直接做这道菜)、asyncio.create_task(coro)(把卡片挂上订单板),或者 asyncio.gather(...)(多卡一起排)。光写菜谱不交单,厨房永远不会响锅。

为何常见:普通函数“一调就执行”的心智模型太牢固,很少有人意识到 async 函数调用只是“构造对象”,执行权在事件循环手里。

误区asyncio 能让我 10 个任务同时执行,就像多核 CPU 一样。我把图片压缩改成协程,应该能快 10 倍吧?

正解单线程事件循环同一时刻只有一个协程在运行,其余都在挂起等待——这是协作式并发,不是并行。大厨只有一个,他再快也是一道菜一道菜地做;并行是雇十个大厨,各占一口灶。CPU 密集任务用 asyncio 不仅不加速,反而因调度开销略变慢,该用 multiprocessing 或多个 worker 进程。

为何常见:“并发”和“并行”中文读音相近、概念常被混用;线程的抢占式调度与协程的主动让出,也容易让人误以为它们运行方式相同。

误区我觉得 time.sleep(1) 和 await asyncio.sleep(1) 差不多,反正都是等一秒,只是写法不同而已。

正解天差地别。time.sleep 是同步阻塞——它把整个线程连同事件循环一起冻住,所有协程全部停摆,如同大厨真的蹲在锅前发呆,整个后厨都瘫痪;await asyncio.sleep 会挂起当前协程并把控制权交还事件循环,大厨趁机去切别的菜。同样的坑还包括在协程里调用 requests.get、read() 等同步 I/O。

为何常见:两个 API 名字都带 sleep,使用位置又一样,被误当作等价物几乎是必然;本质差异在“有没有让出控制权”,光看名字看不出来。

误区取消嘛,跟杀进程一样,一调 cancel() 任务就没了,文件、连接池这些操作系统会自动回收,我啥也不用管。

正解取消是一次协作式握手,不是强杀。cancel() 只是在当前 await 点抛一个 CancelledError;如果协程里用 except 把它吞掉,任务会活得好好的,甚至继续跑完。打开的文件、数据库连接也不会自动关闭——撤单的大厨必须自己关火、倒掉半成品、把锅洗干净,也就是在 finally 或 async with 里显式清理,然后再把异常抛上去。

为何常见:操作系统杀进程确实会回收资源,这个经验很容易被错误类比到协程取消;实际上协程的取消更像“礼貌地通知你该收手了”,收不收拾由代码决定。

面试题

Q1. Python 中调用 async 函数但不 await,结果是什么?

单选题 · easy

  • A.函数体立即执行并返回结果
  • B.返回协程对象函数不执行← 正确答案
  • C.函数在后台线程中立即执行
  • D.抛出语法错误要求等待

答案:B。async 函数调用只生成协程对象,函数体一行都未执行,就像写好菜谱卡却没交到订单板上,大厨根本不会看它一眼。必须 await、create_task 或 gather 调度后,代码才真正开始跑。

Q2. 下列哪个 API 能同时调度多个协程并等待全部完成?

单选题 · easy

  • A.asyncio.sleep 实现延时等待
  • B.asyncio.create_task 注册任务
  • C.asyncio.gather 并发聚合等待← 正确答案
  • D.asyncio.run 运行单个协程

答案:C。gather 把多个协程打包交给事件循环并发调度,并等待全部完成,相当于把五张菜谱卡同时挂上板,大厨见缝插针全部做完再一起上菜。create_task 只是登记任务,并不保证等齐所有人。

Q3. Python 原生的 asyncio 协程属于哪种实现流派?

单选题 · easy

  • A.栈式协程自带独立调用栈
  • B.无栈协程状态保存于堆上← 正确答案
  • C.内核线程封装的轻量包装
  • D.操作系统级纤程的直接映射

答案:B。asyncio 协程不拥有自己的调用栈,挂起时状态被拷贝到堆对象上,是无栈协程。栈式协程(如 Lua 协程)自带独立栈,可以在任意深层调用中挂起,但内存开销更大。

Q4. 协程挂起时不需要保存以下哪一项?

单选题 · mid

  • A.程序计数器 PC 的位置
  • B.函数局部变量的当前值
  • C.循环计数的中间结果
  • D.操作系统线程完整内核栈← 正确答案

答案:D。无栈协程挂起只需保存 PC 和局部变量,线程自己的内核栈不受影响,就像菜谱卡只记“做到哪一步、锅里有什么”,不会把整个厨房都搬走。

Q5. 在 async 函数内部直接调用 time.sleep(1) 的后果是?

单选题 · mid

  • A.只挂起当前协程等待一秒
  • B.整个事件循环卡死约一秒← 正确答案
  • C.自动转换为异步延迟执行
  • D.编译器报错并终止运行

答案:B。time.sleep 是同步阻塞调用,不会把控制权交还事件循环,等它返回时整个循环都停摆——大厨站在锅前发呆,所有订单都冻结。协程里等待要用 await asyncio.sleep。

Q6. 关于事件循环的微任务队列,正确的说法是?

单选题 · mid

  • A.微任务优先级低于定时器
  • B.宏任务之后优先清空微任务← 正确答案
  • C.微任务与宏任务随机轮询
  • D.微任务跑在独立的工作线程

答案:B。事件循环在每个宏任务结束后会优先清空微任务队列,保证“撒葱花”级别的收尾动作不被新的大菜插队,协程恢复等高时效逻辑因此能低延迟续跑。

Q7. 以下哪类任务最适合用 asyncio 协程优化?

单选题 · mid

  • A.大规模矩阵乘法的密集计算
  • B.等待数据库响应的慢查询← 正确答案
  • C.视频编码压缩的转码任务
  • D.图形渲染软件光栅化处理

答案:B。协程擅长 I/O 密集场景:等待响应时挂起,把 CPU 让给其他协程。数据库慢查询的等待占比极高,正好用得上;矩阵乘法、视频编码是 CPU 密集,需要多进程并行而非协程并发。

Q8. 关于 asyncio.Task 取消机制,正确的有哪些?

多选题 · hard

  • A.取消是协作式而非强制杀死← 正确答案
  • B.取消在 await 点抛取消异常← 正确答案
  • C.协程可吞掉异常拒绝取消← 正确答案
  • D.调用取消任务立刻终止运行

答案:ABC。取消是协作式握手:cancel() 只是注入 CancelledError,任务在 await 点收到异常;若用 except 吞掉异常,任务会继续运行,所以必须先清理再 raise。D 把取消误解为强杀,是初学者最常见的错误。

Q9. 关于协程与线程对比,正确的描述有哪些?

多选题 · hard

  • A.协程切换发生在用户态完成← 正确答案
  • B.线程切换由操作系统内核调度← 正确答案
  • C.单协程实例仅占用数 KB 内存← 正确答案
  • D.单事件循环可充分利用多核

答案:ABC。协程切换是用户态函数跳转,不陷入内核,开销极低;线程切换要内核参与,成本高。协程对象紧凑小巧,数 KB 内存即可;但单事件循环只有一个线程,无法利用多核并行。

Q10. 关于协程异常传播与取消的边界行为,正确的有哪些?

多选题 · extreme

  • A.未调度的协程异常不会自动上抛← 正确答案
  • B.gather 遇首异常会取消其余任务← 正确答案
  • C.收集异常参数可避免 gather 抛出← 正确答案
  • D.取消异常应当被外层静默吞掉

答案:ABC。协程对象若从未被调度(没有 await/gather),其内部异常不会自动传播;gather 默认 return_exceptions=False,首个异常会取消兄弟任务并上抛;设置 return_exceptions=True 可收集异常不抛出。CancelledError 继承自 BaseException,只应做清理后继续上抛,不应静默吞掉。