你有一段纯 Python 的计算要跑 4 遍,机器有 8 个核,于是开了 4 个线程。结果总耗时和串行几乎一样。换成 4 个进程,快了 3 倍;可如果任务是下载 10 个网页,线程又突然快了 10 倍。这一篇讲清楚这三个现象背后的同一个原因,再讲怎么在动手优化之前,先找到程序真正慢的地方。
下文数字都在 Apple M3(4 性能核 + 4 能效核)、macOS、CPython 3.12 上真实跑出。计时每次运行都有波动,看量级。
多开线程,为什么没变快
先做实验。把一个纯 Python 的倒计数跑 4 份,分别用串行、4 个线程、4 个进程:
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
N = 10_000_000
def count_down(n):
while n > 0: # 纯字节码循环,没有任何 I/O 或 C 扩展调用
n -= 1
# 串行:[count_down(N) for _ in range(4)]
with ThreadPoolExecutor(4) as ex: list(ex.map(count_down, [N] * 4))
with ProcessPoolExecutor(4) as ex: list(ex.map(count_down, [N] * 4))串行 0.88 s
4 线程 0.83 s
4 进程 0.29 s线程几乎没帮忙,进程快了约 3 倍(没到 4 倍,一部分原因是 M3 有一半是较慢的能效核)。
原因是 GIL(Global Interpreter Lock,全局解释器锁)。它是 CPython 解释器里的一把互斥锁:线程必须持有它才能执行 Python 字节码。一个进程里只有一把,所以不管开多少线程,同一时刻只有一个在跑字节码,其余的在排队。4 个线程只是把同样多的计算轮流做完,总量不变。
为什么要设计这样一把锁?CPython 用引用计数管理内存:每个对象记着有多少名字指向它,每次赋值、传参、函数返回都要加减这个计数,归零就释放(名字和对象的关系见 Python 对象模型)。如果两个线程同时对同一个计数做 +1,就是 并发与同步 里讲的经典竞态,计数会算错,对象可能在还有人用的时候被释放。给每个对象各配一把细粒度锁,单线程程序要为大量加锁解锁付出代价;CPython 选了最简单的方案,一把大锁护住整个解释器。好处是单线程快、C 扩展好写,代价就是上面那个 0.83 s。
GIL 也不是被一个线程永远霸占。别的线程等锁超过切换间隔(sys.getswitchinterval(),默认 0.005 s)后会请求释放,持有者在下一个检查点(循环回跳、函数调用入口等位置)放手,再由操作系统决定谁拿到。所以线程之间是抢占式的:你的代码在哪两行之间被切走,你控制不了。这一点到讲锁的时候会咬人。
进程为什么能绕过去?每个子进程是一个独立的解释器,有自己的内存、自己的 GIL,4 把锁互不相干,4 个核才能真的同时算。3.13 起 CPython 还提供去掉 GIL 的 free-threaded 构建(可执行文件名带 t,如 python3.13t),但默认构建仍然有 GIL。
在 8 核机器上,用 8 个线程各跑一份纯 Python 的数值累加循环,总耗时最可能是?
GIL 每个进程只有一把,纯字节码的线程只能轮流执行,总计算量不变,耗时接近串行。
GIL 没有"两个线程"之类的配额,就是互斥;线程也可以正常创建,只是不能并行执行字节码。
GIL 什么时候会松手
如果 GIL 让线程彻底没用,threading 早就被废掉了。关键在于 GIL 只管”执行字节码”,有两种情况线程会主动把它让出来。
第一种是阻塞 I/O。time.sleep、socket 读写、文件读写在进入系统调用前释放 GIL,返回后再抢回来。等待期间别的线程可以跑。10 个任务各等 0.5 s(模拟网络请求):
串行 5.04 s
10 线程 0.51 s
asyncio 0.50 s10 次等待被重叠起来,总耗时约等于一次等待。asyncio 那一行后面再讲。
第二种是 C 扩展。C 代码在不碰 Python 对象的计算期间可以主动释放 GIL(C API 里的 Py_BEGIN_ALLOW_THREADS)。hashlib 对较大输入会这样做,numpy 的多数逐元素运算也会,PyTorch 算子的计算全在 C++/CUDA 里,同理。对 100 MB 的 bytes 做 4 次 sha256:
sha 串行 0.24 s
sha 4线程 0.06 s这次线程真的并行了,4 倍。所以准确的说法不是”Python 线程不能并行”,而是”Python 线程不能并行执行字节码”。一个训练脚本的主线程发起大矩阵乘法后,几乎不占 GIL,这也是为什么 numpy/torch 程序里线程常常够用。
下面哪些工作放进多个线程里,可能得到明显的多核加速?
hashlib 和 numpy ufunc 在 C 代码里计算,计算期间释放 GIL,多个线程可以同时占用多个核。
后两个都是纯 Python 字节码,执行时必须持有 GIL,只能轮流,所以和串行差不多。
GIL 不等于线程安全
既然同一时刻只有一个线程执行字节码,是不是共享变量就不用加锁了?这是最常见的误解。GIL 保证的是解释器内部数据结构不乱(比如引用计数不会算错),它不保证你的多步操作不被打断。
读-改-写 的机制在 并发与同步 里讲过:线程 A 读到 5,还没写回就被切走;线程 B 也读到 5,写回 6;A 回来也写回 6,两次 +1 只算了一次。在 Python 里复现它:
import sys, threading
counter = 0
lock = threading.Lock()
def increment(x):
return x + 1 # 函数入口是 GIL 的检查点,读和写之间因此可能被切走
def unsafe():
global counter
for _ in range(100_000):
tmp = counter
tmp = increment(tmp)
counter = tmp
def safe():
global counter
for _ in range(100_000):
with lock: # 退出 with 时一定释放,包括抛异常的情况
tmp = counter
tmp = increment(tmp)
counter = tmp
sys.setswitchinterval(1e-6) # 把切换间隔调到极小,让问题稳定复现
# 每种各开 4 个线程运行,期望 400000无锁 第1次: 183242
无锁 第2次: 165816
无锁 第3次: 187185
加锁: 400000丢了一大半,而且每次都不一样。不调切换间隔、用默认 5 ms 再跑一次,三轮得到 400000、349438、400000:bug 照样在,只是变得偶发。这类 bug 最难缠的地方就在这里,测试大概率是绿的,线上某天突然算错。
Python 里用锁的几条规矩:
- 用
with lock:,不手写acquire()/release()。中间抛异常时手写版本会忘了释放,其他线程就永远等下去。 - 不要依赖”某条语句恰好是原子的”。把上面的
increment(tmp)换回tmp + 1,3.12 上常常碰巧正确,但这取决于解释器在哪几条字节码之间检查切换,属于实现细节,换版本或换 free-threaded 构建就可能失效。共享可变状态一律加锁。 - 同一线程重复获取
Lock会锁死自己。方法之间互相调用、都要拿同一把锁时,用可重入的threading.RLock。 - 临界区要短。锁里做 I/O 或长计算,别的线程全在排队,等于退回串行。
- 线程之间传数据,优先用
queue.Queue。它内部已经用锁和条件变量实现了线程安全的生产者-消费者队列,maxsize还能提供背压:队列满时put阻塞,生产太快会被拖慢而不是撑爆内存。很多时候用了队列,就不需要自己的锁了。
死锁、条件变量、信号量这些通用机制属于 并发与同步,Python 的 threading 模块只是把它们原样提供出来。
4 个线程各执行 10 万次 tmp = counter; tmp = f(tmp); counter = tmp,不加锁。结果最可能是?
GIL 只保证单条字节码执行时不被打断,读和写之间仍可能切到别的线程,别人的 +1 被这次写回覆盖。
覆盖只会丢失更新,不会多加;CPython 也不检测这类数据竞争,结果是静默变少。
进程能并行,但要付搬家费
回到第一节:CPU 密集就换进程。代价有两项。
一是启动成本。macOS 和 Windows 默认用 spawn 启动子进程:启动一个全新的解释器、重新 import 主模块,再把要运行的函数和参数传过去。Linux 在 3.14 之前默认用 fork,直接复制父进程地址空间,快得多(fork 的机制见 进程异常与信号)。spawn 会重新 import 主模块,所以创建进程池的代码必须放在 if __name__ == "__main__": 里,否则子进程 import 时又会去创建进程,Python 会报 RuntimeError 提示你漏了这句;worker 函数也必须定义在模块顶层,子进程才能按名字找到它。
二是序列化成本。进程之间不共享 Python 对象,参数和返回值都要 pickle 编码、经管道传输、再解码。一个几乎不计算的函数,传入 400 MB 的数组,本进程直接调用约 1 微秒,交给(已启动的)子进程要约 0.70 s,时间几乎全花在搬数据上。所以 multiprocessing 适合”输入小、算得久”的任务;数据大时用 multiprocessing.shared_memory,或只传文件路径让子进程自己读。
fork 还有个陷阱:它只复制调用 fork() 的那一个线程,但别的线程持有的锁的状态被原样复制了。如果 fork 时恰好有线程拿着某把锁(logging 的锁、malloc 内部的锁),子进程里这把锁永远是”已锁”,没人会释放,子进程就莫名挂住。3.12 起 Python 在多线程进程里调用 fork 会发 DeprecationWarning。CUDA 同理:父进程初始化过 CUDA 之后 fork,子进程一用 GPU 就报 Cannot re-initialize CUDA in forked subprocess,要改用 spawn。
PyTorch 的 DataLoader(num_workers=4) 就是这些取舍的产物:图片解码、分词、数据增强大多是 Python 代码,CPU 密集,所以用 4 个 worker 进程而不是线程;产出的 tensor 经共享内存交回主进程,绕开 pickle 拷贝。
把一个 2 GB 的数组交给 ProcessPoolExecutor 的子进程,子进程只求一次和就返回。最可能的结果是?
求和本身很快,而 2 GB 数据要序列化、传输、反序列化,开销远大于计算,正文 400 MB 就要约 0.70 s。
独立 GIL 只在计算量够大时才划算;普通进程池不会共享父进程的对象;numpy 数组可以 pickle。
asyncio:一个线程管上千个等待
对 I/O 密集,线程已经够快了,为什么还要 asyncio?想象一个 LLM API server:几千个连接同时挂着,每个大部分时间在等下一个 token。每个连接一个线程,栈内存和切换开销随连接数涨;每个连接一个协程,只是一个小对象,一个线程能管成千上万个。
协程是 async def 定义的函数,调用它只得到一个可暂停、可恢复的对象(它建立在生成器的暂停机制之上,见 迭代器生成器与解释器)。事件循环是单线程的调度器,轮流推进一堆协程。await x 的意思是”我要等 x,先把控制权交还给循环”,循环趁机推进别的协程。切换只发生在 await 处,协程自己决定何时让出,所以叫协作式调度。这带来一个好处:两个 await 之间的代码不会被别的协程插入,单线程 asyncio 里大多数共享状态不需要锁(跨越 await 的读-改-写仍然会交错)。
也带来一个坑:协程里只要调用一个不经过 await 的阻塞函数,唯一的线程就被占住,整个循环停摆。一个每 0.25 s 打一次心跳的协程,和一个要等 1 s 的处理函数并发:
async def bad():
time.sleep(1) # 同步阻塞,事件循环卡死
async def good():
await asyncio.to_thread(time.sleep, 1) # 阻塞调用丢进线程,循环照常调度--- 阻塞 ---
[0.00s] heartbeat
[0.00s] bad 开始 time.sleep(1)
[1.01s] bad 结束
[1.01s] heartbeat
[1.26s] heartbeat
--- to_thread ---
[0.00s] heartbeat
[0.00s] good 开始 to_thread
[0.25s] heartbeat
[0.50s] heartbeat
[0.75s] heartbeat
[1.00s] heartbeat
[1.01s] good 结束阻塞版本心跳停了整整 1 秒。所以 async 代码里要用 asyncio.sleep 而不是 time.sleep,用异步 HTTP 客户端而不是同步的 requests,绕不开的同步库就 asyncio.to_thread。
另一个常见误会:两行 await job() 是串行的,await 会等它结束才往下走。要并发,得用 asyncio.gather(job1(), job2()),或先 asyncio.create_task(...) 把协程交给循环再 await;3.11 起的 asyncio.TaskGroup 还会在一个任务失败时取消同组其他任务。超时用 asyncio.wait_for 或 async with asyncio.timeout(...),它们的实现是在任务当前停住的 await 处抛 CancelledError,所以清理代码放 finally,并且不要用裸 except: 把这个异常吞掉。
vLLM 的 OpenAI 兼容 server 正是这么分工的:API 层跑在 asyncio 上收请求、流式返回 token,模型计算交给独立的引擎进程,等待和计算各归各位(见 推理系统)。
asyncio 程序里某个协程调用了同步的 requests.get(url),耗时 2 秒。这期间其他协程会怎样?
事件循环只在 await 处切换,同步调用不经过 await,所有协程都要等它返回。
释放 GIL 只能让其他线程运行,而这些协程都在同一个线程上;asyncio 不会识别阻塞调用,也不会因此取消任务。
怎么选
把前面四节压成一张表:
| 任务把时间花在哪 | 选择 | 原因 |
|---|---|---|
| 纯 Python 计算 | multiprocessing / ProcessPoolExecutor | 每个进程独立 GIL;输入数据要小 |
| numpy / torch 等 C 扩展的大计算 | 线程,或库自带的多线程 | 计算期间释放 GIL,不必付进程的搬家费 |
| 少量 I/O,或调用只有同步接口的库 | threading / ThreadPoolExecutor | 阻塞 I/O 释放 GIL,改动最小 |
| 大量并发连接、长连接 | asyncio | 每个等待只是一个协程对象 |
ThreadPoolExecutor 和 ProcessPoolExecutor 接口完全一样,换一个类名就能在两者之间切换,这让”先试一种、测一下、不行再换”变得很便宜。
但这张表的第一列有个前提:你得知道时间花在哪。下面这件事比选哪种并发更常决定成败。
先测量:程序到底慢在哪
一个程序分三步:生成 5 万条记录、找出不同的用户、算平均分。哪步最慢?凭直觉很多人会猜”生成记录”,因为那里调用了随机数。别猜,量。
计时用 time.perf_counter(),它是单调的高精度时钟;time.time() 是墙上时钟,系统校时会让它跳变。单次计时抖动大(上面第一节的数字,同一脚本第一次运行得到串行 2.18 s、4 线程 1.22 s,后两次才稳定到 0.9 s 左右,机器上别的负载就能造成这种偏差),比较小段代码用 timeit.repeat:多组重复,看最小值,因为噪声只会让程序变慢,最小值最接近代码本身的成本。
整个程序就交给 profiler。标准库的 cProfile 是确定性 profiler:在每次函数调用和返回时记时间戳,所以调用次数和耗时都是精确值。
def unique_users(records):
seen = []
for r in records:
if r["user"] not in seen: # list 的 in 是线性扫描
seen.append(r["user"])
return seen
p = cProfile.Profile(); p.enable(); main(); p.disable()
pstats.Stats(p).strip_dirs().sort_stats("cumulative").print_stats(6) 386398 function calls in 1.124 seconds
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 1.124 1.124 prof.py:18(main)
1 1.047 1.047 1.048 1.048 prof.py:5(unique_users)
1 0.021 0.021 0.071 0.071 prof.py:2(load_records)
50000 0.015 0.000 0.046 0.000 random.py:291(randrange)tottime 是函数自身代码的时间,cumtime 还包含所有子调用。main 的 cumtime 1.124 s、tottime 0,它只是在调别人;unique_users 自身就占了 1.047 s,约 93%。被怀疑的 load_records 只有 0.071 s。不改代码也能用:python -m cProfile -s cumulative script.py。
cProfile 的粒度只到函数。函数很长时,要知道是哪一行,用第三方的 line_profiler:给函数加 @profile,用 kernprof -l -v script.py 运行,它对这个函数逐行计时:
Line # Hits Time Per Hit % Time Line Contents
==============================================================
9 50001 10151.0 0.2 0.9 for r in records:
10 50000 11554.0 0.2 1.0 user = r["user"]
11 50000 1104118.0 22.1 98.0 if user not in seen:
12 4999 1137.0 0.2 0.1 seen.append(user)Time 的单位是微秒。第 11 行和第 10 行执行次数一样,但每次 22.1 微秒对 0.2 微秒,差别在每次的成本,证据直接指向 list 的线性查找:seen 长到 5000 个元素,每次 in 都要从头比。换成 set(哈希查找平均 O(1),见 数据结构):
不开 profiler: 1.02 s
换成 set: 0.031 s快了 30 多倍,改动只有一行。注意 profiler 自身有开销:不开时 1.02 s,cProfile 下 1.12 s;line_profiler 每行都要记录,开销更大,所以看它的相对比例,别把绝对时间当真。
前两种工具都要在程序里启用。对已经在跑的训练任务或线上服务,用 py-spy:它是采样式 profiler,从进程外按固定频率(默认每秒 100 次)读目标进程的调用栈,某函数出现在 30% 的样本里,就估计它占 30% 的时间。目标不用改代码、基本不被拖慢:
py-spy record -o profile.svg --pid 12345 # 采样并生成火焰图
py-spy top --pid 12345 # 实时显示最热的函数
py-spy dump --pid 12345 # 打印此刻各线程调用栈,查卡死火焰图里每个横条是一个函数,宽度是它在样本中出现的比例,最宽的就是热点。macOS 上读别的进程内存需要 root(不加 sudo 会直接提示 This program requires root on OSX);Linux 上取决于 ptrace 权限,Docker 里要给容器加 SYS_PTRACE。py-spy dump 还能回答”程序卡住了,停在哪一行”,这对排查上面讲的 fork 死锁很有用。
找到热点之后,优化的顺序通常是:先换算法,再换数据结构(刚才的 list 换 set),再用 numpy 向量化,最后才是换实现或写 C 扩展。并发排在很后面:上面的程序开多少进程都比不上把一个 list 换成 set。占 90% 时间的函数快一倍,比占 1% 的函数快十倍有用得多,这就是 Amdahl 定律的直观版本,整体加速受限于没被优化的部分。GPU 上计时还有一层坑:CUDA kernel 是异步发射的,计时前后要 torch.cuda.synchronize(),见 GPU 与 Kernel。
pstats 里函数 A 的 cumtime 很大、tottime 接近 0,最合理的解读是?
cumtime 包含子调用,tottime 只算自身,大而近零说明 A 只是调度者,就像正文里的 main。
优化 A 自身几乎没收益;两者相差很大完全正常;调用次数要看 ncalls 列,和这两列无关。
关于 cProfile、line_profiler 和 py-spy,哪些说法正确?
cProfile 是确定性 profiler,每次调用都记,所以有开销;line_profiler 逐行计时,范围限定在装饰过的函数。
py-spy 在进程外按频率采样,结果是比例估计,所以几乎不拖慢目标;精确计数的是 cProfile,最后一项说反了。
记住这三件事
- GIL 让一个进程里同一时刻只有一个线程执行字节码:纯 Python 计算用进程,I/O 和释放 GIL 的 C 扩展计算用线程,海量并发等待用 asyncio。
- GIL 不是线程安全的保证:共享可变状态用
with lock:,线程间传数据用queue.Queue;协程里不能调用阻塞函数。 - 先用 cProfile 找函数、line_profiler 找行、py-spy 看线上进程,再优化;换个数据结构往往比加并发有用得多。