你有一段纯 Python 的计算要跑 4 遍,机器有 8 个核,于是开了 4 个线程。结果总耗时和串行几乎一样。换成 4 个进程,快了 3 倍;可如果任务是下载 10 个网页,线程又突然快了 10 倍。这一篇讲清楚这三个现象背后的同一个原因,再讲怎么在动手优化之前,先找到程序真正慢的地方。

前置:并发与同步、Python 对象模型、迭代器生成器与解释器

下文数字都在 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 让线程彻底没用,threading 早就被废掉了。关键在于 GIL 只管”执行字节码”,有两种情况线程会主动把它让出来。

第一种是阻塞 I/O。time.sleep、socket 读写、文件读写在进入系统调用前释放 GIL,返回后再抢回来。等待期间别的线程可以跑。10 个任务各等 0.5 s(模拟网络请求):

串行         5.04 s
10 线程      0.51 s
asyncio     0.50 s

10 次等待被重叠起来,总耗时约等于一次等待。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 程序里线程常常够用。

停一下,检查理解多选

下面哪些工作放进多个线程里,可能得到明显的多核加速?

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,不加锁。结果最可能是?

进程能并行,但要付搬家费

回到第一节: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 的子进程,子进程只求一次和就返回。最可能的结果是?

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 秒。这期间其他协程会怎样?

怎么选

把前面四节压成一张表:

任务把时间花在哪选择原因
纯 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,最合理的解读是?

停一下,检查理解多选

关于 cProfile、line_profiler 和 py-spy,哪些说法正确?

记住这三件事

  1. GIL 让一个进程里同一时刻只有一个线程执行字节码:纯 Python 计算用进程,I/O 和释放 GIL 的 C 扩展计算用线程,海量并发等待用 asyncio。
  2. GIL 不是线程安全的保证:共享可变状态用 with lock:,线程间传数据用 queue.Queue;协程里不能调用阻塞函数。
  3. 先用 cProfile 找函数、line_profiler 找行、py-spy 看线上进程,再优化;换个数据结构往往比加并发有用得多。

延伸阅读