今晚的订单
#08 · 第八晚
危机
明天要用的面条面团有四团。派了四个厨师上去,花的时间却和一个人揉一模一样。
目标
让四团面比一个人揉时快两倍以上做完。
深夜食堂 同时
营业结束后的凌晨一点。工作台上放着明天要用的四团面。敏浩、秀雅、老卓,再加上你,四个人一人一团,照理应该快四倍。
可主厨的计时器停下的数字,和一个人揉时差不多。敏浩歪着头:“大家手可都没闲着啊?”
尹瑞妍主厨指了指工作台上的刀架。上面只插着一把刀。“这间厨房有一条老规矩:切和揉,一次只能一个人。今天就来聊聊这条规矩。”
第 1 课的订单是等汤煮开的活。今天的面团不一样,是一个把数字累加两百万次的循环,也就是手停不下来的活(CPU 任务)。
import threading
import time
def knead(n=2_000_000):
s = 0
for i in range(n): # 一刻不停地计算:揉面
s += i
return s
start = time.perf_counter()
for _ in range(4):
knead()
print(f"sequential {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
cooks = [threading.Thread(target=knead) for _ in range(4)]
for th in cooks:
th.start()
for th in cooks:
th.join()
print(f"4 threads {time.perf_counter() - start:.2f}s")
# sequential 0.24s
# 4 threads 0.23s (12 核电脑,Python 3.14 实测)CPython(我们平常用的 Python)里有一个 GIL(Global Interpreter Lock,全局解释器锁)。规矩只有一条:
在一个进程里,同一时刻只有一个线程在执行 Python 字节码。
换成厨房来说,就是“刀只有一把”的规矩。就算有四个厨师,也只有握着刀的人能切,其余的人等刀转回来。刀大约每 0.005 秒传给下一个人(sys.getswitchinterval(),第 3 课)。
| 时刻 | 敏浩 | 秀雅 | 老卓 | 你 |
|---|---|---|---|---|
| 0~5ms | 握着刀揉面 | 等待 | 等待 | 等待 |
| 5~10ms | 等待 | 握着刀揉面 | 等待 | 等待 |
| 10~15ms | 等待 | 等待 | 握着刀揉面 | 等待 |
| 15~20ms | 等待 | 等待 | 等待 | 握着刀揉面 |
四个人轮流揉,四团面都在一点点推进。有并发,却没有并行(第 1 课)。总工作量没变,时间自然也没变。
为什么会有这样一条规矩?Python 会给每个对象记一个“有几处在用我”的数(引用计数)。多个线程同时改这个数,就会像第 3 课的黑板一样出错。很久以前,CPython 选了一条省事的路:用一把锁护住整个解释器。这就是 GIL。
GIL 有一个重要的例外:线程在等待期间(time.sleep、读文件、等网络响应)会释放 GIL。等汤煮开的人用不着刀。
def simmer():
time.sleep(0.3) # 等汤煮开:这期间释放 GIL
start = time.perf_counter()
cooks = [threading.Thread(target=simmer) for _ in range(4)]
for th in cooks:
th.start()
for th in cooks:
th.join()
print(f"4 threads {time.perf_counter() - start:.2f}s")
# 4 threads 0.30s (按顺序的话是 1.2 秒)用 C 写的函数在做繁重计算时,也可以释放 GIL。hashlib 给大块数据算哈希时就是这样。这活不是人手在干,而是和面机(C 代码)在转,算是在刀的规矩之外。
import hashlib
dough = b"x" * 50_000_000 # 50MB
def press():
hashlib.sha256(dough).hexdigest() # C 代码计算期间释放 GIL
# 用和上面相同的方式测量(这台电脑上大约)
# sequential 0.11s
# 4 threads 0.04s| 活的种类 | 用有 GIL 的线程 | 原因 |
|---|---|---|
| 要等的活(sleep、文件、网络) | 会变快 | 等待期间释放 GIL |
| C 扩展里的繁重计算(hashlib、zlib 等) | 通常会变快 | C 代码释放 GIL 再计算 |
| 纯 Python 计算(循环、拼字符串) | 不会变快 | 字节码一次只有一个线程执行 |
还有,GIL 不保护你的代码。GIL 只护着解释器内部,像 count += 1 这种分好几步的操作,中途照样会被打断。这就是第 3 课的黑板明明有 GIL 还是算错的原因。
下面的实验台,是部署服务器上真的有线程在执行 time.sleep 的厨房。服务器上的 Python 有 GIL,但因为是要等的活,横条照样会叠在一起。
厨房时间线
订单
6
厨师
3
烹饪
等待
已取消
失败
主厨的提醒
加了线程却没变快,先问问这活是汤还是面团。是汤,线程就够了。是面团,那要加的不是人,是厨房。
既然刀只有一把,那就再开一间有自己刀的厨房。这就是进程。每个进程跑着各自的 Python 解释器,GIL 也各有一把。
import multiprocessing as mp
import os
def branch(name):
print(name, "pid", os.getpid(), "parent", os.getppid())
if __name__ == "__main__":
print("main pid", os.getpid())
p = mp.Process(target=branch, args=("branch-1",))
p.start()
p.join()
print("exitcode", p.exitcode)
# 例如:
# main pid 39516
# branch-1 pid 40268 parent 39516
# exitcode 0Process 的用法和 threading.Thread 一样(target、args、start、join)。不同的是 os.getpid(),也就是进程号。分店是一个和总店编号不同的独立程序。
分店不共享内存。分店往列表里放什么,总店的列表都纹丝不动。
orders = []
def add(n):
orders.append(n)
print("in branch:", orders)
if __name__ == "__main__":
p = mp.Process(target=add, args=("#1",))
p.start()
p.join()
print("in main:", orders)
# in branch: ['#1']
# in main: []第 3 课的黑板问题从根上就不会出现,但要拿到结果,就得专门传回来。把多个任务分派出去时,用 Pool 很方便。
def knead(n):
s = 0
for i in range(n):
s += i
return s
if __name__ == "__main__":
with mp.Pool(processes=4) as pool:
print(pool.map(knead, [10, 100, 1000, 10_000]))
# [45, 4950, 499500, 49995000]if __name__ == "__main__":Windows 和 macOS 用 spawn 方式开分店:启动一个新的 Python,由它把你的文件从头再 import 一遍。只有重新读一遍函数定义,它才知道 knead 是什么。
没有这个判断的话,分店读文件时又会碰到 mp.Process(...).start() 这一行,于是分店也要去开分店。Python 会拦住这种情况并报错。
# 不加判断、直接在最外层启动的话(Windows)
p = mp.Process(target=knead, args=(1,))
p.start()
# 在分店里:
# RuntimeError:
# An attempt has been made to start a new process before the
# current process has finished its bootstrapping phase.
# ...所以 multiprocessing 的示例要存成文件,用 python 文件名.py 运行。在交互式命令行或 notebook 里,分店很容易找不到函数而失败。
还记得第 7 课的 ThreadPoolExecutor 吗?ProcessPoolExecutor 用同样的用法把任务交给进程。submit、Future、result()、map、as_completed 全都照旧。
import os
from concurrent.futures import ProcessPoolExecutor
def knead(n):
s = 0
for i in range(n):
s += i
return os.getpid(), s
if __name__ == "__main__":
print("main", os.getpid())
with ProcessPoolExecutor(max_workers=2) as ex:
future = ex.submit(knead, 1000)
print(future.result())
for pid, s in ex.map(knead, [10, 100, 1000]):
print(pid, s)
# 例如:
# main 43452
# (42784, 499500)
# 42784 45
# 42784 4950
# 42784 499500进程号和 main 的不一样。哪家分店接哪团面,每次运行都不同。
分店和总店不共享内存,所以函数、参数和结果都要打包成字节(pickle)再传来传去。打不了包的东西就传不过去。最典型的就是 lambda。
def double(x):
return x * 2
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers=2) as ex:
print(ex.submit(double, 21).result())
try:
ex.submit(lambda x: x * 2, 21).result()
except Exception as e:
print(type(e).__name__, e)
# 42
# PicklingError Can't pickle <function <lambda> at 0x...>: it's not found as __main__.<lambda>double”来用。没有名字的 lambda,分店找不到。def 定义。函数里面的函数、lambda 都不行。分店不是免费的。要新启动 Python、重新 import 文件、把值打包来回传。面团小的话,这些准备时间比干活本身还长。
# 把 8-1 的小面团(两百万次)4 个交给 4 个进程
with ProcessPoolExecutor(max_workers=4) as ex:
results = list(ex.map(knead, [2_000_000] * 4))
# 4 processes 0.31s (12 核电脑实测,按顺序是 0.24 秒)| 方式(4 个小面团) | 耗时(12 核电脑实测) |
|---|---|
| 按顺序 | 0.24 秒 |
| 4 个线程 | 0.23 秒 |
| 4 个进程 | 0.31 秒 |
反而变慢了。把面团放大十倍(两千万次),情况就不同了。在这台电脑上,按顺序大约 2.42.7 秒,4 个进程 0.91.1 秒。准备成本几乎没变,活却变大了。
map 的 chunksize=)。with ProcessPoolExecutor(),就等于每次都重新开一家分店。怎么开分店,叫作启动方式(start method)。
| 方式 | 做什么 | 换成厨房来说 |
|---|---|---|
spawn | 启动新的 Python,重新 import 文件 | 在一栋空楼里从零搭起厨房 |
fork | 把当前进程整个复制一份 | 把总店原样复制(连正在干活的厨师手里的锁也一起) |
forkserver | 先准备一个干净的服务器进程,从它复制 | 留一间干净的样板厨房,照着它复制 |
forkserver。 Windows 和 macOS 是 spawn。之前 Linux 默认的 fork 在复制正在跑线程的程序时,锁可能会乱掉,所以改了。spawn 一种。import multiprocessing as mp
if __name__ == "__main__":
print(mp.get_start_method()) # spawn (Windows)
print(mp.get_all_start_methods()) # ['spawn']
ctx = mp.get_context("spawn") # 只让这个池用 spawn
with ctx.Pool(processes=2) as pool:
print(pool.map(knead, [10, 100])) # [45, 4950]mp.get_context("spawn") 只对用这个上下文创建的 Pool、Process 生效。在库的代码里,这样做更安全。if __name__ == "__main__": 的最前面调用 mp.set_start_method("spawn"),而且只调用一次。调用两次会报 RuntimeError: context has already been set。spawn 并做测试。从 3.14 起,自由线程(free-threaded)构建获得正式支持。这是去掉 GIL 构建的 Python,可执行文件叫 python3.14t(末尾带个 t)。相当于把厨房翻修了一遍,刀的数量和厨师一样多了。
uv run --python 3.14t 文件名.py。sys._is_gil_enabled() 查看。# knead_threads.py:只在 8-1 的代码最上面加两行
import sys
print(sys._is_gil_enabled())
...
# python knead_threads.py
# True
# sequential 0.24s
# 4 threads 0.23s
# python3.14t knead_threads.py
# False
# ...
# 4 threads 0.09s (12 核电脑实测,约 2.7 倍)代码一个字都没改。 同样的线程代码,只换了解释器,就快了约 2.7 倍。之所以不是四倍,是因为启动和管理线程有开销,没有 GIL 也要安全运行的内部机制也有开销。相应地,只用一个线程的代码可能比普通构建稍慢一点。
有两点需要知道:
PYTHON_GIL=0(或 -X gil=0)强制关闭;设成 PYTHON_GIL=1 的话,3.14t 上也会开着 GIL。下面实验台上方的四根横条,是准备讲义时在 12 核电脑上测出的值。下方的“在服务器上直接揉面”,会在本站的部署服务器上把面团(60 万次 × 4)按顺序、用 4 个线程各直接跑一遍。部署服务器是有 GIL 的构建,核数也没法事先知道,所以数字可能和实测横条不同。看两根横条的长度是否差不多就行。
揉面对比
本地实测(12 核电脑,Python 3.14)
按顺序
0.24s
4 个线程
0.23s
4 个进程
0.31s
4 个线程(3.14t,无 GIL)
0.09s
主厨的提醒
GIL 没了,是刀的规矩没了,不是黑板的规矩没了。好几个人往同一块黑板上写,现在可是真的同时在写。迁到 3.14t 之前,先把锁重新检查一遍。
分店太贵,翻修又还没有让所有库都跟上。两者之间还有一个选择:在同一栋楼里用隔板隔出几间厨房,也就是子解释器。在一个进程里放好几个解释器,每个隔间各有一把 GIL(刀)。3.14 通过 concurrent.interpreters 模块把它纳入了标准库。
from concurrent import interpreters
booth = interpreters.create()
booth.exec("menu = ['#1', '#2']; print('booth menu:', menu)")
booth.exec("menu.append('#3'); print('booth menu:', menu)")
print("main has menu?", "menu" in globals())
def knead(n):
s = 0
for i in range(n):
s += i
return s
print(booth.call(knead, 1000))
booth.close()
# booth menu: ['#1', '#2']
# booth menu: ['#1', '#2', '#3']
# main has menu? False
# 499500create() 建隔间,用 exec(代码字符串) 在隔间里运行代码,用 call(函数, 参数) 调用函数并拿回结果。menu)只存在于隔间里。和分店一样,不共享内存。实际工作中,和第 7 课一个模样的 InterpreterPoolExecutor(3.14)用起来更方便。
from concurrent.futures import InterpreterPoolExecutor
if __name__ == "__main__":
with InterpreterPoolExecutor(max_workers=4) as ex:
results = list(ex.map(knead, [2_000_000] * 4))
# 这台电脑上大约 0.18 秒(有 GIL 的 3.14,按顺序是 0.24 秒)每个隔间都有自己的刀,所以即使是普通的 Python,面团也能并排推进。不过要记住,目前还有很多 C 扩展不支持子解释器。
今晚的目标是让四团面比一个人揉时快两倍以上做完。这是纯 Python 计算,线程不管用;每一团都很大,开分店的成本也担得起。
import time
from concurrent.futures import ProcessPoolExecutor
BIG = 20_000_000 # 一大团面
def knead(n):
s = 0
for i in range(n):
s += i
return s
if __name__ == "__main__":
start = time.perf_counter()
alone = [knead(BIG) for _ in range(4)]
print(f"sequential {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as ex:
branches = list(ex.map(knead, [BIG] * 4))
print(f"4 processes {time.perf_counter() - start:.2f}s", branches == alone)
# 例如(这台电脑上):
# sequential 2.43s
# 4 processes 0.93s True大约快了 2.5 倍,结果也和一个人做的完全一样。如果有 3.14t,用四个线程跑 knead 也能得到差不多的效果。
| 工具 | 刀(GIL) | 内存 | 启动成本 | 适合的活 |
|---|---|---|---|---|
| 线程(3.14) | 每个进程一把 | 共用 | 小 | 要等的活 |
| 线程(3.14t) | 没有 | 共用 | 小 | 要等的活 + 计算 |
| 进程 | 每家分店一把 | 分开(pickle) | 大 | 大计算 |
| 子解释器 | 每个隔间一把 | 分开 | 中等 | 计算、纯 Python 代码 |
主厨解下围裙说:“面团靠多开厨房解决了。可前厅呢?十张桌子配十个服务员,还是让一个人跑十张桌子?”
营业日志 — 第八晚。面团用线程快不起来,因为刀只有一把。分给四家分店去做,快了 2.5 倍。明天改组前厅。
knead 换成 time.sleep(0.06),再测一次按顺序和 4 个线程。用 GIL 解释一下,为什么这回线程变快了。ProcessPoolExecutor 的耗时记成一张表。在你的电脑上,面团多大时进程开始占上风?ProcessPoolExecutor 里 submit 一个在函数内部定义的函数(嵌套函数),看看会报什么错,再把它改好。主厨的配方卡
GIL:Python 字节码一次只有一个线程执行。等待(I/O)时和部分 C 代码会释放 GIL。
计算(CPU)任务用线程快不起来 → 用进程、3.14t、子解释器。
ProcessPoolExecutor 和 ThreadPoolExecutor 用法相同。必须写 if __name__ == "__main__":
和分店来回传的值要被 pickle:lambda、嵌套函数都不行。用最外层的 def。
进程启动成本高:只用于大计算。启动方式:Windows、macOS 是 spawn,3.14 的 Linux 是 forkserver。
sys._is_gil_enabled():在 3.14t(python3.14t)上为 False。同样的线程代码原样变成并行。
concurrent.interpreters:create() → exec() / call(),以及 InterpreterPoolExecutor(3.14)。
订阅新课时和新课程通知
本课程新增课时或推出后续课程时,我们会发邮件通知你。
问答
关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。