今晚的订单
#12 · 第十二晚
危机
靠窗的客人递上一张名片:美食评论家。今晚她正式点了一套十二道菜的套餐。第 3、7、11 道菜第一次做时会格外费时间。
目标
十二道菜全部上齐,灶台最多只用三个,3 秒内做完。
深夜食堂 同时
星期五晚上十点。和约好的一样,靠窗的客人走了进来。总是一个人,总是一碗面,总是那块手表和那个小本子。今天她一坐下,就把一张名片放到了桌上:美食评论家。
“这几个星期,我一直在看这个厨房。今天我好好点一次,十二道菜的套餐。”评论家翻开小本子,摘下了手表。
厨房一下子安静了。敏浩的刀掉在了地上,秀雅把订单夹又理了一遍,老卓只是默默地擦着灶台。小露拿着订单走进来。尹瑞妍主厨把脖子上的计时器拨回 0。“厨房就是程序。今晚,把我们做出来的东西全部跑一遍。”
十一个晚上下来,工具攒了不少。客人进门时,要先问的只有一个问题:“这件活是要等的活,还是手不停的活?”
| 情况 | 该拿的工具 | 换成厨房的说法 | 课 |
|---|---|---|---|
| 要等的活非常多(连接数成百上千乃至上万) | asyncio(gather、TaskGroup) | 一个人跑全场的前厅经理小露 | 9~10 |
| 要等的活,但只有同步库,几十个 | threading、ThreadPoolExecutor | 多个厨师、外派厨师 | 2、7 |
| 计算多(CPU) | ProcessPoolExecutor、multiprocessing(或 3.14t 的线程) | 分店厨房 | 8 |
| asyncio 里的老式阻塞代码 | asyncio.to_thread | 另派一个人守在烤箱前 | 11 |
| asyncio 里的繁重计算 | loop.run_in_executor(进程池, ...) | 把面团交给分店 | 11 |
| 好几个人改同一个值 | Lock,或者用队列让它只有一个主人 | 黑板前的锁、订单夹 | 3、6 |
| 同时最多 n 个 | Semaphore(n) | 三个灶台 | 4、11 |
| 必须一起结束,且不许超时 | TaskGroup、asyncio.timeout | 团体预订 | 10 |
挑选的顺序是这样的。
to_thread 包起来。评论家的订单是这样的:十二道菜,每道 0.5 秒。可第 3、7、11 道菜第一次做时,食材还没缓开,要花三倍的时间(1.5 秒)。重做一次就是 0.5 秒。灶台只有三个。
需要的零件有三样:守住灶台数量的信号量、不让一道菜拖太久的时间限制、把被截断的菜重做的重试。把它们装进一个 TaskGroup。
import asyncio, time
SLOW = {3, 7, 11} # 第一次做要花三倍时间的菜
async def plate(n, attempt):
await asyncio.sleep(0.5 * (3 if n in SLOW and attempt == 0 else 1))
async def order(n, stoves, limit_s, retries):
for attempt in range(retries + 1):
async with stoves: # 只在占着灶台时做菜
try:
async with asyncio.timeout(limit_s):
await plate(n, attempt)
return True
except TimeoutError:
pass # 放下灶台,排到队尾再来
return False
async def service(stoves_n, limit_s, retries):
stoves = asyncio.Semaphore(stoves_n)
start = time.perf_counter()
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(order(n, stoves, limit_s, retries)) for n in range(1, 13)]
served = sum(t.result() for t in tasks)
print(f"stoves={stoves_n} limit={limit_s} retries={retries}: "
f"served {served}/12, {time.perf_counter() - start:.1f}s")
asyncio.run(service(3, None, 0)) # 不设时间限制
asyncio.run(service(3, 0.6, 0)) # 只设限制
asyncio.run(service(3, 0.6, 1)) # 限制 + 重试一次
# stoves=3 limit=None retries=0: served 12/12, 3.5s
# stoves=3 limit=0.6 retries=0: served 9/12, 2.2s
# stoves=3 limit=0.6 retries=1: served 12/12, 2.7s
# (耗时每次运行会差 0.1 秒左右。第一行是 3.5s 或 3.6s)asyncio.timeout(None) 表示不设限。所以同一个函数既能跑“不设限”,也能跑“限时 0.6 秒”。设计上有两点值得留意。
async with stoves 写在 for 里面,所以被截断的菜会先松开灶台,再重新排队。要是攥着灶台重试,这段时间别的菜就只能干等。False。 TaskGroup 里一出异常,其余任务就会全部被取消(第 10 课)。团体预订那样做没错,可套餐就算有一道失败,其余的也得照样上。出于同样的理由,用 asyncio.gather 收集也可以。实际工作中,常会在两次重试之间像 await asyncio.sleep(0.05 * 2 ** attempt) 这样,一次比一次歇得久一点。这是为了避免所有人同时一拥而上(和第 5 课的活锁是同一个道理)。
下面的时间线在服务器上跑的是同一个处理器。一行就是一道菜,被时间限制截断的尝试是灰色,重试会标上 r1、r2。初始设置故意设成了达不到目标。
厨房时间线
订单
6
灶台
2
时间限制(秒)
关
重试
0
烹饪
等待
已取消
失败
主厨的提醒
时间限制的原则是:比普通的菜长,比做砸的菜短。连普通的菜都截断的话,重试加多少次都白搭。定数字之前,先量量平常要花多久。
评论家的套餐里,有揉面(计算),有旧烤箱烤的面包(阻塞),也有灶台上的菜(等待)。每样活交给合适的人,前厅的小露把它们全部等齐。因为用到进程,要存成文件再运行。
import asyncio, threading, time
from concurrent.futures import ProcessPoolExecutor
def knead(n): # 揉面:手不停的活 → 分店厨房(进程)
return sum(i * i for i in range(n)) % 97
tally_lock = threading.Lock() # 烤箱线程和循环一起改的账本
tally = {"plates": 0}
def count_plate():
with tally_lock: # 锁里面既没有 await,也没有耗时的活
tally["plates"] += 1
def old_oven(item): # 旧烤箱:阻塞 → 线程
time.sleep(0.4)
count_plate()
return f"{item} baked"
async def stove_dish(n, stoves): # 灶台上的菜:信号量
async with stoves:
await asyncio.sleep(0.2)
count_plate()
return f"#{n}"
async def main():
loop = asyncio.get_running_loop()
stoves = asyncio.Semaphore(3)
with ProcessPoolExecutor(max_workers=2) as dough_room:
async with asyncio.timeout(5): # 整套菜的截止时间
results = await asyncio.gather(
loop.run_in_executor(dough_room, knead, 3_000_000),
asyncio.to_thread(old_oven, "bread"),
*(stove_dish(n, stoves) for n in range(1, 7)),
)
print(results)
print(tally)
if __name__ == "__main__":
asyncio.run(main())
# [56, 'bread baked', '#1', '#2', '#3', '#4', '#5', '#6']
# {'plates': 7}| 活 | 负责的人 | 工具 |
|---|---|---|
| 整个前厅、等待 | 小露 | asyncio.run、gather |
| 揉面(计算) | 分店厨房 | run_in_executor + ProcessPoolExecutor |
| 旧烤箱(阻塞) | 守在烤箱前的厨师 | asyncio.to_thread |
| 灶台上的菜 | 三个灶台 | asyncio.Semaphore(3) |
| 账本(共享状态) | 黑板锁 | threading.Lock(线程和循环都要用) |
| 截止时间 | 主厨的计时器 | asyncio.timeout(5) |
账本在烤箱线程和循环两边都会改,所以用了 threading.Lock。循环这边拿这把锁也没问题,因为锁里没有 await,只改一个数就马上放开。第 11 课看到的事故,发生在攥着锁用 await 让出去的时候。
为了不让厨房在评论家面前停住,开门营业前先检查这六项。
| 检查项 | 症状 | 对策 | 课 |
|---|---|---|---|
| 共享状态 | 数字偶尔不对 | 用 Lock 包住读到写的那一段,或者用队列让它只有一个主人 | 3、11 |
| 加锁顺序 | 偶尔永远卡住 | 所有人按同一顺序加锁,acquire(timeout=) | 5 |
| 阻塞调用 | asyncio 整个变慢 | to_thread,用 debug=True 找出来 | 11 |
| 吞掉的取消 | 时间限制和 TaskGroup 不起作用 | CancelledError 收尾后要再 raise | 10 |
| 没有引用的任务 | 后台的活偶尔凭空消失 | 把任务收集起来攥住,或者用 TaskGroup | 9 |
| 结束不了的队列 | join() 一直不返回 | 取走多少就 task_done() 多少,加结束信号(None、shutdown()) | 6、11 |
“没有引用的任务”的对策是这样的:把任务放进一个集合攥住,结束后让它自己退出来。
import asyncio
running = set() # 攥住任务的地方
async def wipe(table):
await asyncio.sleep(0.05)
print("wiped", table)
async def main():
for t in ("T1", "T2"):
task = asyncio.create_task(wipe(t))
running.add(task)
task.add_done_callback(running.discard) # 结束后自己退出来
await asyncio.sleep(0.1)
print(len(running))
asyncio.run(main())
# wiped T1
# wiped T2
# 0“结束不了的队列”,只要漏掉一个 task_done() 就会出现。怀疑的时候,给 join() 加个时间限制确认一下。
import asyncio
async def cook(q):
while True:
await q.get()
await asyncio.sleep(0.01)
# q.task_done() ← 忘了写
async def main():
q = asyncio.Queue()
for n in range(3):
q.put_nowait(n)
worker = asyncio.create_task(cook(q))
try:
async with asyncio.timeout(0.3):
await q.join()
except TimeoutError:
print("join never finished:", q.qsize(), "in queue")
worker.cancel()
asyncio.run(main())
# join never finished: 0 in queue队列已经空了(0 in queue),join() 却还是结束不了。因为取出来的三张订单都没拿到“做完了”的标记。
前面那些晚上的厨房,都收进了同一个实验台。同样的八份订单,只换方式跑跑看。厨师和灶台的滑块,只在用到这些值的方式里才起作用。
厨房时间线
订单
6
厨师
3
灶台
2
烹饪
等待
已取消
失败
拿下评论家套餐的设置是 灶台 3、时间限制 0.6 秒、重试 1。这是用服务器上的实验台引擎亲自跑过确认的值。
| 灶台 | 时间限制 | 重试 | 已上菜 · 失败 | 耗时 | 目标 |
|---|---|---|---|---|---|
| 2 | 0.4 秒 | 0 | 0 · 12 | 约 2.4 秒 | 失败 |
| 3 | 关 | 0 | 12 · 0 | 约 3.5~3.6 秒 | 超时 |
| 4 | 关 | 0 | 12 · 0 | 约 3.0 秒 | 灶台超额 |
| 3 | 0.4 秒 | 2 | 0 · 12 | 约 4.9 秒 | 失败 |
| 3 | 0.6 秒 | 0 | 9 · 3 | 约 2.2 秒 | 3 道失败 |
| 3 | 0.6 秒 | 1 | 12 · 0 | 约 2.7 秒 | 成功 |
# 用 12-2 的 service 函数
asyncio.run(service(3, 0.6, 1))
# stoves=3 limit=0.6 retries=1: served 12/12, 2.7s第十二道菜端出去,2.7 秒。主厨的计时器停了。评论家在小本子上写了几行,吃完那碗面,站起身。在收银台前,她从小本子上撕下一页,递给了主厨。
“十二道菜,没有一道是凉着端上来的。有等待,可没有一个人是在傻等。我还会再来的。下次,就只是个客人。”
评论家走了,厨房的灯一盏盏熄灭。主厨从围裙口袋里掏出最后一张配方卡,递给副厨。“十二个晚上了。明晚十点,门照样开。这回轮到你写订单了。第一行,你打算写什么?”
| 课 | 那一晚 | 留下的一句话 |
|---|---|---|
| 1 | 第一晚的长队:并发与并行 | 把要等的活和手不停的活分开。并发是轮流来,并行是同一瞬间一起来。 |
| 2 | 多招几个厨师:threading 基础 | Thread(target=)、start()、join()。结果要自己收回来。 |
| 3 | 黑板上的数字错了:竞争条件与 Lock | 读和写之间有人插进来,数字就错了。with lock: 要短。 |
| 4 | 只有三个灶台:同步工具 | Semaphore、Event、Condition、Barrier。 |
| 5 | 刀与砧板:死锁 | 按同一顺序加锁,或者拿不到就放下。 |
| 6 | 订单夹:queue | 用队列把生产者和消费者隔开,用 task_done() 和 join() 知道何时做完。 |
| 7 | 外派厨师:concurrent.futures | ThreadPoolExecutor、Future、as_completed 与 map。 |
| 8 | 面团为什么快不起来:GIL 与并行 | 计算交给进程,或者交给 3.14t。 |
| 9 | 一个人跑全场的前厅经理:asyncio 入门 | async def、await、gather、create_task。 |
| 10 | 团体预订取消了:结构化并发 | TaskGroup、except*、asyncio.timeout,取消要再抛回去。 |
| 11 | 前厅停住了:阻塞调用与混合使用 | 阻塞交给 to_thread,asyncio 版工具只在 asyncio 里用。 |
营业日志 — 第十二晚。用灶台 3、时间限制 0.6 秒、重试 1 的设置,约 2.7 秒上齐了评论家的十二道菜。前厅用 asyncio,揉面用进程,旧烤箱用线程,灶台用信号量。从明天起,订单由副厨来写。
await asyncio.sleep(0.05 * 2 ** attempt))。应该在拿灶台之前歇,还是拿到之后歇?两种都跑一遍,比较耗时。主厨的配方卡
先问:是要等的活(asyncio、线程),还是手不停的活(进程、3.14t)?
asyncio 里的阻塞交给 to_thread,繁重计算交给 run_in_executor(进程池)。
资源上限用 Semaphore,时间上限用 asyncio.timeout,必须一起结束的活用 TaskGroup。
重试要先放下灶台,到队尾重新排。时间限制要“比普通的长,比做砸的短”。
检查清单:共享状态、加锁顺序、阻塞调用、吞掉的取消、没有引用的任务、结束不了的队列。
厨房就是程序。把长条画出来,就看得见了。
订阅新课时和新课程通知
本课程新增课时或推出后续课程时,我们会发邮件通知你。
问答
关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。