今晚的订单
#09 · 第九晚
危机
前厅改组第一天。前厅员工只有小露一个,桌子有十张。要是站在厨房门口等一桌的菜,其余九桌就全停了。
目标
不加人手,0.5 秒内把十张桌子的菜全部送出去。
深夜食堂 同时
从今晚起,前厅换了个样。前厅员工只有小露一个人,桌子有十张。主厨没有再招人。
小露一刻不停地绕着前厅一圈一圈地转。接了点单就递进厨房,不等菜出来,马上去下一桌。厨房的铃一响,她就端起那盘菜送出去。一圈,又一圈。看她跟着铃声(事件)沿同一条路线打转,厨师们第一天就给她起了个外号:事件循环。
小露一边系围裙带子一边说:“我最讨厌站在桌子前面傻等。我在这儿等的时候,别的桌子都在等我。”
主厨点点头。“到昨天为止,我们都是靠加人。今天学的是:怎么用好一个人等待的时间。在 Python 里,这叫 asyncio。”
在函数前面加上 async,它就成了协程函数。先来看看奇怪的地方:调用了,却不执行。
import asyncio
async def take_order(table):
print("order", table)
return f"T{table}"
c = take_order(1)
print(type(c).__name__)
print(c)
# coroutine
# <coroutine object take_order at 0x...>
# <sys>:0: RuntimeWarning: coroutine 'take_order' was never awaitedtake_order(1) 并没有打印 "order 1"。函数体一行都没跑。was never awaited 警告。看到这个警告,几乎总是漏写了 await。执行便条有两种办法:在最外层交给 asyncio.run(),在别的协程里面用 await。
print(asyncio.run(take_order(1)))
# order 1
# T1
c = take_order(2)
asyncio.run(c)
asyncio.run(c)
# order 2
# RuntimeError: cannot reuse already awaited coroutine协程对象只能用一次。想再点一次同样的单,就重新调用 take_order(2),写一张新便条。
await 的意思是:“我在这儿等这件事做完。这期间,循环可以去做别的事。”await 只能写在 async def 里面。写在普通的 def 里,会报 SyntaxError: 'await' outside async function。
import asyncio
import time
async def table(n, wait):
print(f"T{n} order")
await asyncio.sleep(wait) # 等厨房出菜的时候,小露是空闲的
print(f"T{n} served")
return f"T{n}"
async def one_by_one():
a = await table(1, 0.3)
b = await table(2, 0.3)
return [a, b]
start = time.perf_counter()
print(asyncio.run(one_by_one()), f"{time.perf_counter() - start:.2f}s")
# T1 order
# T1 served
# T2 order
# T2 served
# ['T1', 'T2'] 0.62sasyncio.sleep(0.3) 是 asyncio 版的 time.sleep(0.3)。等待期间把执行机会交还给循环。await table(1, ...) 结束后才会走到下一行。就像小露站在 1 号桌前,一直等到菜出来。await 的意思不是“同时”,而是“等”。想同时等,需要下一节的工具。asyncio.run(main()) 是程序的入口:
main() 协程一直运行到结束。一个程序里通常只在最外层调用一次。在已经有循环在跑的地方再调用,会报 RuntimeError: asyncio.run() cannot be called from a running event loop。
事件循环是在一个线程里转的循环。它做的事,和小露在前厅里转圈一样:
await。把三张桌子同时交给她(下一节的 gather),小露的路线是这样的。1 号桌的菜要 0.3 秒,2 号桌 0.2 秒,3 号桌 0.1 秒。
| 时刻 | 小露在做什么 | 输出 |
|---|---|---|
| 0.0 秒 | 下了 T1 的单,在 await 处离开 | T1 order |
| 0.0 秒 | 下了 T2 的单,离开 | T2 order |
| 0.0 秒 | 下了 T3 的单,离开 | T3 order |
| 0.0~0.1 秒 | 没事可做,等最早响的铃 | |
| 0.1 秒 | T3 的铃响了。端起盘子送出去 | T3 served |
| 0.2 秒 | T2 的铃 | T2 served |
| 0.3 秒 | T1 的铃 | T1 served |
记住两件事:
await 处才会交出执行权。 线程随时可能被操作系统打断(第 3 课),协程却只在自己 await 时才离开。所以两个 await 之间的代码不会被中途打断。asyncio.gather(协程, 协程, ...) 把多个协程一次性交给循环,并一直等到全部结束。
async def together():
return await asyncio.gather(table(1, 0.3), table(2, 0.2), table(3, 0.1))
start = time.perf_counter()
print(asyncio.run(together()), f"{time.perf_counter() - start:.2f}s")
# T1 order
# T2 order
# T3 order
# T3 served
# T2 served
# T1 served
# ['T1', 'T2', 'T3'] 0.31sexecutor.map 一样。gather(*coros) 这样用星号解包。在下面的实验台里,轮流切换“一桌一桌 await”和“用 gather 一起”跑一跑。一行就是一张桌子,横条全是“等待”的颜色。小露真正动手的时间几乎为零,绝大部分都在等厨房。
厨房时间线
订单
6
烹饪
等待
已取消
失败
交给 gather 的协程里有一个抛了异常,gather 就会把这个异常原样抛出来。想把结果全部拿到,就传 return_exceptions=True。
async def table(n):
await asyncio.sleep(0.1 * n)
if n == 2:
raise ValueError(f"T{n} wrong dish")
return f"T{n}"
async def main():
results = await asyncio.gather(table(1), table(2), table(3), return_exceptions=True)
print(results)
try:
await asyncio.gather(table(1), table(2), table(3))
except ValueError as e:
print("ValueError:", e)
asyncio.run(main())
# ['T1', ValueError('T2 wrong dish'), 'T3']
# ValueError: T2 wrong dishreturn_exceptions=True,异常也会作为结果列表里的一格放进来。逐个用 isinstance(r, Exception) 检查结果。False)时即使异常被抛出来,其余的桌子也不会被取消,会继续跑下去。“一个失败就让其余的都停下”,要靠第 10 课的 TaskGroup。主厨的提醒
was never awaited 这个警告,意思是订单写好了却没递进厨房。调用了协程函数,就一定要 await,或者做成任务。不然那道菜永远也上不了桌。
asyncio.create_task(协程) 把协程包成一个任务(Task),马上排进循环。任务会在小露腾出手的时候(当前协程 await 的时候)开始执行。它不等任务做完就走到下一行,所以可以先把单下了,再去干别的事。
async def table(n, wait):
print(f"T{n} order")
await asyncio.sleep(wait)
print(f"T{n} served")
return f"T{n}"
async def main():
start = time.perf_counter()
t1 = asyncio.create_task(table(1, 0.3))
t2 = asyncio.create_task(table(2, 0.2))
print("Lou: 2 tasks created")
await asyncio.sleep(0.1) # 小露先去干一会儿别的
print("Lou: back", t1.done(), t2.done())
print(await t1, await t2, f"{time.perf_counter() - start:.2f}s")
asyncio.run(main())
# Lou: 2 tasks created
# T1 order
# T2 order
# Lou: back False False
# T2 served
# T1 served
# T1 T2 0.31s"Lou: 2 tasks created" 比 T1 order 先打印出来。任务一创建就排上了,但要等 main 用 await 让出位置时才第一次执行。await t1 是等一个已经在跑的任务结束。两个任务是一起等的,所以不是 0.5 秒,而是约 0.3 秒。gather 会在内部把收到的协程包成任务。只需要把结果收齐时用 gather;要单独拿着、查看状态或者稍后再等时,用 create_task。事件循环对任务只持有弱引用。没有放进变量或容器里的任务,可能还没跑完就被垃圾回收掉了。哪怕是倒水这种不需要结果的活,交代下去时也得攥着引用。
background = set()
async def refill_water(table):
await asyncio.sleep(0.1)
print(f"T{table} water")
async def main():
for n in (1, 2, 3):
task = asyncio.create_task(refill_water(n))
background.add(task) # 攥住引用
task.add_done_callback(background.discard) # 做完后自己移出去
print("pending", len(background))
await asyncio.sleep(0.2)
print("pending", len(background))
asyncio.run(main())
# pending 3
# T1 water
# T2 water
# T3 water
# pending 0像 asyncio.create_task(refill_water(n)) 这样把结果直接扔掉的一行,大多数时候都能正常跑,可问题就出在“大多数时候”。偶尔消失的任务,是最难查的 bug。
两者都是“等一会儿”,可对小露来说完全是两码事。
async def polite(n):
await asyncio.sleep(0.3) # 等待期间把执行机会交给循环
async def stubborn(n):
time.sleep(0.3) # 连循环一起停住
async def main(fn):
start = time.perf_counter()
await asyncio.gather(*(fn(n) for n in range(3)))
print(fn.__name__, f"{time.perf_counter() - start:.2f}s")
asyncio.run(main(polite))
asyncio.run(main(stubborn))
# polite 0.32s
# stubborn 0.90sasyncio.sleep 在 await 的那一刻,跟循环说一声“0.3 秒后叫醒我”,然后离开。time.sleep 让线程本身停住。跑循环的线程只有一个,这就等于小露站在桌子前傻等。这期间,没有人去照看别的桌子。gather 也没用。不只是 time.sleep,耗时很长的计算、等待时不交出执行权的老式库,也会同样让循环停住。这个问题和它的解法,是第 11 课的主题。
asyncio 里有三个名字很像的东西。
| 名称 | 换成厨房来说 | 作用 |
|---|---|---|
| 协程(coroutine) | 订单便条 | 调用 async def 时产生。自己不会运行 |
| 任务(Task) | 已经递进厨房的订单 | 包住协程,交给循环。有 done()、result()、cancel() |
| Future | 稍后会响的铃 | “将来会有值放进来的位置”。任务是 Future 的一种 |
print(issubclass(asyncio.Task, asyncio.Future)) # True
async def main():
coro = take_order(1)
task = asyncio.create_task(coro)
print(type(coro).__name__, type(task).__name__) # coroutine Task思路和第 7 课的 concurrent.futures.Future 一样。不过 asyncio 的 Future 可以 await,并且只在同一个循环里使用。很少需要自己创建 Future,通常有协程和任务就够了。
线程是操作系统创建的,每一个都要带上栈内存和操作系统的管理开销。任务只是一个 Python 对象。
async def table(n):
await asyncio.sleep(0.5)
return n
async def main():
return await asyncio.gather(*(table(n) for n in range(10_000)))
# 10,000 tasks: 约 0.57 秒 (1 万张各等 0.5 秒的桌子)
# 1,000 threads: 约 0.62 秒 (1 千个各等 0.5 秒的线程)
# 这台电脑上测的值开 1 万个任务的开销,比 1 千个线程还小。要等的活有成千上万件时(聊天服务器、向大量地址发请求),asyncio 就很合适,原因就在这里。
今晚的目标是:不加人手,0.5 秒内把十张桌子的菜全部送出去。一个小露、一个事件循环、一个 gather 就够了。
import asyncio
import time
async def table(n):
await asyncio.sleep(0.3) # 等厨房出菜
return f"T{n}"
async def hall():
return await asyncio.gather(*(table(n) for n in range(1, 11)))
start = time.perf_counter()
served = asyncio.run(hall())
print(served)
print(f"{time.perf_counter() - start:.2f}s")
# ['T1', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'T8', 'T9', 'T10']
# 0.30s一个线程都没创建。十张各要 0.3 秒的桌子,约 0.3 秒就全部完成。
| 线程(第 2~7 课) | asyncio(从今天起) | |
|---|---|---|
| 干活的人 | 好几个厨师 | 小露一个人 |
| 什么时候换人 | 操作系统随时切换 | 只在 await 处 |
| 每一个的成本 | 大(操作系统线程) | 小(Python 对象) |
| 要当心的 | 竞争条件、死锁 | 阻塞调用、漏写 await |
小露放下空托盘。“十张桌子,一圈就搞定了。”主厨看着计时器问:“好。不过明天是团体预订。六张桌子里有一张取消了,剩下五张桌子的菜,你打算怎么叫停?”
营业日志 — 第九晚。小露一个人负责十张桌子。用等待时离开(await)和一起等(gather)的办法,0.3 秒上完菜。明天有团体预订。
await 直接调用 9-1 的 take_order”和“用 asyncio.run() 调用它”这两行,比较输出和警告。main 里的 await asyncio.sleep(0.1),输出顺序会怎么变?先把预测写下来,再运行确认。主厨的配方卡
async def 创建协程。只调用不会执行(会有 was never awaited 警告)。
asyncio.run(main()):打开循环,把 main 跑完,再关闭。在最外层调用一次。
await:等到结束,期间把执行权交给循环。只有在 await 处才会切换。
await asyncio.gather(*coros):同时等,结果按传入顺序。还有 return_exceptions=True。
task = asyncio.create_task(coro):先交代下去。一定要攥住引用(弱引用)。
asyncio.sleep 会让出执行权,time.sleep 会让循环停住。
协程(便条)→ 任务(递进厨房的订单)⊂ Future(稍后会响的铃)。1 万个任务也很轻。
订阅新课时和新课程通知
本课程新增课时或推出后续课程时,我们会发邮件通知你。
问答
关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。