R

ReflexKR

Reflex 官方文档

深夜厨房里的并发

·

课程概览

1

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

2

多招几个厨师:threading 基础

3

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

4

只有三个灶台:同步工具

5

刀与砧板:死锁

6

订单夹:queue

7

外派厨师:concurrent.futures

8

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

9

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

10

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

11

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

12

评论家之夜:综合

深夜厨房里的并发
第2课

时间 0:50–1:40

多招几个厨师:threading 基础

今晚的订单

#02 · 第二晚

多招几个厨师

危机

敏浩、秀雅和老卓第一天上班。三个人都忙得团团转,可谁在什么时候做完了什么,没有一个人说得清。

目标

六份订单由三个人分着做,0.6 秒内做完,并且记下谁在第几秒做完了什么。

深夜食堂 同时

第二晚,九点半。三位炉台厨师系上围裙。敏浩已经拿起了刀,秀雅在按顺序整理订单,老卓慢条斯理地擦着灶台。

十点一到,订单进来了,三个人各自架上锅。可三十分钟后,主厨问“3 号桌的乌冬是谁做的?”,却没有一个人答得上来。

主厨轻轻敲了敲挂在脖子上的计时器。“人是招到了。今天要学的是:挂上名牌、把活派下去,然后等到他们做完。”

2-1. 招一个厨师:Thread、start、join(10 分钟)

在 Python 里,一个厨师就是一个线程(thread)。大家共用同一间厨房(内存),各自推进自己的活。线程用 threading.Thread 来创建。

import threading
import time

def cook(name, dish):
    print(f"{name}: start {dish}")
    time.sleep(0.3)          # 等汤煮开
    print(f"{name}: done {dish}")

minho = threading.Thread(target=cook, args=("minho", "ramen"))
minho.start()                # 敏浩开工
print("chef: next order")    # 主厨不等他,接着干下一件事
minho.join()                 # 等敏浩做完
print("chef: served")
# minho: start ramen
# chef: next order
# minho: done ramen
# chef: served
  • target= 传线程要做的事(函数),args= 以元组的形式传给这个函数的参数。传的是函数本身而不是调用它,所以写 cook 而不是 cook()。
  • 光创建 Thread(...) 什么也不会发生,只相当于把厨师招进门。要调用 start(),cook 才会在新线程里开始运行。
  • start() 会立刻返回。所以敏浩煮拉面的时候,主厨(最初启动程序的主线程)已经走到下一行了。
  • join() 会一直等到那个线程结束。也就是“站在出菜口,等敏浩把盘子递过来”。

给三个人各派一单,就是这样:

def cook(name, dish):
    time.sleep(0.3)
    print(f"{name}: done {dish}")

crew = [("minho", "ramen"), ("sua", "udon"), ("tak", "soba")]
threads = [threading.Thread(target=cook, args=c) for c in crew]

start = time.perf_counter()
for th in threads:
    th.start()        # 先让三个人全部开工
for th in threads:
    th.join()         # 然后再等所有人
print(f"{time.perf_counter() - start:.2f}s")
# 例如:
# sua: done udon
# minho: done ramen
# tak: done soba
# 0.30s

三个人完成的顺序每次运行都不一样。三个人都在 0.3 秒后几乎同时做完,先叫醒谁要看操作系统。在并发代码里,“输出顺序”是没有保证的。

不要把 start() 和 join() 写在同一个循环里。th.start(); th.join() 交替执行的话,必须等一个人做完下一个人才开工,最后要花 0.9 秒。

等待也可以设上限。join(timeout=秒数) 只等这么久就返回,线程是否还在干活,用 is_alive() 来确认。

slow = threading.Thread(target=time.sleep, args=(1.0,))
slow.start()
slow.join(timeout=0.2)
print(slow.is_alive())   # True:还在煮
slow.join()
print(slow.is_alive())   # False

厨房时间线

订单

6

厨师

3

烹饪

等待

已取消

失败

6 份订单,每份 0.3 秒。在“多个厨师”里把厨师从 1 个调到 6 个试试。行数一行行增加,总耗时从 1.8 秒 → 0.9 秒 → 0.6 秒 → 0.3 秒逐步缩短。
  • 只有 1 个厨师时,和“一个人按顺序”完全一样,大约 1.8 秒。只用一个线程,没有什么可以变快的。
  • 2 个厨师大约 0.9 秒,3 个大约 0.6 秒,6 个大约 0.3 秒。
  • 4 个和 5 个都是大约 0.6 秒。6 份订单分给四个人,总有人要做两份。耗时由分得最多的那个人决定。
  • 一行就是一个线程。这条时间线里的厨师,也和上面的代码一样创建 Thread,再调用 start()、join()。

2-2. 调用 run() 只是普通的函数调用(5 分钟)

Thread 还有一个叫 run() 的方法。名字看着像“运行”,可直接调用它就掉进坑里了。

def cook(name, dish):
    time.sleep(0.3)
    print(f"{threading.current_thread().name}: done {dish}")

threads = [threading.Thread(target=cook, args=c) for c in crew]

start = time.perf_counter()
for th in threads:
    th.run()          # 不是 start(),而是 run()
print(f"{time.perf_counter() - start:.2f}s")
# MainThread: done ramen
# MainThread: done udon
# MainThread: done soba
# 0.90s
  • run() 只是一个调用 target 的普通方法。它不创建新线程,而是在当前线程里按顺序执行。所以三次都是 MainThread 做的,耗时也是 0.3 × 3 = 0.9 秒。
  • 启动新线程的是 start()。start() 创建新线程,再在那个线程里调用 run()。

一个线程只能启动一次。对同一个线程调用两次 start() 会报错。想再做同样的事,就新建一个 Thread。

th = threading.Thread(target=time.sleep, args=(0.1,))
th.start()
th.start()
# RuntimeError: threads can only be started once

主厨的提醒

args=("ramen") 里没有逗号,那就不是元组,只是个字符串。结果 r、a、m、e、n 五个字母被分开传进去,直接报 'cook() takes 1 positional argument but 5 were given'。只有一个参数的时候,一定要写成 args=("ramen",),把逗号带上。还有,线程里抛出的异常不会传到主厨这儿来。屏幕上只打一行 'Exception in thread ...' 和 traceback,程序照样往下跑。别让它悄悄溜过去,一定要看日志。

2-3. 挂上名牌:name、current_thread、enumerate(7 分钟)

今晚的问题是“不知道是谁做的”。线程是可以挂名牌的。

def cook(dish):
    me = threading.current_thread()       # 正在执行这段代码的线程
    print(me.name, dish, threading.get_native_id())
    time.sleep(0.2)

a = threading.Thread(target=cook, args=("ramen",), name="minho")
b = threading.Thread(target=cook, args=("udon",))      # 不给名字会怎样?
a.start(); b.start()
print([t.name for t in threading.enumerate()])
print(threading.active_count())
a.join(); b.join()
print(threading.current_thread() is threading.main_thread())
print([t.name for t in threading.enumerate()])
# 例如:
# minho ramen 25680
# Thread-1 (cook) udon 38072
# ['MainThread', 'minho', 'Thread-1 (cook)']
# 3
# True
# ['MainThread']
工具返回什么
Thread(name="minho")给线程起名字。不给的话,会自动起成 Thread-1 (cook) 这样的“编号加函数名”
threading.current_thread()当前正在执行这一行的线程对象
threading.main_thread()启动程序的主线程(名字是 MainThread)。在今晚的厨房里就是主厨
threading.enumerate()当前还活着的线程列表(包括主线程)
threading.active_count()上面那个列表的长度
threading.get_native_id()操作系统分配的线程编号。和 Linux 上 top -H 这类系统工具里看到的编号一致
  • 没给名字的 b 叫 Thread-1 而不是 Thread-2,是因为编号只分给没有名字的线程。
  • get_native_id() 的数字每次运行都不一样。Python 内部用的编号是 th.ident(或 threading.get_ident()),两者都要等线程启动之后才确定。
  • 干完活的线程会从 enumerate() 里消失。下班的厨师不在名单上。

2-4. 线程不会把盘子递回来(6 分钟)

函数用 return 返回结果,可线程不会替你接住这个值。target 函数的返回值直接被丢掉,join() 也什么都不返回。

def make(dish):
    time.sleep(0.1)
    return f"{dish} ok"

th = threading.Thread(target=make, args=("ramen",))
th.start()
print(th.join())     # join 什么都不返回
# None

所以要事先准备好接结果的容器,让线程把结果放进去。最简单的办法是给每个厨师分一个自己的格子。

dishes = ["ramen", "udon", "soba"]
results = [None] * len(dishes)          # 先把位置留好

def cook(i, dish):
    results[i] = make(dish)             # 只往自己的格子里写

threads = [threading.Thread(target=cook, args=(i, d)) for i, d in enumerate(dishes)]
for th in threads:
    th.start()
for th in threads:
    th.join()
print(results)
# ['ramen ok', 'udon ok', 'soba ok']

想按名字查的话,就用 dict。

done = {}

def cook2(name, dish):
    done[name] = make(dish)

ts = [threading.Thread(target=cook2, args=(n, d)) for n, d in [("minho", "ramen"), ("sua", "udon")]]
for t in ts:
    t.start()
for t in ts:
    t.join()
print(sorted(done.items()))
# [('minho', 'ramen ok'), ('sua', 'udon ok')]
  • 各写各的格子、各写各的键,厨师之间就不会撞车。一旦开始读出同一个格子的值再改写回去,情况就不一样了,那就是明晚的事件。
  • 结果一定要在 join() 结束之后再读。提前读的话,可能还有格子是 None。
  • 如果嫌每次都要准备容器太麻烦,第 7 课 ThreadPoolExecutor 返回的 Future 会替你做这件事。用 future.result() 就能直接接过盘子。

2-5. daemon=True:关灯时不会被等的线程(6 分钟)

Python 程序即使主线程结束了,也会等所有非 daemon 线程都结束之后才退出。默认创建的线程都不是 daemon。

def clean():
    for i in range(1, 6):
        print("cleaning", i)
        time.sleep(0.1)

th = threading.Thread(target=clean)      # daemon=False(默认值)
th.start()
time.sleep(0.25)
print("lights off")
# cleaning 1
# cleaning 2
# cleaning 3
# lights off
# cleaning 4
# cleaning 5

就算主厨喊了一声“关灯了”,只要还有厨师在打扫,店门就关不上。同样的代码,只加上 daemon=True 看看。

th = threading.Thread(target=clean, daemon=True)
th.start()
time.sleep(0.25)
print("lights off", th.daemon)
# cleaning 1
# cleaning 2
# cleaning 3
# lights off True
  • 程序结束时,daemon 线程不会被等。打扫到一半灯就灭了,第 4 和第 5 步永远做不成。
  • 想等的话,即使是 daemon 也可以调用 join()。不管是不是 daemon,join() 都会等。
  • daemon 只用在“中途被掐断也无所谓的活”上,比如定时打印状态的监控线程。要是把写文件、记录订单这种必须做完的活交给 daemon,就会留下写了一半的文件。
  • 是否为 daemon 要在 start() 之前定好。不特意设置的话,会继承创建它的那个线程的值;主线程不是 daemon,所以通常是 False。

2-6. 其他工具:继承 Thread、Timer、local(6 分钟)

继承 Thread、重写 run。 想让厨师带上自己的状态(负责的菜、结果)时用。按 2-2 说的,start() 会在新线程里调用 run(),所以只要重写 run() 就行。

class Cook(threading.Thread):
    def __init__(self, name, dish):
        super().__init__(name=name)     # 一定先调用父类的 __init__
        self.dish = dish
        self.result = None

    def run(self):                      # start() 会在新线程里调用它
        time.sleep(0.1)
        self.result = f"{self.dish} ok"

tak = Cook("tak", "soba")
tak.start()
tak.join()
print(tak.name, tak.result)
# tak soba ok

threading.Timer:n 秒后执行一次。 就像煮面的计时器,到了设定时间就调用一次函数的线程。还没响之前,可以用 cancel() 关掉。

start = time.perf_counter()

def ring(msg):
    print(f"{time.perf_counter() - start:.1f}s", msg)

noodles = threading.Timer(0.5, ring, args=("noodles up",))
noodles.start()                     # 0.5 秒后响一次
eggs = threading.Timer(1.0, ring, args=("eggs up",))
eggs.start()
eggs.cancel()                       # 响之前关掉,就什么也不会发生
noodles.join()
eggs.join()
print("end")
# 0.5s noodles up
# end

threading.local():每个厨师各用各的围裙口袋。 变量名是同一个,但每个线程装的值不一样。谁往里放,用的就是谁的口袋。

pocket = threading.local()

def cook(name, n):
    pocket.name = name
    pocket.count = 0
    for _ in range(n):
        pocket.count += 1
        time.sleep(0.01)
    print(pocket.name, pocket.count)

ts = [threading.Thread(target=cook, args=a) for a in [("minho", 3), ("sua", 5)]]
for t in ts:
    t.start()
for t in ts:
    t.join()
print(hasattr(pocket, "name"))      # 主厨(主线程)的口袋是空的
# minho 3
# sua 5
# False

pocket 明明只是一个全局变量,敏浩的 count 和秀雅的 count 却不会混在一起。让每个线程各自持有一个数据库连接时,经常用这个办法。

主厨的提醒

建线程的时候先把名字起好。凌晨打开日志,满屏只有 Thread-7、Thread-12,你绝对查不出是谁闯的祸。在 logging 的格式里加一个 %(threadName)s,每一行就都会带上名字。

打烊(10 分钟)

今晚的目标是:六份订单由三个人分着做,0.6 秒内做完,并且记下谁在第几秒做完了什么。挂上名牌(name=),每做完一份,就往公用日志里写一行。

start = time.perf_counter()
log = []                                # (完成时刻, 厨师, 订单)

def cook(dishes):
    me = threading.current_thread().name
    for dish in dishes:
        time.sleep(0.3)
        log.append((round(time.perf_counter() - start, 1), me, dish))

plan = {"minho": ["#1", "#4"], "sua": ["#2", "#5"], "tak": ["#3", "#6"]}
threads = [threading.Thread(target=cook, args=(d,), name=n) for n, d in plan.items()]
for th in threads:
    th.start()
for th in threads:
    th.join()

for row in sorted(log):
    print(*row)
print(f"total {time.perf_counter() - start:.2f}s, {len(log)} orders")
# 0.3 minho #1
# 0.3 sua #2
# 0.3 tak #3
# 0.6 minho #4
# 0.6 sua #5
# 0.6 tak #6
# total 0.60s, 6 orders
  • 参数只有一个列表,所以 args=(d,) 里带着逗号。
  • log.append(...) 是一步就完成的列表操作,多个线程一起调用也不会丢行。日志在 join() 之后排好序再读。

主厨把日志扫了一遍,点点头。“好,现在知道谁做了什么了。那明天再试一件事。四个人把各自做的份数,都加到黑板上的同一个数字里,那个数字会对吗?”

营业日志 — 第二晚。敏浩、秀雅、老卓第一天上班。挂上名牌,6 份订单两两分派,0.6 秒做完。谁在第几秒出了什么菜,都记进了日志。

课后练习

  1. 把 2-1 里三个厨师的例子改成在同一个循环里调用 start() 和 join()。先预测要花几秒,再测一测。
  2. 把打烊的代码改成只有老卓每份订单要 0.5 秒(提示:根据厨师名字设不同的 sleep 时间)。总耗时由谁决定?和时间线里只有一行变长的样子对比一下。
  3. 用 threading.Timer 做一个“每 0.3 秒响一次、一共响三次”的厨房计时器。Timer 只响一次,所以响的时候要重新启动下一个 Timer。

主厨的配方卡

  • th = threading.Thread(target=函数, args=(参数,), name="minho") → th.start() → th.join()

  • 直接调用 run() 不会创建新线程,而是在当前线程里按顺序执行。线程用 start() 启动,且只能启动一次。

  • 是谁在做:current_thread().name、main_thread()、enumerate()、get_native_id()

  • 线程不返回结果。放进事先准备好的列表格子或 dict 键里,在 join() 之后读取。

  • daemon=True 的线程在程序结束时不会被等。必须做完的活别交给它。

  • 继承 Thread(重写 run)、Timer(n 秒后执行一次,可 cancel)、local()(每个线程各用各的口袋)

← 上一课

第1课 · 第一晚的长队:并发与并行

下一课 →

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

订阅新课时和新课程通知

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

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

问答

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

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

R

ReflexKR

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

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

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

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

Built with Reflex