R

ReflexKR

Reflex 官方文档

深夜厨房里的并发

·

课程概览

1

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

2

多招几个厨师:threading 基础

3

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

4

只有三个灶台:同步工具

5

刀与砧板:死锁

6

订单夹:queue

7

外派厨师:concurrent.futures

8

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

9

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

10

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

11

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

12

评论家之夜:综合

深夜厨房里的并发
第8课

时间 5:50–6:40

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

今晚的订单

#08 · 第八晚

面团为什么快不起来

危机

明天要用的面条面团有四团。派了四个厨师上去,花的时间却和一个人揉一模一样。

目标

让四团面比一个人揉时快两倍以上做完。

深夜食堂 同时

营业结束后的凌晨一点。工作台上放着明天要用的四团面。敏浩、秀雅、老卓,再加上你,四个人一人一团,照理应该快四倍。

可主厨的计时器停下的数字,和一个人揉时差不多。敏浩歪着头:“大家手可都没闲着啊?”

尹瑞妍主厨指了指工作台上的刀架。上面只插着一把刀。“这间厨房有一条老规矩:切和揉,一次只能一个人。今天就来聊聊这条规矩。”

8-1. 四个厨师,时间没变(5 分钟)

第 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 实测)
  • 四个线程确实都跑了,耗时却和按顺序做几乎一样。
  • 这台电脑有 12 个核,并不是核不够用。
  • 数字因电脑、因每次运行都会略有不同。重点在于两个数字几乎相同。

8-2. GIL:刀只有一把(8 分钟)

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

烹饪

等待

已取消

失败

4 个要等的活,每个 0.3 秒。即使在有 GIL 的服务器上,多个厨师一起做,横条也会叠在一起,约 0.3 秒做完。和一个人按顺序做比一比。

主厨的提醒

加了线程却没变快,先问问这活是汤还是面团。是汤,线程就够了。是面团,那要加的不是人,是厨房。

8-3. 分店厨房:multiprocessing(7 分钟)

既然刀只有一把,那就再开一间有自己刀的厨房。这就是进程。每个进程跑着各自的 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 0

Process 的用法和 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 里,分店很容易找不到函数而失败。

8-4. 把外派厨师派到分店:ProcessPoolExecutor(6 分钟)

还记得第 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

分店和总店不共享内存,所以函数、参数和结果都要打包成字节(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 都不行。
  • 打开的文件、锁、socket 这类东西也不能当参数传。传数字、字符串、列表、dict 这类简单的值就好。

8-5. 开分店的成本(4 分钟)

分店不是免费的。要新启动 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(),就等于每次都重新开一家分店。

8-6. 开分店的三种方式:spawn、fork、forkserver(3 分钟)

怎么开分店,叫作启动方式(start method)。

方式做什么换成厨房来说
spawn启动新的 Python,重新 import 文件在一栋空楼里从零搭起厨房
fork把当前进程整个复制一份把总店原样复制(连正在干活的厨师手里的锁也一起)
forkserver先准备一个干净的服务器进程,从它复制留一间干净的样板厨房,照着它复制
  • 从 3.14 起,Linux 的默认值是 forkserver。 Windows 和 macOS 是 spawn。之前 Linux 默认的 fork 在复制正在跑线程的程序时,锁可能会乱掉,所以改了。
  • Windows 上只有 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 并做测试。

8-7. 翻修厨房:没有 GIL 的 Python 3.14t(5 分钟)

从 3.14 起,自由线程(free-threaded)构建获得正式支持。这是去掉 GIL 构建的 Python,可执行文件叫 python3.14t(末尾带个 t)。相当于把厨房翻修了一遍,刀的数量和厨师一样多了。

  • 在 python.org 的安装程序里勾选一并安装 free-threaded 二进制文件;用 uv 的话,执行 uv run --python 3.14t 文件名.py。
  • 现在 GIL 是否开着,用 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 也要安全运行的内部机制也有开销。相应地,只用一个线程的代码可能比普通构建稍慢一点。

有两点需要知道:

  • C 扩展可能会把 GIL 重新打开。 如果 import 了没有声明支持自由线程的扩展模块,Python 会发出警告并重新启用 GIL。可以用环境变量 PYTHON_GIL=0(或 -X gil=0)强制关闭;设成 PYTHON_GIL=1 的话,3.14t 上也会开着 GIL。
  • 竞争条件更容易出现。 第 3 课的黑板实验在 3.14t 上,4000 次只剩 2410(实测)。线程真的在同时跑,锁就更重要了。

下面实验台上方的四根横条,是准备讲义时在 12 核电脑上测出的值。下方的“在服务器上直接揉面”,会在本站的部署服务器上把面团(60 万次 × 4)按顺序、用 4 个线程各直接跑一遍。部署服务器是有 GIL 的构建,核数也没法事先知道,所以数字可能和实测横条不同。看两根横条的长度是否差不多就行。

揉面对比

本地实测(12 核电脑,Python 3.14)

按顺序

0.24s

4 个线程

0.23s

4 个进程

0.31s

4 个线程(3.14t,无 GIL)

0.09s

把 4 个 CPU 任务分别按顺序、用线程、用进程、用无 GIL 的 3.14t 线程跑出来的实测横条。也在服务器上直接跑一跑,看看服务器的 GIL 状态和核数。

主厨的提醒

GIL 没了,是刀的规矩没了,不是黑板的规矩没了。好几个人往同一块黑板上写,现在可是真的同时在写。迁到 3.14t 之前,先把锁重新检查一遍。

8-8. 隔间厨房:子解释器(2 分钟)

分店太贵,翻修又还没有让所有库都跟上。两者之间还有一个选择:在同一栋楼里用隔板隔出几间厨房,也就是子解释器。在一个进程里放好几个解释器,每个隔间各有一把 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
# 499500
  • 用 create() 建隔间,用 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 扩展不支持子解释器。

打烊(10 分钟)

今晚的目标是让四团面比一个人揉时快两倍以上做完。这是纯 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 倍。明天改组前厅。

课后练习

  1. 把 8-1 的 knead 换成 time.sleep(0.06),再测一次按顺序和 4 个线程。用 GIL 解释一下,为什么这回线程变快了。
  2. 把 8-5 的面团大小依次改成 20 万、200 万、2000 万次,把按顺序和 ProcessPoolExecutor 的耗时记成一张表。在你的电脑上,面团多大时进程开始占上风?
  3. 往 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)。

← 上一课

第7课 · 外派厨师:concurrent.futures

下一课 →

第9课 · 一个人跑全场的前厅经理:asyncio 入门

订阅新课时和新课程通知

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

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

问答

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

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

R

ReflexKR

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

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

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

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

Built with Reflex