想象你是一家川菜馆的后厨主管。后厨只有一个猛火灶,却同时排着几十道菜;外卖窗口只有五个格口,出菜口只挂着一个铃铛。如果大厨们想用就用、想占就占,后厨会怎样?出菜顺序全乱套,两只手同时伸向同一个炒勺,菜撒一地。asyncio 就是一个“单线程后厨”:一个事件循环(Event Loop)像总调度员,几百个协程(Coroutine)像后厨帮工,大家默契地在 await 处让出位置。可只要有人共用同一个变量、同一个连接池,就会像抢炒勺一样打架。于是 Python 给这个后厨配了一整套“协作规矩”:Lock 是灶台挂牌,Semaphore 是窗口限位,Event 是出菜铃,Condition 是对讲机,异步生成器则是自动传送带,async for 就是沿着传送带逐盘夹菜。这篇文章,我们就用这间后厨,把 asyncio 的同步原语与异步生成器讲得明明白白。
掌握 asyncio 同步原语与异步生成器,是写出不卡死、不丢数据的高并发 AI 服务(推理、训练、数据管道)的必备内功,也是 AI 后端工程师面试的常客。
可视化演示
展示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):不是“同时发生”,而是“交错发生”造成的丢失更新。
1. 读取旧值:协程 A 把共享变量的当前值读进自己的临时变量
2. 让出控制权:遇到 await(比如 sleep 或网络请求),事件循环切换到协程 B
3. 写回新值:协程 B 也读到同样的旧值并加一写回,A 再写回时把 B 的结果覆盖
4. 最终结果错误:三次 x += 1 后,x 最终只增加了 2
后厨里,这就是“两个帮工同时往一道菜里加盐”的灾难:只要在“尝咸淡”和“落盐勺”之间被别的事打断,菜就毁了。所以后厨需要“挂牌”和“排队”这类规矩,而 asyncio 的同步原语,正是把规矩变成代码的工具。
Lock互斥锁
灶台挂牌:哪位大厨把“占用”牌挂上,其他人就得在门口等着,直到他做完菜、摘下牌子走出来。asyncio.Lock 就是这块牌子,它解决的问题非常朴素——把“读、改、写”这三步牢牢包成一个不许插队的整体,也就是临界区。
使用 Lock 的方式很简单:进临界区之前 await lock.acquire(),出来之后 lock.release()。但人有三急,代码会忘事——一旦忘记 release,所有等待的协程就永远堵在门口。所以 Python 更推荐 async with lock: 这种写法,它会在进入时自动获取锁,退出时自动释放,相当于“自动挂牌、自动摘牌”。
1. 请求获取:await lock.acquire(),锁空闲则标记占用,否则进入等待队列挂起
2. 临界区操作:读写共享数据,此刻只有本协程能进入这一段代码
3. 释放与唤醒:lock.release() 归还锁,并唤醒等待队列里的一个协程
4. 推荐写法:async with lock 自动完成获取与释放,杜绝忘记 release
“挂牌”规矩听着简单,但若大厨端着菜忘了摘牌,整条出菜线就堵死了。代码里对应的就是“忘记 release”造成的死锁。还要记住:asyncio.Lock 不是可重入锁,同一个协程对同一把锁连拿两次,等于把自己挂死在自己挂的牌子上。
Semaphore信号量
外卖窗口不是“只能一个人用”,而是“最多同时接待五个人”。Semaphore 就是这种“限位”工具,它其实是 Lock 的广义版:Lock 等价于 Semaphore(1),而 Semaphore(3) 允许最多三个协程同时进入受保护区域。
Semaphore 内部维护一个计数器。await sem.acquire() 把计数减一,减到 0 时后来者排队等待;sem.release() 把计数加一并唤醒一个等待者。它特别适合限流场景:数据库连接池、爬虫并发数、同时下载的任务数——你想限制“最多 N 个”,就用 asyncio.Semaphore(N)。
1. 设定上限:Semaphore(5) 相当于在窗口摆了 5 个外卖格口
2. 占用一个:await sem.acquire() 让计数减一,减到 0 则排队
3. 归还一个:sem.release() 让计数加一,并唤醒一个排队的协程
4. 并发容量:任意时刻最多 limit 个协程同时处于受保护区域
就像外卖窗口只能同时递五份外卖一样,连接池、限流器都可以用信号量“限位”。但窗口格口被占用后必须归还——如果协程拿到信号量却忘了 release,额度就永久少一个,最终所有协程都会堵在门口。
Event事件
后厨装了一个出菜铃。服务员不用一直盯着后厨看,而是在出菜口等着;铃一响,所有服务员的手机同时收到通知,一起涌向窗口。asyncio.Event 就是这枚出菜铃,它不负责“限制人数”,只负责“广播信号”。
Event 内部是一个布尔状态:False 表示未设置,True 表示已设置。协程调用 await e.wait() 时,如果状态是 False 就挂起;e.set() 把状态置为 True 并唤醒所有等待者;e.clear() 把它重置回 False,供下一轮复用。Event 有个容易忽视的特性——它有“记忆”:set 之后再调用 wait 的协程会立即返回,直到 clear 才重新开始等待。
1. 创建事件:asyncio.Event(),初始状态为 False,相当于铃声未响
2. 等待铃响:await e.wait(),状态为 False 则协程挂起休眠
3. 触发广播:e.set() 把状态置为 True,并唤醒全部等待者
4. 重置事件:e.clear() 回到 False,供下一轮场景复用
出菜铃的妙处在于广播:一声铃响,所有服务员同时动起来。它适合“多个协程等同一个信号”的场景,比如模型训练完成、缓存预热结束、批量数据就绪——一个 set 通知所有人,干净利落。
Condition条件
大厨与服务员之间有一部对讲机。服务员问:“水煮鱼好了吗?”大厨说:“还没,你先去等会儿。”(wait)等鱼出锅,大厨喊:“水煮鱼好了!”(notify)服务员跑回来端菜。Condition 就是这台对讲机,它把“锁”和“等待通知”绑在了一起。
Condition 的核心价值在于“原子性”。使用前必须先拿到锁;cond.wait() 会先释放锁,再把协程挂起——这保证了“检查条件”和“进入睡眠”之间不会被其他协程插队,从而不会错过通知(missed wakeup)。被唤醒后,协程还要重新抢回锁才能继续执行。
1. 持锁检查:在 with cond 的保护下检查条件,比如“队列是否满了”
2. 原子等待:cond.wait() 释放锁并挂起自己,不占着锁干等
3. 通知唤醒:cond.notify() 唤醒一个等待者,notify_all() 唤醒全部
4. 重新抢锁:被唤醒的协程要重新 acquire 锁成功后才能继续
对讲机和出菜铃的区别在于:铃响后所有人自己去取;而对讲机通知的是一个“正在等待的特定流程”。更重要的是,Condition 必须“边持锁边等待”才不会漏听,这正是生产者-消费者队列的经典场景。
异步生成器
回转寿司店的后厨不会在开餐前把一百盘寿司全摆上桌,而是一盘一盘现做现放;客人吃一盘,传送带补一盘——这就是惰性生产。异步生成器(Async Generator)就是这条传送带:它不一次性生产完整数据,而是生产一条、交出一条。
同步生成器用 yield 让函数暂停与恢复;异步生成器则是在 async def 函数里写 yield。这样一来,每次产出数据之前都可以先 await 等待 IO——比如读数据库、发网络请求。它把“异步取数据”和“逐条交给消费者”两件事无缝衔接起来。
1. 定义生成器:async def 函数体内出现 yield,函数即成为异步生成器
2. 异步生产:先 await 网络、数据库等 IO 操作,得到一条数据
3. 逐条产出:yield 把这条数据交给消费者,并暂停在这里
4. 惰性节奏:只有被消费者索取时才执行到下一个 yield
寿司带的好处是“现做现吃”:内存不爆、首字节更快。拉取分页数据、流式读大文件、LLM 逐 token 输出,全都是这个节奏——异步生成器正是流式数据的最佳源头。
异步迭代async for
服务员端着盘子沿着传送带走,看到一盘夹一盘,直到传送带尽头——这就是 async for。有了异步生成器这条“传送带”,还需要一把合适的“夹子”,async for 就是为异步迭代器量身定做的语法糖。
一个对象只要实现了 __aiter__(返回异步迭代器)和 __anext__(返回一个 awaitable 的下一项),就是异步迭代器。async for x in a: 会自动循环调用 __anext__ 并 await 它,直到捕获 StopAsyncIteration 异常才结束。也就是说,async for = for 循环 + 自动 await 取下一项。
1. 获取迭代器:循环开始前调用 aiter() 拿到异步迭代器对象
2. 取下一个:每一轮循环都 await 一次 it.anext(),得到一条数据
3. 循环终止:anext 抛出 StopAsyncIteration 时,循环正常结束
4. 使用限制:普通 for 循环和 next() 无法处理异步迭代器
沿传送带夹菜与开餐前把菜全堆上桌的差别,就是 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 演示
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 with lock:相当于“自动挂牌摘牌”,进入前 acquire、退出后 release,绝不会忘记归还锁。await asyncio.gather(...)一次性并发调度三个协程,像同时安排三位帮工上工。counter["value"] += 1读改写三步,正因在锁内才不会被其他协程撕成两半。await asyncio.sleep(0.05)模拟 IO 让出 CPU,这正是协程交错发生的地方。yield i函数体内出现 yield 就变成生成器,配上 async 就是异步生成器。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 是单线程运行,为什么还需要同步原语?
- A.多个协程会在 await 处交错,共享数据可能竞态← 正确答案
- B.操作系统线程仍在同时运行,必须加锁
- C.主要为了保护全局解释器锁 GIL
- D.同步原语能让程序运行速度变快
答案:A。后厨只有一个灶台,但两个帮工可以在“尝咸淡”和“放盐”之间被打断。asyncio 单线程只是没有真正的并行,await 处仍会切换协程,读改写三步一旦交错就会丢失更新,所以需要同步原语。B 把多线程当成了必要条件,C 与 GIL 无关,D 不是加锁的目的。
Q2. 下列哪种用法能正确使用 asyncio.Lock?
- 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) 表示什么限制?
- A.所有协程只能申请三次信号量
- B.三个协程必须轮流持有共享锁
- C.最多三个协程同时进入受保护区域← 正确答案
- D.信号量只能释放三次后作废
答案:C。Semaphore(3) 相当于在窗口摆了三个外卖格口,最多三个协程同时占用资源。A 把计数当成了总配额,D 把 release 当成了可耗尽次数,都不对;B 把信号量误解成了“三个人轮流用一把锁”,实际上允许多个协程同时进入。
Q4. 关于 Event 的 set 与 clear 行为,正确的是?
- 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() 在内部完成了什么?
- A.立即返回并继续保持持有的锁不动
- B.释放锁并挂起,被唤醒后重新抢锁← 正确答案
- C.只挂起协程但不释放任何锁
- D.自动调用 notify() 再进入等待
答案:B。Condition 的 wait() 内部先释放持有的锁、再把协程挂起,被 notify 唤醒后重新抢锁继续——这是避免错过通知的关键,相当于服务员“放下手里的单子再去睡觉”。A 说保持锁、C 说不释放锁,都违背了“等条件时不能占着锁”的原则;D 里的 notify 应该是别人调用,而不是自己调用自己。
Q6. 队列满/空场景为什么更适合用 Condition?
- A.因为 Condition 比 Event 运行速度快
- B.因为 Event 不能被多个协程同时等待
- C.因为 Condition 不需要配合 Lock 使用
- D.因为先检查条件再 wait 可避免错过通知← 正确答案
答案:D。生产者-消费者场景必须先检查条件(队列是否满/空)再决定是否等待,Condition 把“检查条件”和“挂起等待”放进了同一个持锁过程,不会漏掉 notify——好比先确认鱼没做好再去等,而不是闭着眼睡。Event 没有“条件配合锁”的语义,A、B、C 的说法本身也都不成立。
Q7. 协程持锁期间执行 await asyncio.sleep(0) 会怎样?
- A.其他协程拿不到锁,只能等待释放← 正确答案
- B.事件循环会自动把锁让给其他协程
- C.锁会被挂起协程抛弃并引发异常
- D.等价于自动调用了一次 release
答案:A。asyncio.Lock 是独立的协作原语,不随 await 自动让出——锁只能由持有者显式 release 才释放。sleep(0) 只是让事件循环调度其他协程运行,但其他协程执行到 acquire 时依然会排队。B、C、D 都误解了锁与事件循环调度的关系,这正是“持锁等待”类死锁的根源。
Q8. 与一次性返回完整列表相比,异步生成器的特点有哪些?
- A.惰性逐条产出,消费一条才生产一条← 正确答案
- B.必须搭配线程池才能正常运行
- C.可以用 async for 异步遍历← 正确答案
- D.产出的数据必须是一次性完整列表
答案:AC。异步生成器像回转寿司带,消费一条才生产一条,且生产前可以 await IO,所以 A 正确;它本身就是为 async for 准备的,C 正确。B 说必须搭配线程池是错误的,异步生成器运行在事件循环里;D 说必须一次性生成完整列表,与惰性特性正好相反。
Q9. 关于 async for 内部协议,正确的说法有?
- 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 程序的正确实践有哪些?
- A.共享计数器可用 Lock 或交给单一协程← 正确答案
- B.连接池用信号量限制并发上限← 正确答案
- C.忘记 release 的锁会被 GC 自动恢复
- D.分页拉取大量数据适合 async for← 正确答案
答案:ABD。共享计数器要么用 Lock 保护,要么干脆放在一个协程里串行修改,A 正确;连接池这类有限资源用信号量控制上限,B 正确;海量分页数据用 async for 流式消费,内存友好,D 正确。C 是经典误区——asyncio.Lock 内部有一条挂起队列,不会像内存那样被 GC 自动回收,忘 release 就会永久死锁。