想象你是一家繁忙咖啡店的唯一咖啡师。早晨高峰,20 个顾客同时下单:美式、拿铁、手冲……如果你坚持“一次只做一杯”,做那杯手冲(要磨豆、焖蒸、注水,整整 4 分钟)时,其他 19 位顾客就只能干等。但聪明的咖啡师会这样干:手冲正在滴滤时,转身去把美式做了;美式在接咖啡液时,又去给拿铁打奶泡——每一秒都没闲着。 这种“一件事在等待时,转头去做另一件事”的能力,就是协程调度的灵魂。Python 的 asyncio 就像一个训练有素的咖啡师调度系统,它知道每个订单(协程)进行到哪一步、谁在等什么资源、谁可以暂停、谁需要立刻恢复。理解协程调度,就是读懂这位咖啡师的排单心法:什么时候挂起、什么时候恢复、如何决定先做哪杯、如何保证没有客人被无限晾着。

岗位关联

掌握协程调度,你就能在高并发 IO 场景下用极少量线程撬动海量并发任务,让代码在“等待”中榨干每一滴 CPU 能力——这是后端开发进阶的必修内功。

目录
  1. 可视化演示
  2. 调度基础
  3. await挂起恢复
  4. 任务优先级
  5. 调度器策略
  6. 并发控制
  7. 避免协程饿死
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

演示Python协程调度的核心机制,包括await挂起恢复、任务优先级、并发控制、信号量限流和公平调度,通过交互式可视化展示协程在事件循环中的执行流程。

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

Await挂起恢复 遇到await挂起当前协程 保存执行状态到协程对象 事件循环调度其他就绪任务 await是协程调度的核心机制

调度基础

咖啡师小明的台面上贴着一张便利贴菜单,上面写着所有待做订单。他不需要记住每一杯的操作细节——只要看菜单就知道:3 号美式在等浓缩液、7 号拿铁在等蒸汽、9 号手冲在滴滤。这张便利贴,就相当于事件循环(Event Loop)里的任务队列;每个订单就是队列中的一个协程。

Python 中 async def 定义的函数被调用时并不会执行,而是返回一个协程对象——就像顾客点单后拿到的小票,上面写着“将做一杯美式”,但还没开始做。要让协程真正跑起来,需要把它包成 Task(任务)扔进事件循环。asyncio.create_task() 就是这个动作:把小票交给咖啡师,咖啡师把它贴到便利贴上排队。

事件循环是这个调度系统的核心引擎,它维护两类集合:就绪队列(可以立刻做的订单)和等待队列(在等咖啡机/牛奶/热水的订单)。循环每转一圈,就从就绪队列取一个任务执行,遇到等待条件就把任务扔进等待队列,然后转头处理下一个就绪任务。

调度基础就像咖啡师的便利贴系统:事件循环是咖啡师本人,就绪队列是“在台面上直接能做的订单”,等待队列是“需要等机器的订单”。没有这个基础,咖啡师只会手忙脚乱——理解了它,你就能看懂后面所有调度行为的底层逻辑。

事件循环→从就绪队列取任务执行协程→await 等待 IO切入等待队列→IO 完成回到就绪队列\text{事件循环} \xrightarrow{\text{从就绪队列取任务}} \text{执行协程} \xrightarrow{\text{await 等待 IO}} \text{切入等待队列} \xrightarrow{\text{IO 完成}} \text{回到就绪队列}
事件循环本质是一个 while True 的轮询器,它维持任务队列不断切换执行,让所有任务公平地推进。
  1. 事件循环取任务Python 中 loop.run_until_complete() 启动事件循环,循环从就绪队列取出一个可执行的 Task。
    task=ready_queue.dequeue()\text{task} = \text{ready\_queue.dequeue()}
  2. 执行协程直到等待对 Task 调用其协程的 send(None) 方法,协程会一直执行到遇到下一个 await 表达式,然后暂停。
    result=coroutine.send(None)\text{result} = \text{coroutine.send}(\text{None})
  3. 切入等待队列await 的可等待对象(如 Socket IO)如果尚未就绪,Task 会被注册到事件循环的等待队列中,同时协程状态被保存。
    task.state=WAITING\text{task.state} = \text{WAITING}
  4. IO 完成后回归就绪当 IO 事件到达(如数据可读),事件循环该 Task 移回就绪队列,等待下一轮被调用。
    task.state=READY⇒ready_queue.enqueue(task)\text{task.state} = \text{READY} \quad \Rightarrow \quad \text{ready\_queue.enqueue(task)}

await挂起恢复

“等待”是协程调度的灵魂时刻。小明做美式时发现浓缩机还在预热,他没有傻站着,而是把美式订单翻个面(标记“等在预热”),转手去做拿铁——先打奶泡。这个“翻面”动作就是协程的挂起,对应的代码就是 await。

await 的关键语义是挂起而不阻塞。它告诉事件循环:“当前这个任务需要等一个东西(IO、定时器、另一个协程),在此期间请先执行别的任务。” 注意,挂起的是当前协程,而整个事件循环的线程并没有停下来——咖啡师本人没有闲着,他还在做别的订单。

挂起时,协程的完整状态(局部变量、指令指针、调用栈)会被保存在 Task 对象中。这就像给订单夹上一个便签:“美式,浓缩机预热到 90°C 后,加 1 泵糖浆,用 350ml 杯。” 恢复时,事件循环通过调用 coroutine.send(value) 从上次暂停的位置继续执行。

await 挂起与恢复,就是咖啡师“不傻等”的秘诀。每个订单被翻面等待时,那杯咖啡的进度被完整记录下来,等机器就绪后咖啡师拿起便签接着做——没有一步操作被遗漏。在你的代码里,每一次 await 都是在告诉事件循环:“这里可能要等,先安排别人,我很快就回来。”

执行流→await 表达式⏟挂起点→保存上下文→注册回调→让出 CPU→IO 完成后恢复→继续执行\text{执行流} \rightarrow \underbrace{\text{await 表达式}}_{\text{挂起点}} \rightarrow \text{保存上下文} \rightarrow \text{注册回调} \rightarrow \text{让出 CPU} \rightarrow \text{IO 完成后恢复} \rightarrow \text{继续执行}
await 挂起的是协程,不是线程。底层线程还在事件循环驱动下执行着其他协程。
  1. 执行到 awaitPython 解释器执行协程代码时碰到 await,这是一个“我可能要等”的信号。
    await A\text{await}\ A
  2. 检查可等待对象Python 调用 A.__await__() 获取迭代器,若 A 尚未完成,协程会暂停执行。
    iter=A.__await__()⇒yield\text{iter} = A.\_\_await\_\_() \Rightarrow \text{yield}
  3. 保存状态并挂起当前帧的局部变量、行号等被打包保存到 Task 对象,Task 被加入等待队列。
    task._state=SUSPENDED,task._stack=current_frame\text{task.\_state} = \text{SUSPENDED}, \quad \text{task.\_stack} = \text{current\_frame}
  4. IO 完成恢复执行事件循环收到 IO 就绪通知,调用 task.coro.send(result),协程从挂起点继续。
    result=coro.send(io_ready)\text{result} = \text{coro.send}(\text{io\_ready})
  5. 继续运行await 表达式求值得到结果,协程继续执行后续代码,直到下一个 await 或函数结束。
    value=A 的返回值⇒继续后续代码\text{value} = \text{A 的返回值} \Rightarrow \text{继续后续代码}

任务优先级

咖啡店有 VIP 客户,也有赶时间的外卖骑手。小明在便利贴菜单上会用红色马克笔标出急单,这些订单会提前做。在协程调度中,这种“急单机制”通过 asyncio.wait 的 timeout 参数和 asyncio.wait_for 来实现,但更底层的优先级逻辑依赖 asyncio 的 Queue 配合 PriorityQueue 自定义实现。

Python 原生 asyncio 的 Task 本身没有公开的优先级属性,但我们可以通过调度顺序控制来影响优先级:先 create_task 的任务通常先被调度(FIFO),而 loop.call_soon() 可以插队到队首。此外 asyncio.sleep(0) 是一个“让位”技巧,主动把执行权交还给事件循环,让其他同优先级任务有机会运行。

对于需要严格优先级的场景,更实用的方式是结合 asyncio.Queue 的优先级变体:

优先级机制就像咖啡师的红色马克笔。不是所有订单都一样紧急——VIP、外卖、平台超时单需要优先处理。你的代码也一样:用 PriorityQueue 把紧急任务排到队首,让调度器优先服务。注意,这件事是“调度顺序”的体现,和操作系统进程优先级的内部抢占机制不同;asyncio 的协作式模型下,高优任务是“被先取到并执行”,而不是“打断别人”。

PriorityQueue:heapq 堆排序⇒每次出队取最小优先级编号\text{PriorityQueue} \quad : \quad \text{heapq 堆排序} \Rightarrow \text{每次出队取最小优先级编号}
优先级本质是“调度顺序”的体现:高优任务在队列前面,低优任务在后面,事件循环按顺序取出。
  1. 按优先级入队任务进入 PriorityQueue 时按 (priority, task) 元组存储,底层用堆排序维护。
    heapq.heappush(q,(pi,taski))\text{heapq.heappush}(q, (p_i, \text{task}_i))
  2. 取队首(最小优先级号)事件循环从这里拿任务时,总拿到堆顶的最低编号,例如 p=1 的高优任务。
    pmin⁡,task=heapq.heappop(q)p_{\min}, \text{task} = \text{heapq.heappop}(q)
  3. 执行该任务拿到任务后对其 await 完成,结束后回到取任务循环,继续取下一个最高优先级任务。
    await task⇒回到 worker 的取队列循环\text{await task} \Rightarrow \text{回到 worker 的取队列循环}
python12 行
import asynciofrom asyncio import PriorityQueue async def worker(q):    while True:        priority, coro = await q.get()        await coro        q.task_done() q = PriorityQueue()await q.put((1, task_a()))  # 数字越小优先级越高await q.put((10, task_b()))

调度器策略

小明的老板给他定了一条规矩:绝不和任何顾客纠缠超过 30 秒。如果一个订单太复杂,先做到一半停下来,做完其他简单订单再回来继续。这在计算机调度中叫时间片轮转(Round-Robin)。而 asyncio 的调度策略有自己的特色——它是协作式(Cooperative)的:切换不是靠外部打断,而是靠每个协程自觉 await。

asyncio 的默认调度策略是协作式 + FIFO 队列:每个就绪任务按被创建的先后排列,事件循环依次调用它们,直到每个任务遇到 await 才让位。这种策略的优点是切换开销极小(没有操作系统级上下文切换,只是 Python 函数暂停/恢复),缺点是某个协程如果不 await 就长时间占用 CPU,整个循环会被打断。

这是 asyncio 和线程调度最本质的区别:线程调度是抢占式的(操作系统强制切走),协程是协作式的(本人主动让位)。

调度策略就像咖啡团队的工作章程:协作式让每个咖啡师自行安排节奏,时间片轮转保证没有顾客被无限冷落。理解这一点至关重要——在线程世界里,“不干活”是会被系统打断的;在协程世界里,你必须在代码里主动 await 让位,否则整个异步系统都会卡壳。协作式的代价是“自觉”,好处是“零切换开销、超高并发吞吐”。

协作式:Task 执行→await让出 CPU→就绪重新排队\text{协作式:} \quad \text{Task 执行} \xrightarrow{\text{await}} \text{让出 CPU} \xrightarrow{\text{就绪}} \text{重新排队}
抢占式:Task 执行→时间片耗尽系统强制切换\text{抢占式:} \quad \text{Task 执行} \xrightarrow{\text{时间片耗尽}} \text{系统强制切换}
asyncio 使用协作式调度,每个协程自觉 await 让位;如果某个协程从不释放,其他协程永远等不到执行。
  1. 创建任务进入运行队列事件循环运行时,任务按创建顺序放入内部就绪队列。
    ready_queue=[T1,T2,T3,…,Tn]\text{ready\_queue} = [T_1, T_2, T_3, \dots, T_n]
  2. 顺序执行每个任务事件循环从队首取出任务,驱动其执行,直到遇到 await。如果 await 的对象未就绪,任务进入阻塞集合;如果已就绪,则继续执行。
    T1→执行await→T2→执行await→T3…T_1 \xrightarrow{\text{执行}} \text{await} \rightarrow T_2 \xrightarrow{\text{执行}} \text{await} \rightarrow T_3 \dots
  3. 就绪任务回到队尾被 await 的数据准备好后,任务回到就绪队列尾——公平轮转,人人有机会。
    ready_queue.append(T1)\text{ready\_queue.append}(T_1)

并发控制

咖啡店把订单交给三个咖啡师分别做。老板需要知道三件事:所有订单都做完没有?,哪个订单先做完?,同时最多允许几个订单在操作?——对应 asyncio 的 gather、wait 和信号量(Semaphore)。

asyncio.gather 是最常用的并发等待工具。它接收多个协程或任务,并发地运行它们,然后等待全部完成,并返回每个任务的结果列表。如果其中一个任务抛出异常,gather 会默认把异常抛给调用方,而且不会等待其他任务完成——这就像咖啡师看到某杯咖啡打翻了,立刻全局报错。

asyncio.wait 更灵活:它支持 return_when=asyncio.FIRST_COMPLETED 或 ALL_COMPLETED,返回 (done, pending) 两个集合。这就像老板不需要等所有订单完成,可以先处理第一批完成的外卖单。但要注意,wait 对异常的处理更温和——它不会抛异常,而是把任务放进 done 集合里等你自己检查。

Semaphore(信号量) 是最实用的并发控制工具,它能限制同时运行的任务数量,起到限流作用。

并发控制是调度系统给咖啡师的“管理仪表盘”:gather 告诉他“这桌订单全部齐了再上菜”,wait 告诉他“哪个先好就先端哪个”,Semaphore 告诉他“后厨同时最多开 3 个灶,别把厨房挤爆”。在实际项目中,gather 常用于批量请求多个 API 并汇总结果,wait 常用于“只要有第一个成功响应就开始做后续处理”,Semaphore 则是保护数据库不会被打爆的必备利器。

gather(T1,T2,…,Tn)⇒等待所有完成⇒[res1,res2,…,resn]\text{gather}(T_1, T_2, \dots, T_n) \Rightarrow \text{等待所有完成} \Rightarrow [\text{res}_1, \text{res}_2, \dots, \text{res}_n]
{T1,T2}=wait(T1,T2,T3,return_when=FIRST_COMPLETED)\{T_1, T_2\} = \text{wait}(T_1, T_2, T_3, \text{return\_when}=\text{FIRST\_COMPLETED})
gather 适合“等全部结果”;wait 适合“有结果就处理”;Semaphore 限制最大并发数,保护下游资源。
  1. 创建任务集合gather 会把传入的协程先包装成 Task,统一调度。
    tasks={ensure_future(T1),…,ensure_future(Tn)}\text{tasks} = \{\text{ensure\_future}(T_1), \dots, \text{ensure\_future}(T_n)\}
  2. 等待集合中所有任务完成内部生成一个共享的 Future,当所有任务都 done 时该 Future 完成。
    ⋀i=1n(taski.done())⇒future.set_result(results)\bigwedge_{i=1}^{n} (\text{task}_i.\text{done}()) \Rightarrow \text{future.set\_result}(\text{results})
  3. wait 的 FIRST_COMPLETED 分支只要有一个任务完成,立即返回 done 集中已完成的子集,pending 中包含其余未完成任务。
    ∃i:taski.done()⇒return(done,pending)\exists i: \text{task}_i.\text{done}() \Rightarrow \text{return}(\text{done}, \text{pending})
  4. Semaphore 限流每个任务在进入临界区前先获取许可(若超出计数则等待),完成后释放。
    semaphore.acquire()⇒执行任务⇒semaphore.release()\text{semaphore.acquire()} \Rightarrow \text{执行任务} \Rightarrow \text{semaphore.release()}

避免协程饿死

小明犯过一个错误:他老是优先做那些快的、简单的订单,结果有个大单(手冲+拉花+三层蛋糕)一直排在最后,客人等了 50 分钟气走了。这就是饿死(Starvation)——某个任务因为优先级太低或调度不公,一直得不到执行机会。

在 asyncio 中,饿死通常有两个典型原因。原因一:某个协程是死循环且从不 await,导致事件循环永远无法执行其他就绪协程——它是“霸占咖啡师的客人”;原因二:高优先级任务源源不断补充,低优先级任务永远排在末尾——它是“被 VIP 不断插队的普通顾客”。

解决方案有三板斧。第一板斧:公平轮转——确保每个协程都定期 await 让位,让其他协程获得 CPU;第二板斧:超时保护——用 asyncio.wait_for(coro, timeout) 给每个任务设定最大等待时间,超时就放弃或重试;第三板斧:任务混排——在使用 PriorityQueue 时对相同优先级的任务做轮转,避免极端倾斜。

避免饿死,是不让任何一杯咖啡被永久遗忘。公平轮转让每个任务都有发言机会,超时保护让失控任务被强制叫停,任务混排让优先级队列不至于变成“VIP 专用通道”。写协程代码时,时刻反问自己:我的某个低优任务会不会永远等不到调度?我的某个高优协程是不是从未让出过 CPU?—— 这就是协程调度的“面试官之问”,也是最容易在生产环境踩坑的地方。

饥饿检测:twaiting>Tthreshold⇒提升优先级或强制运行\text{饥饿检测:} \quad t_{\text{waiting}} > T_{\text{threshold}} \Rightarrow \text{提升优先级或强制运行}
await 是协程世界的“呼吸”:每个协程必须定时呼吸(让位),否则其他协程会被憋死。
  1. 任务持续等待低优先级任务每次调度都被高优任务抢在前面,它的等待时间不断增长。
    Wi(t)≈∑j∈highej(t)⇒Wi→∞W_i(t) \approx \sum_{j \in high} e_j(t) \Rightarrow W_i \to \infty
  2. 触发饥饿保护当等待时间超过阈值,调度器介入,临时将这个任务的优先级提到队首。
    Wi>Tthreshold⇒pi=pmin⁡W_i > T_{\text{threshold}} \Rightarrow p_i = p_{\min}
  3. 超时强制放弃或使用 wait_for 包裹,超时后取消协程并抛出 TimeoutError。
    timeout⇒task.cancel()⇒raise TimeoutError\text{timeout} \Rightarrow \text{task.cancel()} \Rightarrow \text{raise TimeoutError}
  4. 定期让位协程内主动添加 await asyncio.sleep(0),保证事件循环有其他任务被调度的机会。
    await sleep(0)⇒让出 CPU⇒ready_queue.append(self)\text{await sleep}(0) \Rightarrow \text{让出 CPU} \Rightarrow \text{ready\_queue.append}(self)

AI 应用场景

场景一:高并发爬虫限流调度

阶段:数据采集与预处理

解决的问题:传统爬虫每请求一个页面就阻塞等待响应,串行采集 1000 个 URL 要几十分钟。即使改为多线程,线程上下文切换开销大,且对目标网站压力不可控,容易触发反爬封禁。

具体案例:某 AI 训练数据服务商需要每天从 50 个新闻网站抓取 10 万篇文章。使用 asyncio.gather 并发发起请求,同时用 asyncio.Semaphore(20) 限制同时最多 20 个 HTTP 请求,每个请求设置 wait_for 10 秒超时。抓取速度提升 40 倍,服务器负载平稳,零封禁。

场景二:实时推理服务任务调度

阶段:模型推理与 Serving

解决的问题:AI 在线推理服务同时接收大量用户请求,若串行处理,GPU 利用率低且响应延迟高;若直接无限制并发,GPU 显存和 CPU 计算资源会被瞬时打满,导致雪崩。

具体案例:某推荐系统推理服务使用自建协程调度器:请求进来后包装成协程任务,按用户等级标记优先级(VIP 用户进高优队列),用 Semaphore 限制并发数为 GPU 批处理最优值(如 32),并用 gather 收集一个 batch 的推理结果后统一返回。P99 延迟从 800ms 降到 120ms,GPU 利用率从 30% 提升到 90%。

场景三:AI Agent 多任务并行规划

阶段:Agent 编排与任务执行

解决的问题:AI Agent 在执行复杂任务时,经常需要同时调用多个工具(搜索、代码执行、数据库查询、模型调用),且部分工具响应慢。若串行调用,Agent 思考链路过长;若随意并发,结果时序混乱且资源浪费。

具体案例:个人 AI 助理在回答“帮我对比三款显卡并推荐”时,用 asyncio.gather 同时发起三个商品搜索请求,用 wait(FIRST_COMPLETED) 优先处理最先返回的结果,并为每个工具调用设置超时(防止模型等待过久)。整个回答链路从 15 秒缩短到 4 秒,用户体验大幅提升。

Python 演示

python59 行
import asyncioimport randomimport time async def fetch_data(url, delay):    """模拟一个 IO 请求:网络等待 + 数据处理"""    print(f"开始请求: {url}")    await asyncio.sleep(delay)          # 模拟网络延迟    result = f"{url} -> 数据({delay}s)"    print(f"完成请求: {url}")    return result async def limited_fetch(sem, url, delay):    """带信号量保护的请求,防止并发过大"""    async with sem:                     # 获取许可,超过并发数则等待        return await fetch_data(url, delay) async def main():    urls = [        ("https://api.a.com/item", 0.5),        ("https://api.b.com/item", 1.0),        ("https://api.c.com/item", 0.3),        ("https://api.d.com/item", 0.8),    ]     # 1. gather: 并发执行所有任务,等待全部完成    print("=== gather 演示 ===")    results = await asyncio.gather(        fetch_data("https://g-1.com", 0.4),        fetch_data("https://g-2.com", 0.6),    )    print("全部完成:", results)     # 2. wait: 只要有最先完成的任务就处理    print("\n=== wait 演示 ===")    tasks = [asyncio.create_task(fetch_data(u, d)) for u, d in urls]    done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)    for t in done:        print("最先完成:", t.result())    for t in pending:        t.cancel()                      # 取消未完成的任务    print("剩余已取消:", len(pending))     # 3. Semaphore: 限制最大并发数为 2    print("\n=== 信号量限流演示 ===")    sem = asyncio.Semaphore(2)    tasks2 = [limited_fetch(sem, u, d) for u, d in urls]    completed = await asyncio.gather(*tasks2)    print("限流完成:", completed)     # 4. wait_for: 超时保护,防止任务饿死    print("\n=== 超时保护演示 ===")    try:        await asyncio.wait_for(fetch_data("https://slow.com", 5), timeout=1)    except TimeoutError:        print("任务超时,已强制取消") if __name__ == "__main__":    asyncio.run(main())
目的:演示 asyncio 中 gather、wait、Semaphore、wait_for 四种调度控制手段的用法与区别。
  1. await asyncio.sleep(delay)模拟网络 IO 等待,主动让出执行权给事件循环
  2. asyncio.gather(...)并发执行多个协程,返回所有结果组成的列表
  3. asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)只等最快的任务先返回,不阻塞等待全部完成
  4. asyncio.Semaphore(2)同时最多允许 2 个协程进入临界区,防止并发过高
  5. asyncio.wait_for(future, timeout=1)给协程设定超时,超过 1 秒就强制取消,避免任务卡死

常见误区

误区我一直以为 `await asyncio.sleep(1)` 会像 `time.sleep(1)` 一样把程序卡住一秒钟,用 await 和不用 await 好像没区别,程序照样是慢吞吞地一个个执行。

正解恰恰相反,await 是“让位不阻塞”。当协程执行到 await 时,它会把执行权交还给事件循环,事件循环立刻去执行其他就绪的协程。就像咖啡师碰到浓缩机预热,不是傻站一分钟,而是立刻去做别的单。time.sleep 是“整个人定住不能干活”,await asyncio.sleep 是“这个订单暂停,人去做别的”。

为何常见:初学者从同步编程转型,习惯了“代码一行行顺序执行”的心智模型,难以理解“暂停后还能跳去执行别处”的控制流反转。

误区协程听起来很强大,那我用它来并行计算几个大数的斐波那契数列,应该能快好几倍吧?毕竟“并发”嘛。

正解协程只能在 IO 等待时切换,不能利用多核 CPU。协程本质是单线程内的协作式调度,如果任务是纯 CPU 计算且不 await,整个事件循环会被卡住,其他协程完全没机会执行。加速 CPU 密集计算要用多进程(ProcessPoolExecutor)或多线程;协程的舞台是 IO 密集场景(网络请求、文件读写、数据库查询)。

为何常见:把“并发”和“并行”混淆了。并发是逻辑上同时推进(交替执行),并行是物理上同时执行(多核)。协程是并发的高手,不是并行的替代品。

误区gather 和 wait 不都是等任务完成吗?那我随便哪个方便用哪个,反正效果一样。

正解二者行为差异很大。gather 会在任一任务抛异常时直接向上抛(默认 cancel 其他任务);wait 不抛异常,而是把所有任务(包括异常/完成的)放进 done 集合,需要你自己检查 task.exception()。gather 返回结果列表(顺序固定),wait 返回 (done, pending) 集合(无序)。选错了,异常处理和结果获取逻辑会完全走样。

为何常见:只看两者的“等待完成”表象,忽略了错误传播和返回值结构的本质区别。

误区我加了 `asyncio.Semaphore(100)` 让并发数量变大,程序应该跑得更快吧?把信号量设得越大越好。

正解Semaphore 是限流器,不是加速器。它约束的是“同时最多 N 个任务进入临界区”,防止下游资源(数据库连接数、API 频率限制)被打爆。并发数太高会导致下游拒绝服务、超时重试风暴,整体吞吐反而下降。合适的并发数要通过压测确定(如数据库连接池上限、目标 API 的 QPS 限制),不是越大越好。

为何常见:把并发数与吞吐量划等号,忽略了系统瓶颈通常在外部资源而不是事件循环本身。

误区协程这么好,那我写个 `while True` 一直处理数据应该没问题吧?反正它是异步的。

正解大问题!协程必须定期 await 让位,否则会霸占事件循环,导致所有其他协程饿死。如果死循环里没有任何 await,整个 asyncio 程序相当于被单线程卡死。解决办法是:循环体内部加上 await asyncio.sleep(0) 主动让位,或改用 asyncio.Queue 配合 wait_for 来设计生产者-消费者模式。

为何常见:把协程和线程混淆。线程是抢占式的,系统会自动切走;协程是协作式的,不主动让位没人能打断它。

面试题

Q1. await 关键字在协程中的核心作用是什么?

单选题 · easy

  • A.阻塞线程直到 IO 操作完成
  • B.将 CPU 执行权交还给事件循环并挂起当前协程← 正确答案
  • C.强制销毁当前协程
  • D.为协程分配更高优先级

答案:B。await 是“挂起而不阻塞”,它将执行权交还事件循环,让其他任务运行。A 是 time.sleep 的同步阻塞;C 是 cancel;D 与 await 无关,优先级由调度器/队列控制。

Q2. asyncio 的调度策略属于哪种类型?

单选题 · easy

  • A.抢占式调度
  • B.协作式调度← 正确答案
  • C.时间片轮转调度
  • D.优先级反转调度

答案:B。asyncio 是协作式调度,任务必须自觉 await 让位。线程调度(A)才是抢占式的;时间片轮转(C)是操作系统的调度算法,不是 asyncio 的核心机制。

Q3. 关于 gather 和 wait 的区别,下列哪项描述正确?

单选题 · mid

  • A.gather 在有任务异常时直接向上抛,wait 需要手动检查 exception← 正确答案
  • B.gather 返回集合,wait 返回结果列表
  • C.gather 能控制超时,wait 不能
  • D.wait 能限制并发数,gather 不能

答案:A。gather 在任务异常时默认取消其他任务并抛异常;wait 不会抛异常,任务异常被包在 Task 对象里需手动检查(task.exception())。wait 不能控制超时(要用 wait_for),二者都不能直接限流(要用 Semaphore)。

Q4. 以下哪种行为会导致 asyncio 中其他协程饿死?

单选题 · mid

  • A.一个协程频繁执行 await asyncio.sleep(0)
  • B.一个协程内部死循环且无任何 await← 正确答案
  • C.使用 Semaphore(100) 限制并发
  • D.使用 wait_for 给任务设置超时

答案:B。协作式调度下,死循环且无 await 的协程会霸占事件循环,其他协程永远得不到执行。sleep(0) 是主动让位不会饿死别人;Semaphore 和 wait_for 是保护机制,不会导致饿死。

Q5. 关于 Semaphore 的作用,下列说法正确的是?

多选题 · mid

  • A.限制同时进入临界区的协程数量← 正确答案
  • B.加速 CPU 密集计算
  • C.保护下游资源不被超高并发打爆← 正确答案
  • D.提高单协程执行效率

答案:AC。Semaphore 是限流器,限制并发协程数量以保护外部资源(数据库、API)。它不会加速 CPU 计算(B)——那是多进程/多核的职责;也不会改变单协程执行速度(D)。

Q6. asyncio.run(main()) 的作用是?

单选题 · mid

  • A.为 main 创建多个子线程并行执行
  • B.创建事件循环并运行 main 协程直到所有任务完成← 正确答案
  • C.编译 main 协程为机器码执行
  • D.为 main 分配最高优先级

答案:B。asyncio.run 创建一个新的事件循环,运行传入的协程,待其完成后关闭循环。它不会创建子线程(A),协程跑在单线程事件循环内;C 和 D 与 asyncio.run 无关。

Q7. 关于 wait 的 return_when 参数,说法正确的是?

多选题 · hard

  • A.FIRST_COMPLETED 表示最早指定的那个任务完成时返回
  • B.FIRST_COMPLETED 表示任一任务完成时立即返回← 正确答案
  • C.ALL_COMPLETED 表示等待所有任务完成后返回← 正确答案
  • D.ALL_COMPLETED 时 pending 集合始终非空

答案:BC。FIRST_COMPLETED 是任一任务(不是指定任务)完成就返回(A 错误)。ALL_COMPLETED 时所有任务都完成,pending 为空(D 错误)。FIRST_COMPLETED 常用于“先到先处理”的场景,ALL_COMPLETED 则是默认等待全部。

Q8. 以下哪项最能说明 asyncio 适合 IO 密集而非 CPU 密集任务?

单选题 · hard

  • A.协程支持多核并行计算
  • B.IO 等待时协程让位,CPU 被调度给其他任务← 正确答案
  • C.IO 操作会自动创建新线程处理
  • D.await 关键字能将计算分发到多个 CPU

答案:B。asyncio 在 IO 等待时让出 CPU,让其他协程执行,提升整体吞吐。但 C 错误——IO 不一定创建线程;A、D 错误——协程是单线程单核并发,不能直接利用多核。

Q9. 优先级队列中“数字越小优先级越高”意味着什么?

单选题 · easy

  • A.数字大的任务先被执行
  • B.堆顶元素是优先级最小的任务,最先被调度← 正确答案
  • C.所有任务随机执行
  • D.数字大小与执行顺序无关

答案:B。PriorityQueue 内部用堆排序,pop 总是弹出最小值,即优先级最高的任务。这与“数字越小优先级越高”的约定一致。

Q10. 以下哪些手段可以有效避免协程饿死?

多选题 · extreme

  • A.在长循环中加入 await asyncio.sleep(0)← 正确答案
  • B.使用 wait_for 设置任务超时← 正确答案
  • C.允许任务无限运行直到自然完成
  • D.对任务做公平轮转调度(如同等优先级交替执行)← 正确答案

答案:ABD。A 让 CPU 定期让位,D 保证任务交替执行,都是防饿死手段。B 超时保护能强制取消失控任务,也防止饿死。C 是错误做法——无限运行的任务会持续霸占资源,反而是饿死的根源。