今晚的订单
#02 · 第二晚
危机
敏浩、秀雅和老卓第一天上班。三个人都忙得团团转,可谁在什么时候做完了什么,没有一个人说得清。
目标
六份订单由三个人分着做,0.6 秒内做完,并且记下谁在第几秒做完了什么。
深夜食堂 同时
第二晚,九点半。三位炉台厨师系上围裙。敏浩已经拿起了刀,秀雅在按顺序整理订单,老卓慢条斯理地擦着灶台。
十点一到,订单进来了,三个人各自架上锅。可三十分钟后,主厨问“3 号桌的乌冬是谁做的?”,却没有一个人答得上来。
主厨轻轻敲了敲挂在脖子上的计时器。“人是招到了。今天要学的是:挂上名牌、把活派下去,然后等到他们做完。”
在 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: servedtarget= 传线程要做的事(函数),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
烹饪
等待
已取消
失败
Thread,再调用 start()、join()。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.90srun() 只是一个调用 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,程序照样往下跑。别让它悄悄溜过去,一定要看日志。
今晚的问题是“不知道是谁做的”。线程是可以挂名牌的。
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() 里消失。下班的厨师不在名单上。函数用 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。ThreadPoolExecutor 返回的 Future 会替你做这件事。用 future.result() 就能直接接过盘子。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 Truejoin()。不管是不是 daemon,join() 都会等。start() 之前定好。不特意设置的话,会继承创建它的那个线程的值;主线程不是 daemon,所以通常是 False。继承 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 okthreading.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
# endthreading.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
# Falsepocket 明明只是一个全局变量,敏浩的 count 和秀雅的 count 却不会混在一起。让每个线程各自持有一个数据库连接时,经常用这个办法。
主厨的提醒
建线程的时候先把名字起好。凌晨打开日志,满屏只有 Thread-7、Thread-12,你绝对查不出是谁闯的祸。在 logging 的格式里加一个 %(threadName)s,每一行就都会带上名字。
今晚的目标是:六份订单由三个人分着做,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 ordersargs=(d,) 里带着逗号。log.append(...) 是一步就完成的列表操作,多个线程一起调用也不会丢行。日志在 join() 之后排好序再读。主厨把日志扫了一遍,点点头。“好,现在知道谁做了什么了。那明天再试一件事。四个人把各自做的份数,都加到黑板上的同一个数字里,那个数字会对吗?”
营业日志 — 第二晚。敏浩、秀雅、老卓第一天上班。挂上名牌,6 份订单两两分派,0.6 秒做完。谁在第几秒出了什么菜,都记进了日志。
start() 和 join()。先预测要花几秒,再测一测。sleep 时间)。总耗时由谁决定?和时间线里只有一行变长的样子对比一下。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()(每个线程各用各的口袋)
订阅新课时和新课程通知
本课程新增课时或推出后续课程时,我们会发邮件通知你。
问答
关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。