R

ReflexKR

Reflex 官方文档

深夜厨房里的并发

·

课程概览

1

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

2

多招几个厨师:threading 基础

3

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

4

只有三个灶台:同步工具

5

刀与砧板:死锁

6

订单夹:queue

7

外派厨师:concurrent.futures

8

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

9

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

10

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

11

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

12

评论家之夜:综合

深夜厨房里的并发
第4课

时间 2:30–3:20

只有三个灶台:同步工具

今晚的订单

#04 · 第四晚

只有三个灶台

危机

因为有团体客人,厨师增加到了六个。可灶台只有三个,而且主厨喊“开业!”之前,谁都不准点火。

目标

开业信号之前没人点火,灶台同时最多只用三个,1 秒内送出六份订单。

深夜食堂 同时

第四晚,九点四十分。因为有团体预订,又来了三位只干一天的厨师。厨师六个,灶台三个。

敏浩第一个站到灶台前点了火。“先热着不是更快嘛。”主厨一言不发地关上了煤气阀。“开业前点火是禁止的。还有,六个人全挤在灶台前,谁也动弹不得。”

秀雅举起手:“在灶台前只挂三张号码牌怎么样?只有拿着号码牌的人才能站到火前面。”主厨头一次笑了。“这就是今晚的第一个工具。”

4-1. 三个灶台:Semaphore(12 分钟)

第 3 课的 Lock 是一支粉笔,也就是一次一个人。Semaphore(n) 是 n 张号码牌。最多 n 个人同时进去,第 n+1 个人要等有人把号码牌还回来。

import threading
import time

stoves = threading.Semaphore(3)         # 灶台只有三个
start = time.perf_counter()

def cook(name):
    with stoves:                        # 等到有空灶台再占上
        print(f"{time.perf_counter() - start:.1f}s {name} on")
        time.sleep(0.3)

names = [f"cook{i}" for i in range(1, 7)]
threads = [threading.Thread(target=cook, args=(n,)) for n in names]
for th in threads:
    th.start()
for th in threads:
    th.join()
print(f"total {time.perf_counter() - start:.2f}s")
# 例如:
# 0.0s cook1 on
# 0.0s cook2 on
# 0.0s cook3 on
# 0.3s cook4 on
# 0.3s cook5 on
# 0.3s cook6 on
# total 0.60s
  • 进入 with stoves: 时拿走一张号码牌(acquire),出来时还回去(release)。号码牌只剩 0 张时,要进去的人就得等。
  • 0.0 秒进三个,0.3 秒再进三个。灶台只有三个,所以六份订单分两轮,花 0.6 秒。谁先占到灶台,每次运行可能都不一样。
  • Semaphore(1) 和 Lock 一样,一次只放一个人。不过 Lock 在已释放的状态下再释放会抛 RuntimeError,信号量多调用几次 release() 却会悄无声息地多出号码牌(见下面的 BoundedSemaphore)。
  • 实际工作中,它用来限制一拥而上就会出问题的资源的数量,比如“同时最多五个连接”“同时最多下载三个文件”。

在下面的时间线里调一调灶台数。黄色横条是等灶台的时间,橙色横条是真正在做菜的时间。

厨房时间线

订单

6

厨师

3

灶台

2

烹饪

等待

已取消

失败

6 个厨师、6 份订单、3 个灶台。把灶台减到 1 个,黄色的等待横条会像台阶一样越拉越长;加到 6 个,等待横条几乎消失。
  • 3 个灶台:三行直接是橙色横条,另外三行先黄着等 0.3 秒,再变成橙色。总共约 0.6 秒。
  • 1 个灶台:就算有六个厨师,也只能一个接一个地做。最后一个厨师要等 1.5 秒,总共约 1.8 秒,和一个人做一样。
  • 6 个灶台:几乎不用等,约 0.3 秒。灶台数等于厨师数之后,就不会再变快了。
  • 灶台数不变,光加厨师,多出来的只是黄色横条。瓶颈不在人数,而在灶台数。

BoundedSemaphore:抓住多挂号码牌的失误

Semaphore 不会数自己收回了几张。如果因为 bug 多调用了一次 release(),号码牌就悄悄变成了四张。灶台明明只有三个,却有四个人站到了火前面。

loose = threading.Semaphore(3)
loose.release()                         # 不小心多放了一次
print(sum(loose.acquire(blocking=False) for _ in range(10)))   # 灶台变成了四个
# 4

strict = threading.BoundedSemaphore(3)
strict.release()
# ValueError: Semaphore released too many times

BoundedSemaphore 收回的数量一旦超过最初设定的数,就会抛出 ValueError。如果用信号量是为了限制数量,几乎总是 BoundedSemaphore 更好。失误不会悄悄溜过去,而是当场爆出来。

主厨的提醒

信号量只管数人头,可不帮你看着黑板。三个灶台前站了三个人,要是这三个人往同一块黑板上写数字,照样是竞争条件。在最多进三个人的屋子里碰共享值,屋里还得再用一把 Lock。

4-2. “开业!”:Event(9 分钟)

Event 就是厨房墙上的一盏信号灯。一开始是灭的,有人把它点亮(set()),所有在等这个信号(wait())的人就会一齐行动。

opened = threading.Event()              # 一开始是灭的
start = time.perf_counter()

def cook(name):
    opened.wait()                       # 等信号灯亮
    print(f"{time.perf_counter() - start:.1f}s {name} fire")

threads = [threading.Thread(target=cook, args=(n,)) for n in ["minho", "sua", "tak"]]
for th in threads:
    th.start()
time.sleep(0.5)
print(opened.is_set())
opened.set()                            # “开业!”
for th in threads:
    th.join()
print(opened.is_set())
# False
# 0.5s minho fire     (这三行的顺序每次运行都不一样)
# 0.5s sua fire
# 0.5s tak fire
# True

也可以给 wait() 设一个时间上限。这时它的返回值很关键:信号灯亮了就是 True,时间到了还没亮就是 False。

closing = threading.Event()
print(closing.wait(timeout=0.2))        # 等了 0.2 秒,没有信号
closing.set()
print(closing.wait(timeout=0.2))        # 已经亮了就立刻返回 True
closing.clear()                         # 再关掉
print(closing.is_set())
# False
# True
# False
方法作用
set()点亮信号。所有在等的线程全部醒来
wait()一直等到信号亮起。已经亮着就直接通过
wait(timeout)最多等这么久。亮了返回 True,否则返回 False
clear()把信号重新关掉。之后的 wait() 又要重新等
is_set()现在是否亮着

有一种用法很常见:用 Event 做一个“营业结束”的信号,让干活的线程每循环一次就用 if closing.is_set(): break 检查一下。这和第 2 课的 daemon 不同,不是中途被掐断,而是让线程自己收拾干净再结束。

下面这条时间线里,主厨会在 0.5 秒后喊“开业!”。

厨房时间线

厨师

3

烹饪

等待

已取消

失败

厨师们一开始就用 Event.wait() 等着,0.5 秒后信号灯一亮,大家同时开始做菜。不管厨师有几个,出发时刻都一样。
  • 每一行都从 0 秒到 0.5 秒黄着等,到 0.5 秒时一齐变成橙色。总共约 0.8 秒。
  • 厨师设成 1 个也好,8 个也好,起跑线都是 0.5 秒。因为一次 set() 就会叫醒所有在等的人。

4-3. “食材到了叫我一声”:Condition(10 分钟)

Event 只有亮和灭两种状态。可厨房里的等待往往更具体:敏浩在等鸡蛋,秀雅在等豆腐。每当货架上进了食材,就得看一眼“我的到了吗?”,没到就接着等。这种时候用 Condition。

Condition 就是一把锁 + 一张“到了叫我”的名单。它用锁护住共享状态(食材货架),状态一变,就去叫醒名单上的人。

pantry = []                             # 食材货架(共享状态)
arrived = threading.Condition()         # 护住货架的锁 + “到了叫我”的名单
start = time.perf_counter()

def cook(name, need):
    with arrived:
        ok = arrived.wait_for(lambda: need in pantry, timeout=0.5)
        print(f"{time.perf_counter() - start:.1f}s {name} {need if ok else 'gave up'}")

def supplier():
    for item in ["tofu", "egg"]:        # 鱼今天不来
        time.sleep(0.1)
        with arrived:
            pantry.append(item)
            arrived.notify_all()        # 把等着的人全叫醒

threads = [threading.Thread(target=cook, args=a) for a in [("minho", "egg"), ("sua", "tofu"), ("tak", "fish")]]
threads.append(threading.Thread(target=supplier))
for th in threads:
    th.start()
for th in threads:
    th.join()
# 0.1s sua tofu
# 0.2s minho egg
# 0.5s tak gave up
  • 只有用 with arrived: 拿着锁的时候,才去查看和修改货架。这就是第 3 课的规矩。
  • wait_for(条件) 一直等到条件为真。等待期间会暂时放下锁,所以供货商能把食材放上货架。醒来后重新拿到锁,再检查一遍条件。
  • 给了 timeout,时间到时就返回条件最后一次的值。鱼始终没来,老卓 0.5 秒后拿到 False,放弃了。
  • notify_all() 会叫醒名单上的所有人。醒来的人各自检查自己的条件,不满足就接着睡。豆腐到的时候,敏浩和老卓也醒了,但不是自己要的食材,于是继续等。

notify() 只叫醒一个在等的人。适合谁拿都无所谓的活,比如“做好的一盘菜,随便哪个前厅员工端走都行”。

plates = 0
counter = threading.Condition()

def cook():
    global plates
    time.sleep(0.1)
    with counter:
        plates += 1
        counter.notify()                # 只叫醒一个在等的人

def waiter(name):
    global plates
    with counter:
        counter.wait_for(lambda: plates > 0)
        plates -= 1
        print(name, "took a plate")

ws = [threading.Thread(target=waiter, args=(n,)) for n in ["w1", "w2"]]
cs = [threading.Thread(target=cook) for _ in range(2)]
for th in ws + cs:
    th.start()
for th in ws + cs:
    th.join()
print(plates)
# 例如:
# w1 took a plate
# w2 took a plate
# 0

一盘菜只叫醒一个人,就不容易出现两个人同时醒来、为一盘菜争抢的情况。反过来,如果每个人等的条件不同,notify() 可能叫醒不相干的人,这时要用 notify_all()。拿不准的话,notify_all() 更保险。

主厨的提醒

在 Condition 里用 wait() 的时候,一定要放在 while 里重新检查条件。被叫醒不代表你的食材到了。可能是别人的,也可能被人先拿走了。用 wait_for(条件),它会替你转那个 while,最省事。

4-4. 团体桌的四盘菜要一起上:Barrier(5 分钟)

团体 4 人桌的四盘菜必须同时上。要是只先上一盘,剩下三个人就得干坐着,看着菜变凉。Barrier(n) 会在出菜口等到 n 个人全部到齐,凑齐了再一起放行。

start = time.perf_counter()
table = threading.Barrier(4, action=lambda: print("serve 4 plates"))
done = []

def cook(name, sec):
    time.sleep(sec)                     # 每个人速度不一样
    i = table.wait()                    # 在出菜口等四个人到齐
    done.append((round(time.perf_counter() - start, 1), i, name))

crew = [("minho", 0.1), ("sua", 0.2), ("chef", 0.3), ("tak", 0.4)]
threads = [threading.Thread(target=cook, args=c) for c in crew]
for th in threads:
    th.start()
for th in threads:
    th.join()
print(sorted(done))
# serve 4 plates
# [(0.4, 0, 'minho'), (0.4, 1, 'sua'), (0.4, 2, 'chef'), (0.4, 3, 'tak')]
  • 敏浩 0.1 秒就做完了,但四个人都是在最慢的老卓到达的 0.4 秒一起通过。
  • wait() 会给每个人返回一个 0 到 n-1 之间互不相同的编号。在 CPython 里就是按到达顺序排的 0、1、2、3。可以用这个编号来指定“只需一个人做的事”(比如 if i == 0: 时记日志)。
  • 传给 action= 的函数,只在最后一个人到达时执行一次。这就是上菜的信号。
  • Barrier 全员通过后会恢复初始状态,下一桌还能接着用。wait(timeout=) 超时的话,Barrier 就会被打破,所有在等的人都会收到 BrokenBarrierError。

4-5. 今晚的工具小结(4 分钟)

工具换成厨房来说什么时候用
Lock一支粉笔共享值一次只让一个人改(第 3 课)
Semaphore(n)、BoundedSemaphore(n)灶台前的 n 张号码牌最多同时用 n 个
Event墙上的信号灯把“开始”“结束”这类一次性信号发给所有人
Condition锁 + “到了叫我”名单“满足这个条件时叫醒我”。等共享状态发生变化时
Barrier(n)团体桌的出菜口n 个人到齐后一起出发

这五样都在 threading 里,也都是在第 3 课的锁之上搭起来的工具。实际上,Semaphore、Event、Barrier 内部用的是 Condition,而 Condition 内部握着一把锁(不另外指定的话就是 RLock)。

打烊(10 分钟)

今晚的目标是:开业信号之前没人点火,灶台同时最多只用三个,1 秒内送出六份订单。用 Event 定起跑线,用 Semaphore(3) 管灶台,用 Lock 数“现在火前站着几个人”。

opened = threading.Event()
stoves = threading.Semaphore(3)
lock = threading.Lock()
on_fire = peak = 0
log = []
start = time.perf_counter()

def cook(n):
    global on_fire, peak
    opened.wait()                       # 1. “开业!”之前不点火
    with stoves:                        # 2. 灶台同时最多三个
        with lock:
            on_fire += 1
            peak = max(peak, on_fire)
        time.sleep(0.3)
        with lock:
            on_fire -= 1
            log.append((round(time.perf_counter() - start, 1), f"#{n}"))

threads = [threading.Thread(target=cook, args=(n,)) for n in range(1, 7)]
for th in threads:
    th.start()
time.sleep(0.3)                         # 准备迎客
opened.set()
for th in threads:
    th.join()
print(sorted(log))
print(f"peak {peak}, total {time.perf_counter() - start:.2f}s")
# 例如:
# [(0.6, '#1'), (0.6, '#2'), (0.6, '#3'), (0.9, '#4'), (0.9, '#5'), (0.9, '#6')]
# peak 3, total 0.90s
  • 0.3 秒之前没有人做菜。第一盘 0.6 秒出,最后一盘 0.9 秒出,在 1 秒的目标之内。
  • peak 跑多少次都是 3。0.6 秒出来的是哪三个订单,每次运行可能不一样。
  • on_fire 和 peak 是六个厨师一起改的值,所以用 lock 包了起来。哪怕已经在信号量里面,也要这么做。

快打烊时,一位独自坐在靠窗位子的女客人吃完了一碗面。她看了一眼手表,在小本子上记了点什么,然后安静地结账离开了。

主厨一个个关着灶台的阀门说:“今天大家排队排得不错。可要是除了灶台,刀和砧板也各只有一份呢?敏浩拿着刀等砧板,秀雅拿着砧板等刀,会怎么样?”

营业日志 — 第四晚。厨师 6 人,灶台 3 个。开业信号(Event)之前待命,灶台用三张号码牌(Semaphore)限制,0.9 秒完成 6 单。靠窗的客人一位,面一碗。

课后练习

  1. 在灶台时间线里设成 6 个厨师、6 份订单,把灶台从 1 调到 6,把总耗时记成一张表。为什么 4 个灶台和 5 个灶台的时间一样?
  2. 亲手实现 4-2 里“营业结束”信号的用法。准备一个 closing = threading.Event(),让厨师线程每 0.1 秒做一碗,一旦 closing.is_set() 就停下。主线程在 0.35 秒后调用 closing.set(),然后 join()。一共做出了几碗?
  3. 想一想,把 4-4 Barrier 示例里的 Barrier(4) 改成 Barrier(5) 会发生什么。为了不让它真的永远卡住,改成 table.wait(timeout=1) 再运行,看看会抛出什么异常。

主厨的配方卡

  • Semaphore(n):用 with sem: 限制最多同时 n 个。用于限制数量时,换成 BoundedSemaphore(n),release 失误会以 ValueError 暴露出来。

  • Event:set() 点亮后,所有在 wait() 的线程都会醒来。wait(timeout) 亮了返回 True,否则 False。还有 clear()、is_set()。

  • Condition:在 with cond: 里调用 cond.wait_for(条件)。改了状态的一方调用 notify()(一个)或 notify_all()(全部)。

  • Barrier(n):wait() 等 n 个人到齐一起通过。返回编号 0~n-1,action= 只执行一次。

  • 就算在信号量里面,改共享值也要用 Lock。工具是叠起来用的。

← 上一课

第3课 · 黑板上的数字错了:竞争条件与 Lock

下一课 →

第5课 · 刀与砧板:死锁

订阅新课时和新课程通知

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

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

问答

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

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

R

ReflexKR

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

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

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

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

Built with Reflex