R

ReflexKR

Reflex 官方文档

深夜厨房里的并发

·

课程概览

1

第一晚的长队:并发与并行

2

多招几个厨师:threading 基础

3

黑板上的数字错了:竞争条件与 Lock

4

只有三个灶台:同步工具

5

刀与砧板:死锁

6

订单夹:queue

7

外派厨师:concurrent.futures

8

面团为什么快不起来:GIL 与并行

9

一个人跑全场的前厅经理:asyncio 入门

10

团体预订取消了:结构化并发

11

前厅停住了:阻塞调用与混合使用

12

评论家之夜:综合

深夜厨房里的并发
第12课

时间 9:10–10:00

评论家之夜:综合

今晚的订单

#12 · 第十二晚

评论家之夜

危机

靠窗的客人递上一张名片:美食评论家。今晚她正式点了一套十二道菜的套餐。第 3、7、11 道菜第一次做时会格外费时间。

目标

十二道菜全部上齐,灶台最多只用三个,3 秒内做完。

深夜食堂 同时

星期五晚上十点。和约好的一样,靠窗的客人走了进来。总是一个人,总是一碗面,总是那块手表和那个小本子。今天她一坐下,就把一张名片放到了桌上:美食评论家。

“这几个星期,我一直在看这个厨房。今天我好好点一次,十二道菜的套餐。”评论家翻开小本子,摘下了手表。

厨房一下子安静了。敏浩的刀掉在了地上,秀雅把订单夹又理了一遍,老卓只是默默地擦着灶台。小露拿着订单走进来。尹瑞妍主厨把脖子上的计时器拨回 0。“厨房就是程序。今晚,把我们做出来的东西全部跑一遍。”

12-1. 该拿出哪件工具(8 分钟)

十一个晚上下来,工具攒了不少。客人进门时,要先问的只有一个问题:“这件活是要等的活,还是手不停的活?”

情况该拿的工具换成厨房的说法课
要等的活非常多(连接数成百上千乃至上万)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

挑选的顺序是这样的。

  1. 真的需要并发吗? 按顺序做已经够快,就别动它。第 1 课说过,并发不是免费的。
  2. 是要等的活,还是计算? 要等的活用 asyncio 或线程,计算用进程(或 3.14t)。在有 GIL 的版本上,把计算拆给线程并不会变快(第 8 课)。
  3. 正在用的库支持 async 吗? 支持就用 asyncio;不支持,就从线程开始,或者在 asyncio 里用 to_thread 包起来。
  4. 要和什么一起用? 有共享状态,就加锁或队列;资源数量有限,就加信号量;必须一起结束,就加 TaskGroup。

12-2. 评论家的套餐处理器(14 分钟)

评论家的订单是这样的:十二道菜,每道 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 秒”。
  • 不设限的话,三道慢菜各占着灶台 1.5 秒,十二道菜全部上齐要 3.5 秒多。
  • 只设限制,慢菜到 0.6 秒就被截断,灶台很快空出来。代价是这三道菜永远上不了桌。
  • 限制和重试一起设,被截断的菜会放下灶台,重新排到队尾。第二次 0.5 秒就做完,十二道菜全部上齐。

设计上有两点值得留意。

  • 重试时要放下灶台。 async with stoves 写在 for 里面,所以被截断的菜会先松开灶台,再重新排队。要是攥着灶台重试,这段时间别的菜就只能干等。
  • 失败时不抛异常,而是返回 False。 TaskGroup 里一出异常,其余任务就会全部被取消(第 10 课)。团体预订那样做没错,可套餐就算有一道失败,其余的也得照样上。出于同样的理由,用 asyncio.gather 收集也可以。

实际工作中,常会在两次重试之间像 await asyncio.sleep(0.05 * 2 ** attempt) 这样,一次比一次歇得久一点。这是为了避免所有人同时一拥而上(和第 5 课的活锁是同一个道理)。

下面的时间线在服务器上跑的是同一个处理器。一行就是一道菜,被时间限制截断的尝试是灰色,重试会标上 r1、r2。初始设置故意设成了达不到目标。

厨房时间线

订单

6

灶台

2

时间限制(秒)

关

重试

0

烹饪

等待

已取消

失败

评论家的十二道菜。调整灶台、时间限制和重试,达成“已上菜 12 · 失败 0,灶台不超过 3 个,3 秒以内”。用初始设置的话,一道菜也上不了。
  • 用初始设置(灶台 2、时间限制 0.4 秒、重试 0)跑,大约 2.4 秒后得到 已上菜 0 · 失败 12。普通的菜也要 0.5 秒,而限制是 0.4 秒,所以每道菜都被截断了。
  • 把灶台加到 3,只把重试调到 2,结果更糟。同样的 0.4 秒限制,不过是被截断三次而已,依旧一道没上,耗时却涨到约 4.9 秒。重试治不了设错的时间限制。就算把限制设成和普通菜一样的 0.5 秒,也会因为差那么一丁点被截断,全部失败。
  • 关掉时间限制,菜能全部上齐,可灶台 3 个也要 3.5 秒多。灶台加到 4 个大约 3.0 秒,悬得很;何况灶台本来就只有三个。
  • 页面最多只等实验跑 5 秒多一点。像灶台 2 加重试 2 这样很费时的设置,看起来可能会在中途断掉,换个设置再跑一次就好。

主厨的提醒

时间限制的原则是:比普通的菜长,比做砸的菜短。连普通的菜都截断的话,重试加多少次都白搭。定数字之前,先量量平常要花多久。

12-3. 全都放进一个厨房(8 分钟)

评论家的套餐里,有揉面(计算),有旧烤箱烤的面包(阻塞),也有灶台上的菜(等待)。每样活交给合适的人,前厅的小露把它们全部等齐。因为用到进程,要存成文件再运行。

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 让出去的时候。

12-4. 并发 bug 检查清单(6 分钟)

为了不让厨房在评论家面前停住,开门营业前先检查这六项。

检查项症状对策课
共享状态数字偶尔不对用 Lock 包住读到写的那一段,或者用队列让它只有一个主人3、11
加锁顺序偶尔永远卡住所有人按同一顺序加锁,acquire(timeout=)5
阻塞调用asyncio 整个变慢to_thread,用 debug=True 找出来11
吞掉的取消时间限制和 TaskGroup 不起作用CancelledError 收尾后要再 raise10
没有引用的任务后台的活偶尔凭空消失把任务收集起来攥住,或者用 TaskGroup9
结束不了的队列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() 却还是结束不了。因为取出来的三张订单都没拿到“做完了”的标记。

12-5. 把十一个晚上的厨房再跑一遍(4 分钟)

前面那些晚上的厨房,都收进了同一个实验台。同样的八份订单,只换方式跑跑看。厨师和灶台的滑块,只在用到这些值的方式里才起作用。

厨房时间线

订单

6

厨师

3

灶台

2

烹饪

等待

已取消

失败

8 份订单,每份 0.3 秒。依次运行“一个人按顺序”“多个厨师”“灶台有限”“外派厨师”“用 gather 一起”,比较长条的形状和耗时。
  • 一个人按顺序:排成一行,约 2.4 秒(第 1 课)。
  • 4 个厨师:四行叠在一起,约 0.6 秒(第 2 课)。把灶台限制为 2 个,会出现黄色的等待,约 1.2 秒(第 4 课)。
  • 外派厨师:编号越靠后的订单越短,返回顺序是乱的,约 0.4 秒(第 7 课)。
  • gather:八张桌全部重叠,约 0.3 秒(第 9 课)。跟厨师有几个无关,小露一个人就够。

打烊(10 分钟)

拿下评论家套餐的设置是 灶台 3、时间限制 0.6 秒、重试 1。这是用服务器上的实验台引擎亲自跑过确认的值。

灶台时间限制重试已上菜 · 失败耗时目标
20.4 秒00 · 12约 2.4 秒失败
3关012 · 0约 3.5~3.6 秒超时
4关012 · 0约 3.0 秒灶台超额
30.4 秒20 · 12约 4.9 秒失败
30.6 秒09 · 3约 2.2 秒3 道失败
30.6 秒112 · 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
  • 0.6 秒的限制放过普通的菜(0.5 秒),只把第一次拖长的菜(1.5 秒)在 0.6 秒截断。
  • 被截断的三道菜放下灶台,在队尾再试一次,0.5 秒做完。
  • 把限制再收紧到 0.55 秒也能达标(约 2.65 秒)。放宽到 0.7 秒约 2.9 秒,紧贴着 3 秒;0.8 秒就超过 3 秒了。

第十二道菜端出去,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.futuresThreadPoolExecutor、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,揉面用进程,旧烤箱用线程,灶台用信号量。从明天起,订单由副厨来写。

课后练习

  1. 在实验台上达成目标后,把订单减到 8 份,边调时间限制和重试边找找看:只用 2 个灶台,能不能在 3 秒内做完?
  2. 给 12-2 的处理器在两次重试之间加上休息(await asyncio.sleep(0.05 * 2 ** attempt))。应该在拿灶台之前歇,还是拿到之后歇?两种都跑一遍,比较耗时。
  3. 挑一个你想做的程序,用 12-1 的表和 12-4 的检查清单来设计它。每选一件工具,用一句话写下为什么选它就行。

主厨的配方卡

  • 先问:是要等的活(asyncio、线程),还是手不停的活(进程、3.14t)?

  • asyncio 里的阻塞交给 to_thread,繁重计算交给 run_in_executor(进程池)。

  • 资源上限用 Semaphore,时间上限用 asyncio.timeout,必须一起结束的活用 TaskGroup。

  • 重试要先放下灶台,到队尾重新排。时间限制要“比普通的长,比做砸的短”。

  • 检查清单:共享状态、加锁顺序、阻塞调用、吞掉的取消、没有引用的任务、结束不了的队列。

  • 厨房就是程序。把长条画出来,就看得见了。

← 上一课

第11课 · 前厅停住了:阻塞调用与混合使用

订阅新课时和新课程通知

本课程新增课时或推出后续课程时,我们会发邮件通知你。

收集项目:电子邮箱 · 用途:本课程的新课时和上线通知 · 保存期限:直到你要求删除。不会用于其他用途,也不会提供给他人。回复任意通知邮件,我们会立即删除。

问答

关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。

以纯文本显示,不支持链接和格式。

R

ReflexKR

动手学 Python 和 Reflex。所有示例均已在 Reflex 0.10.0 上实际运行验证。

ReflexKR 免费,没有横幅广告。如果对你有帮助,欢迎请我们喝杯咖啡。 ☕ 请我们喝杯咖啡

联盟链接与赞助说明
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex