想象你是一家川菜馆的后厨主管。后厨只有一个猛火灶,却同时排着几十道菜;外卖窗口只有五个格口,出菜口只挂着一个铃铛。如果大厨们想用就用、想占就占,后厨会怎样?出菜顺序全乱套,两只手同时伸向同一个炒勺,菜撒一地。asyncio 就是一个“单线程后厨”:一个事件循环(Event Loop)像总调度员,几百个协程(Coroutine)像后厨帮工,大家默契地在 await 处让出位置。可只要有人共用同一个变量、同一个连接池,就会像抢炒勺一样打架。于是 Python 给这个后厨配了一整套“协作规矩”:Lock 是灶台挂牌,Semaphore 是窗口限位,Event 是出菜铃,Condition 是对讲机,异步生成器则是自动传送带,async for 就是沿着传送带逐盘夹菜。这篇文章,我们就用这间后厨,把 asyncio 的同步原语与异步生成器讲得明明白白。

岗位关联

掌握 asyncio 同步原语与异步生成器,是写出不卡死、不丢数据的高并发 AI 服务(推理、训练、数据管道)的必备内功,也是 AI 后端工程师面试的常客。

目录
  1. 可视化演示
  2. 并发协作困境
  3. Lock互斥锁
  4. Semaphore信号量
  5. Event事件
  6. Condition条件
  7. 异步生成器
  8. 异步迭代async for
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示asyncio中Lock、Semaphore、Event等同步原语的协程调度过程,交互调节并发数查看锁竞争与信号量控制效果。

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

asyncio.Lock协程互斥锁 Lock获取:同一时刻仅一个协程持有锁 等待队列:其他协程挂起在await lock.acquire() async with lock: critical_section() Lock保证临界区原子执行,避免协程间竞态条件导致数据不一致

并发协作困境

后厨再忙也得有规矩:谁先上灶、谁让一让,全靠默契。asyncio 也一样,单线程加一个事件循环,协程之间的“默契”就写在协作式调度里。事件循环是总调度员,它把 CPU 交给某个协程执行,直到这个协程主动 await——await 就相当于“让出炒勺”,调度器趁机把活交给下一个协程。这套机制让大量 IO 等待不再空转,但也埋下了一个隐患:协程之间可以随时被打断、再恢复。

麻烦在于,默契挡不住“手滑”。两个协程同时想去改同一个变量时,事情就失控了。比如一条 x += 1 看起来是一句话,实际上要分三步:先读取旧值,然后计算,再写回新值。如果协程 A 在“读完旧值、还没写回”的间隙遇到 await 让出了控制权,协程 B 插进来也读了同一个旧值,两人各自加一写回,结果只加了一次。这就是竞态条件(Race Condition):不是“同时发生”,而是“交错发生”造成的丢失更新。

x←x+1 分三步:v←x;  await 让出;  x←v+1\displaystyle x \leftarrow x+1 \ \text{分三步:}\quad v \leftarrow x;\ \ \text{await 让出};\ \ x \leftarrow v+1

1. 读取旧值:协程 A 把共享变量的当前值读进自己的临时变量

vA←xv_A \leftarrow x

2. 让出控制权:遇到 await(比如 sleep 或网络请求),事件循环切换到协程 B

await⇒调度器切到协程 B\text{await} \Rightarrow \text{调度器切到协程 B}

3. 写回新值:协程 B 也读到同样的旧值并加一写回,A 再写回时把 B 的结果覆盖

x←vA+1⇒丢失一次+1x \leftarrow v_A + 1 \Rightarrow \text{丢失一次} +1

4. 最终结果错误:三次 x += 1 后,x 最终只增加了 2

xfinal=x0+2≠x0+3x_{\text{final}} = x_0 + 2 \neq x_0 + 3

后厨里,这就是“两个帮工同时往一道菜里加盐”的灾难:只要在“尝咸淡”和“落盐勺”之间被别的事打断,菜就毁了。所以后厨需要“挂牌”和“排队”这类规矩,而 asyncio 的同步原语,正是把规矩变成代码的工具。

Lock互斥锁

灶台挂牌:哪位大厨把“占用”牌挂上,其他人就得在门口等着,直到他做完菜、摘下牌子走出来。asyncio.Lock 就是这块牌子,它解决的问题非常朴素——把“读、改、写”这三步牢牢包成一个不许插队的整体,也就是临界区。

使用 Lock 的方式很简单:进临界区之前 await lock.acquire(),出来之后 lock.release()。但人有三急,代码会忘事——一旦忘记 release,所有等待的协程就永远堵在门口。所以 Python 更推荐 async with lock: 这种写法,它会在进入时自动获取锁,退出时自动释放,相当于“自动挂牌、自动摘牌”。

L∈{0,1},acquire: L=1 (否则等待),release: L=0+唤醒队首\displaystyle L \in \{0,1\}, \quad \text{acquire: } L=1 \ (\text{否则等待}), \quad \text{release: } L=0 + \text{唤醒队首}

1. 请求获取:await lock.acquire(),锁空闲则标记占用,否则进入等待队列挂起

L=0⇒L=1, 继续执行L = 0 \Rightarrow L = 1,\ \text{继续执行}

2. 临界区操作:读写共享数据,此刻只有本协程能进入这一段代码

x←x+1 (安全)x \leftarrow x + 1 \ (\text{安全})

3. 释放与唤醒:lock.release() 归还锁,并唤醒等待队列里的一个协程

L←0, 唤醒队首协程L \leftarrow 0,\ \text{唤醒队首协程}

4. 推荐写法:async with lock 自动完成获取与释放,杜绝忘记 release

async with lock:⋯\text{async with } \mathrm{lock}: \cdots

“挂牌”规矩听着简单,但若大厨端着菜忘了摘牌,整条出菜线就堵死了。代码里对应的就是“忘记 release”造成的死锁。还要记住:asyncio.Lock 不是可重入锁,同一个协程对同一把锁连拿两次,等于把自己挂死在自己挂的牌子上。

Semaphore信号量

外卖窗口不是“只能一个人用”,而是“最多同时接待五个人”。Semaphore 就是这种“限位”工具,它其实是 Lock 的广义版:Lock 等价于 Semaphore(1),而 Semaphore(3) 允许最多三个协程同时进入受保护区域。

Semaphore 内部维护一个计数器。await sem.acquire() 把计数减一,减到 0 时后来者排队等待;sem.release() 把计数加一并唤醒一个等待者。它特别适合限流场景:数据库连接池、爬虫并发数、同时下载的任务数——你想限制“最多 N 个”,就用 asyncio.Semaphore(N)。

S=limit−N占用,acquire: S←S−1,release: S←S+1\displaystyle S = \mathrm{limit} - N_{\text{占用}}, \quad \text{acquire: } S \gets S-1,\quad \text{release: } S \gets S+1

1. 设定上限:Semaphore(5) 相当于在窗口摆了 5 个外卖格口

Sinit=5S_{\text{init}} = 5

2. 占用一个:await sem.acquire() 让计数减一,减到 0 则排队

S←S−1 (S>0 时成功,否则等待)S \gets S - 1 \ (S > 0 \ \text{时成功,否则等待})

3. 归还一个:sem.release() 让计数加一,并唤醒一个排队的协程

S←S+1S \gets S + 1

4. 并发容量:任意时刻最多 limit 个协程同时处于受保护区域

N并发≤limitN_{\text{并发}} \le \mathrm{limit}

就像外卖窗口只能同时递五份外卖一样,连接池、限流器都可以用信号量“限位”。但窗口格口被占用后必须归还——如果协程拿到信号量却忘了 release,额度就永久少一个,最终所有协程都会堵在门口。

Event事件

后厨装了一个出菜铃。服务员不用一直盯着后厨看,而是在出菜口等着;铃一响,所有服务员的手机同时收到通知,一起涌向窗口。asyncio.Event 就是这枚出菜铃,它不负责“限制人数”,只负责“广播信号”。

Event 内部是一个布尔状态:False 表示未设置,True 表示已设置。协程调用 await e.wait() 时,如果状态是 False 就挂起;e.set() 把状态置为 True 并唤醒所有等待者;e.clear() 把它重置回 False,供下一轮复用。Event 有个容易忽视的特性——它有“记忆”:set 之后再调用 wait 的协程会立即返回,直到 clear 才重新开始等待。

E∈{False,True},wait: 阻塞直到 E=True,set: E←True\displaystyle E \in \{\mathrm{False},\mathrm{True}\}, \quad \text{wait: 阻塞直到 } E=\mathrm{True}, \quad \text{set: } E \gets \mathrm{True}

1. 创建事件:asyncio.Event(),初始状态为 False,相当于铃声未响

E=FalseE = \mathrm{False}

2. 等待铃响:await e.wait(),状态为 False 则协程挂起休眠

await e.wait()⇒挂起\text{await } \mathrm{e.wait()} \Rightarrow \text{挂起}

3. 触发广播:e.set() 把状态置为 True,并唤醒全部等待者

E←True, 全体唤醒E \leftarrow \mathrm{True},\ \text{全体唤醒}

4. 重置事件:e.clear() 回到 False,供下一轮场景复用

E←FalseE \leftarrow \mathrm{False}

出菜铃的妙处在于广播:一声铃响,所有服务员同时动起来。它适合“多个协程等同一个信号”的场景,比如模型训练完成、缓存预热结束、批量数据就绪——一个 set 通知所有人,干净利落。

Condition条件

大厨与服务员之间有一部对讲机。服务员问:“水煮鱼好了吗?”大厨说:“还没,你先去等会儿。”(wait)等鱼出锅,大厨喊:“水煮鱼好了!”(notify)服务员跑回来端菜。Condition 就是这台对讲机,它把“锁”和“等待通知”绑在了一起。

Condition 的核心价值在于“原子性”。使用前必须先拿到锁;cond.wait() 会先释放锁,再把协程挂起——这保证了“检查条件”和“进入睡眠”之间不会被其他协程插队,从而不会错过通知(missed wakeup)。被唤醒后,协程还要重新抢回锁才能继续执行。

wait(): 释放锁→挂起;notify(): 唤醒一个等待者并让它重新抢锁\displaystyle \mathrm{wait()}: \ \text{释放锁} \to \text{挂起}; \qquad \mathrm{notify()}: \ \text{唤醒一个等待者并让它重新抢锁}

1. 持锁检查:在 with cond 的保护下检查条件,比如“队列是否满了”

持有锁时检查条件\text{持有锁时检查条件}

2. 原子等待:cond.wait() 释放锁并挂起自己,不占着锁干等

L←0, 挂起等待通知L \leftarrow 0,\ \text{挂起等待通知}

3. 通知唤醒:cond.notify() 唤醒一个等待者,notify_all() 唤醒全部

notify⇒唤醒一个协程\mathrm{notify} \Rightarrow \text{唤醒一个协程}

4. 重新抢锁:被唤醒的协程要重新 acquire 锁成功后才能继续

L←1, 继续执行L \leftarrow 1,\ \text{继续执行}

对讲机和出菜铃的区别在于:铃响后所有人自己去取;而对讲机通知的是一个“正在等待的特定流程”。更重要的是,Condition 必须“边持锁边等待”才不会漏听,这正是生产者-消费者队列的经典场景。

异步生成器

回转寿司店的后厨不会在开餐前把一百盘寿司全摆上桌,而是一盘一盘现做现放;客人吃一盘,传送带补一盘——这就是惰性生产。异步生成器(Async Generator)就是这条传送带:它不一次性生产完整数据,而是生产一条、交出一条。

同步生成器用 yield 让函数暂停与恢复;异步生成器则是在 async def 函数里写 yield。这样一来,每次产出数据之前都可以先 await 等待 IO——比如读数据库、发网络请求。它把“异步取数据”和“逐条交给消费者”两件事无缝衔接起来。

async def g(k):itemk=await 生产(k);yield itemk\displaystyle \text{async def } g(k): \quad \mathrm{item}_k = \text{await 生产}(k); \quad \text{yield } \mathrm{item}_k

1. 定义生成器:async def 函数体内出现 yield,函数即成为异步生成器

async def g(k)\text{async def } \mathrm{g}(k)

2. 异步生产:先 await 网络、数据库等 IO 操作,得到一条数据

itemk=await fetch(k)\mathrm{item}_k = \text{await } \mathrm{fetch}(k)

3. 逐条产出:yield 把这条数据交给消费者,并暂停在这里

yield itemk\text{yield } \mathrm{item}_k

4. 惰性节奏:只有被消费者索取时才执行到下一个 yield

消费一条⇒再生产一条\text{消费一条} \Rightarrow \text{再生产一条}

寿司带的好处是“现做现吃”:内存不爆、首字节更快。拉取分页数据、流式读大文件、LLM 逐 token 输出,全都是这个节奏——异步生成器正是流式数据的最佳源头。

异步迭代async for

服务员端着盘子沿着传送带走,看到一盘夹一盘,直到传送带尽头——这就是 async for。有了异步生成器这条“传送带”,还需要一把合适的“夹子”,async for 就是为异步迭代器量身定做的语法糖。

一个对象只要实现了 __aiter__(返回异步迭代器)和 __anext__(返回一个 awaitable 的下一项),就是异步迭代器。async for x in a: 会自动循环调用 __anext__ 并 await 它,直到捕获 StopAsyncIteration 异常才结束。也就是说,async for = for 循环 + 自动 await 取下一项。

async for x in a:xn=await a.__anext__()直到 StopAsyncIteration\displaystyle \text{async for } x \ \text{in } a: \quad x_n = \text{await } a.\_\_anext\_\_() \quad \text{直到 StopAsyncIteration}

1. 获取迭代器:循环开始前调用 aiter() 拿到异步迭代器对象

it=a.__aiter__()\mathrm{it} = a.\_\_aiter\_\_()

2. 取下一个:每一轮循环都 await 一次 it.anext(),得到一条数据

xn=await it.__anext__()x_n = \text{await } \mathrm{it}.\_\_anext\_\_()

3. 循环终止:anext 抛出 StopAsyncIteration 时,循环正常结束

StopAsyncIteration⇒结束循环\mathrm{StopAsyncIteration} \Rightarrow \text{结束循环}

4. 使用限制:普通 for 循环和 next() 无法处理异步迭代器

必须 async for,或手动 await __anext__\text{必须 async for,或手动 await } \_\_anext\_\_

沿传送带夹菜与开餐前把菜全堆上桌的差别,就是 async for 与一次性拉取列表的差别。它把“异步生产”和“异步消费”优雅地缝合在一起,是流式 AI 服务的基石写法。

AI 应用场景

数据采集限速(Semaphore)

阶段:数据采集与预处理

解决的问题:爬虫并发过高会被封 IP,一次性创建太多数据库连接会拖垮服务。传统做法是手动数着并发数写计数器,极易出错。用信号量把并发下载数限制在 N 以内,超出额度的任务自动排队,代码简洁且不会超限。

具体案例:要爬取 10 万张图片构建训练集,用 asyncio.Semaphore(20) 控制同时打开的 HTTP 连接;每张图片下载后直接交给异步生成器逐条写盘,全程内存占用稳定。

训练进度广播(Event)

阶段:模型训练与评估

解决的问题:评估协程需要等待训练完成信号,轮询等待浪费算力且代码丑陋。传统命令行脚本只能串行等待。用 Event.set() 一次性广播,所有评估、日志、告警协程同时醒来干活。

具体案例:训练任务每完成一个 epoch 就 set 一次事件,三个评估协程被同时唤醒并行评测;全部跑完后 clear 事件,进入下一轮等待,形成干净的“训练-评估”循环。

流式推理输出(异步生成器 + async for)

阶段:模型推理服务

解决的问题:大模型输出是流式 token,若等全部生成完才返回,用户等待太久。传统同步接口做不到边生成边返回。异步生成器每生成一个 token 就 yield 一次,服务端用 async for 逐 token 转发,首字延迟大幅降低。

具体案例:FastAPI 接口内部写 async for token in model.stream(prompt):,把每个 token 实时推送到前端的 SSE 流上,用户看到的是逐字蹦出来的答案。

Python 演示

python36 行
import asyncio  async def number_generator(n):    # 异步生成器: yield 让函数变成"一件一件出菜"的传送带    for i in range(n):        await asyncio.sleep(0.05)  # 模拟 IO 等待, 让出事件循环        yield i  async def worker(lock, counter, name, total):    # 三个协程模拟后厨帮工, 竞争修改同一个共享计数器    for _ in range(total):        async with lock:  # 进入临界区前自动拿到锁            counter["value"] += 1            current = counter["value"]        print(f"{name} safe write, count={current}")        await asyncio.sleep(0.02)  async def main():    lock = asyncio.Lock()          # 灶台挂牌, 同一时刻只许一人改写    counter = {"value": 0}         # 用字典模拟可变共享变量    await asyncio.gather(          # 并发启动三个协程        worker(lock, counter, "Chef-A", 4),        worker(lock, counter, "Chef-B", 4),        worker(lock, counter, "Chef-C", 4),    )    print(f"final count: {counter['value']} (expect 12)")    print("=== consume async generator ===")    async for num in number_generator(5):  # 沿传送带逐盘夹取        print(f"got: {num}")  if __name__ == "__main__":    asyncio.run(main())
目的:用三把“灶台挂牌”保护共享计数器,再用 async for 消费异步生成器,完整演示同步原语与异步迭代的配合。
  1. async with lock:相当于“自动挂牌摘牌”,进入前 acquire、退出后 release,绝不会忘记归还锁。
  2. await asyncio.gather(...)一次性并发调度三个协程,像同时安排三位帮工上工。
  3. counter["value"] += 1读改写三步,正因在锁内才不会被其他协程撕成两半。
  4. await asyncio.sleep(0.05)模拟 IO 让出 CPU,这正是协程交错发生的地方。
  5. yield i函数体内出现 yield 就变成生成器,配上 async 就是异步生成器。
  6. async for num in number_generator(5):自动逐条 await 取数据,直到生产完毕。

常见误区

误区asyncio 是单线程的,只有一个事件循环,两个协程不可能同时改一个变量,应该不需要锁吧?

正解竞态不需要“同时”,只需要“交错”。协程会在 await 处让出控制权,另一个协程可能在你“读完旧值、还没写回新值”之间插进来,照样造成丢失更新。单线程只是排除了操作系统线程的并行,并没有排除协程的切换——后厨只有一个灶台,两个帮工照样可能在同一道菜上重复加盐。

为何常见:把“同时”理解成了绝对时间上的并行,忽略了协作式调度中 await 即切换的本质。

误区我先 acquire 一下,最后忘了 release 应该也没事吧?锁不都是可重入的吗,拿两次应该没问题?

正解asyncio.Lock 没有自动回收机制,忘记 release 会让所有等待者永久挂起,是典型的死锁;它也不是可重入锁,同一个协程对同一把锁连续 acquire 两次,会把自己挂死在自己挂的牌子上。正确姿势是 async with 或 try/finally。

为何常见:习惯了同步 with 的便捷和 CPython 引用计数的兜底,低估了挂起队列“永久等待”的严重性。

误区如果我刚好在 set() 之后才调用 wait(),不就永远等不到了吗?

正解恰恰相反,Event 有“记忆”——set() 会把内部状态置为 True 并保持,直到 clear()。之后任何协程调用 wait() 都会立即返回。真正“错过就醒不来”的是 Condition 的 notify:如果等待前没有先持锁检查条件,且 notify 发生在 wait 之前,这次通知就永久错过了。

为何常见:用普通信号量的心智模型去套 Event,忽略了 asyncio.Event 的状态保持语义。

误区Semaphore 就是个计数器,acquire 后忘了 release,大不了少几个并发位,有什么关系?

正解漏掉一个 release,整个系统的并发额度就永久少一个;多处泄漏之后,所有协程都会阻塞在 acquire 上,等价于死锁。而且信号量不像锁那样绑定归属,release 可以被任意协程调用,所以更要严格遵守“成对使用”的契约。

为何常见:把信号量当成普通计数器而非“共享资源契约”,忽略了它内部还有一条等待队列。

面试题

Q1. 既然 asyncio 是单线程运行,为什么还需要同步原语?

单选题 · easy

  • A.多个协程会在 await 处交错,共享数据可能竞态← 正确答案
  • B.操作系统线程仍在同时运行,必须加锁
  • C.主要为了保护全局解释器锁 GIL
  • D.同步原语能让程序运行速度变快

答案:A。后厨只有一个灶台,但两个帮工可以在“尝咸淡”和“放盐”之间被打断。asyncio 单线程只是没有真正的并行,await 处仍会切换协程,读改写三步一旦交错就会丢失更新,所以需要同步原语。B 把多线程当成了必要条件,C 与 GIL 无关,D 不是加锁的目的。

Q2. 下列哪种用法能正确使用 asyncio.Lock?

单选题 · easy

  • A.用普通的 with lock 包裹临界区代码
  • B.用 async with lock 包裹临界区代码← 正确答案
  • C.先 lock.acquire() 后无需 release
  • D.同一个协程连续 acquire 两次也没事

答案:B。async with lock 会在进入时执行 acquire、退出时执行 release,就像后厨的自动挂牌摘牌,最安全。普通 with 是同步上下文管理器,不会 await 获取锁;锁一旦被 acquire 就必须 release;同一协程对同一把 asyncio.Lock 连拿两次会直接死锁,所以 C、D 都是危险操作。

Q3. asyncio.Semaphore(3) 表示什么限制?

单选题 · easy

  • A.所有协程只能申请三次信号量
  • B.三个协程必须轮流持有共享锁
  • C.最多三个协程同时进入受保护区域← 正确答案
  • D.信号量只能释放三次后作废

答案:C。Semaphore(3) 相当于在窗口摆了三个外卖格口,最多三个协程同时占用资源。A 把计数当成了总配额,D 把 release 当成了可耗尽次数,都不对;B 把信号量误解成了“三个人轮流用一把锁”,实际上允许多个协程同时进入。

Q4. 关于 Event 的 set 与 clear 行为,正确的是?

单选题 · mid

  • A.set 后只唤醒一个协程,其余继续等待
  • B.clear 之后 wait 也会立即返回
  • C.set 后所有等待协程被唤醒,状态保持到 clear← 正确答案
  • D.set 只对之后才开始 wait 的协程生效

答案:C。Event 像出菜铃一样有“记忆”,set() 之后状态保持为 True 直到 clear(),所以已 set 的事件会让之后所有 wait 立即返回,同时 set 会唤醒所有等待者而不仅仅是一个。B 把 clear 的行为说反了,D 说只对之后 wait 生效也不对,事实上 set 对之前和之后的 wait 都生效。

Q5. Condition 的 wait() 在内部完成了什么?

单选题 · mid

  • A.立即返回并继续保持持有的锁不动
  • B.释放锁并挂起,被唤醒后重新抢锁← 正确答案
  • C.只挂起协程但不释放任何锁
  • D.自动调用 notify() 再进入等待

答案:B。Condition 的 wait() 内部先释放持有的锁、再把协程挂起,被 notify 唤醒后重新抢锁继续——这是避免错过通知的关键,相当于服务员“放下手里的单子再去睡觉”。A 说保持锁、C 说不释放锁,都违背了“等条件时不能占着锁”的原则;D 里的 notify 应该是别人调用,而不是自己调用自己。

Q6. 队列满/空场景为什么更适合用 Condition?

单选题 · mid

  • A.因为 Condition 比 Event 运行速度快
  • B.因为 Event 不能被多个协程同时等待
  • C.因为 Condition 不需要配合 Lock 使用
  • D.因为先检查条件再 wait 可避免错过通知← 正确答案

答案:D。生产者-消费者场景必须先检查条件(队列是否满/空)再决定是否等待,Condition 把“检查条件”和“挂起等待”放进了同一个持锁过程,不会漏掉 notify——好比先确认鱼没做好再去等,而不是闭着眼睡。Event 没有“条件配合锁”的语义,A、B、C 的说法本身也都不成立。

Q7. 协程持锁期间执行 await asyncio.sleep(0) 会怎样?

单选题 · hard

  • A.其他协程拿不到锁,只能等待释放← 正确答案
  • B.事件循环会自动把锁让给其他协程
  • C.锁会被挂起协程抛弃并引发异常
  • D.等价于自动调用了一次 release

答案:A。asyncio.Lock 是独立的协作原语,不随 await 自动让出——锁只能由持有者显式 release 才释放。sleep(0) 只是让事件循环调度其他协程运行,但其他协程执行到 acquire 时依然会排队。B、C、D 都误解了锁与事件循环调度的关系,这正是“持锁等待”类死锁的根源。

Q8. 与一次性返回完整列表相比,异步生成器的特点有哪些?

多选题 · easy

  • A.惰性逐条产出,消费一条才生产一条← 正确答案
  • B.必须搭配线程池才能正常运行
  • C.可以用 async for 异步遍历← 正确答案
  • D.产出的数据必须是一次性完整列表

答案:AC。异步生成器像回转寿司带,消费一条才生产一条,且生产前可以 await IO,所以 A 正确;它本身就是为 async for 准备的,C 正确。B 说必须搭配线程池是错误的,异步生成器运行在事件循环里;D 说必须一次性生成完整列表,与惰性特性正好相反。

Q9. 关于 async for 内部协议,正确的说法有?

多选题 · mid

  • A.先调用 next 获取同步迭代器
  • B.循环中逐次 await 调用 anext← 正确答案
  • C.捕获 StopAsyncIteration 后正常结束← 正确答案
  • D.普通 for 循环也能直接遍历它

答案:BC。async for 的底层协议是先调用 aiter 拿到异步迭代器,再逐次 await anext(),直到 StopAsyncIteration 结束,所以 B、C 正确。A 说的 next 是同步迭代器的协议;D 说普通 for 能遍历也不对,同步 for 无法等待异步方法,强行使用会直接报错。

Q10. 构建高并发 asyncio 程序的正确实践有哪些?

多选题 · hard

  • A.共享计数器可用 Lock 或交给单一协程← 正确答案
  • B.连接池用信号量限制并发上限← 正确答案
  • C.忘记 release 的锁会被 GC 自动恢复
  • D.分页拉取大量数据适合 async for← 正确答案

答案:ABD。共享计数器要么用 Lock 保护,要么干脆放在一个协程里串行修改,A 正确;连接池这类有限资源用信号量控制上限,B 正确;海量分页数据用 async for 流式消费,内存友好,D 正确。C 是经典误区——asyncio.Lock 内部有一条挂起队列,不会像内存那样被 GC 自动回收,忘 release 就会永久死锁。