CPU 密集任务在 Python 里开多线程,不会变快,有时还更慢。这不是玄学,是 GIL(Global Interpreter Lock,全局解释器锁)在起作用。
我之前写一个批量数据处理脚本时就踩过:单线程跑要一分多钟,心想 8 核机器开 4 个线程总能快点,结果耗时几乎没变,还略涨了一点。排查一圈代码没问题,问题出在对 GIL 的理解上。
一次失败的多线程优化
当时的脚本本质上是纯计算:循环里做数值累加。简化后的基准代码如下,可以直接跑:
import timefrom concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
N = 30_000_000
def cpu_work(n: int) -> int: total = 0 for i in range(n): total += i * i return total
def timed(fn, *args): start = time.perf_counter() fn(*args) return time.perf_counter() - start
def single(): cpu_work(N)
def threads(k: int): chunk = N // k with ThreadPoolExecutor(max_workers=k) as pool: list(pool.map(cpu_work, [chunk] * k))
def processes(k: int): chunk = N // k with ProcessPoolExecutor(max_workers=k) as pool: list(pool.map(cpu_work, [chunk] * k))
if __name__ == "__main__": t1 = timed(single) t4 = timed(threads, 4) t4p = timed(processes, 4) print(f"单线程: {t1:.2f}s") print(f"4 线程: {t4:.2f}s") print(f"4 进程: {t4p:.2f}s")实测结果(Python 3.12.13,8 核 Linux):
| 方案 | 耗时 | 相对单线程 |
|---|---|---|
| 单线程 | 1.84s | 1.00x |
| 4 线程 | 1.89s | 1.03x(更慢) |
| 4 进程 | 0.57s | 0.31x(快 3 倍多) |
4 个线程把任务切成 4 份并行,总耗时和单线程跑整份几乎一样,还多付了线程切换的开销。4 个进程则真正吃到了多核。
同样是「并行」,线程和进程的表现完全相反,差异就来自 GIL。
GIL 锁住的到底是什么
GIL 是 CPython(官方 Python 解释器)里的一把全局互斥锁:同一时刻,同一个进程里只允许一个线程执行 Python 字节码。
注意它锁的不是「你的数据」,而是「解释器本身」。哪怕你的代码里没有任何共享变量、完全不需要加锁,GIL 依然在每个线程执行字节码之前生效。
所以上面的基准里,4 个线程并没有真正并行执行 total += i * i,而是在 4 个核之间轮流抢一把锁,串行地执行各自的字节码。切片的总工作量没变,耗时自然不会降,还多出了抢锁和上下文切换的成本。
CPython 为什么要有 GIL
GIL 不是设计失误,而是一个历史包袱很重的工程取舍。
CPython 用引用计数管理内存:每个对象记录有多少引用指向自己,归零就释放。如果没有 GIL,两个线程同时增减同一个对象的引用计数,就会出现竞态——要么计错数导致内存泄漏,要么把还在用的对象提前释放。
给每个对象的引用计数都加锁,正确但慢得没法接受。GIL 用一把大锁换来了两个好处:
- 单线程快。解释器内部不用为每个对象操作加解锁,单线程路径非常干净。
- C 扩展好写。扩展作者默认自己独占解释器,不用考虑线程安全。NumPy、Pandas 这套生态能长起来,GIL 的简单模型功不可没。
代价就是今天看到的:多线程无法并行执行字节码。
GIL 会放手的时刻
GIL 并不是全程焊死的。它在几个时刻会主动释放,这决定了多线程什么时候有用:
- IO 阻塞时。线程发起网络请求、读写文件、查数据库,进入系统调用前会释放 GIL,其他线程可以趁机跑。
time.sleep()等主动等待。本质是告诉解释器「我要等,锁给别人」。- 进入释放 GIL 的 C 扩展时。NumPy 的重计算、Pillow 的图像处理,在 C 层干活时不持有 GIL,多线程可以并行。
所以 IO 密集任务用多线程依然有效。同一台机器上的对照实验:4 次 time.sleep(0.5) 串行要 2.00s,4 个线程只要 0.50s,接近理想的 4 倍。
判断标准可以简化成一句话:线程在等待,GIL 就放行;线程在计算,GIL 就挡路。
实际场景里 GIL 挡不挡路
回到工程上,GIL 的影响完全取决于任务的瓶颈在哪:
| 场景 | 瓶颈 | GIL 影响 | 合适的并发方案 |
|---|---|---|---|
| Web 后端、API 服务 | 数据库、下游 RPC | 很小,请求大部分时间在等 IO | 多线程 / asyncio 都可以 |
| 爬虫、批量调用 LLM API | 网络延迟 | 很小 | 多线程或 asyncio,轻松几十并发 |
| 数据清洗、批处理脚本 | CPU 计算 | 直接挡死 | multiprocessing,或换 NumPy 向量化 |
| 科学计算、图像处理 | CPU 计算 | 视库而定 | NumPy / Pillow 等在 C 层释放 GIL,多线程有效 |
| 训练、推理 | GPU / C 内核 | 基本无关 | 框架内部已绕开 |
两个容易误判的点:
「我的 Web 服务是 Python 写的,是不是天然慢?」 大部分 Web 请求的时间花在等数据库和下游服务上,GIL 根本不参与。真正受影响的场景是接口里有重计算(比如在请求里做压缩、加解密大文件、跑正则批处理),这时单 worker 的多线程会被 GIL 卡住,表现为 CPU 一个核打满、其他核闲置。生产部署用 Gunicorn / Uvicorn 起多个 worker 进程,本身就是在绕开 GIL。
「调了 NumPy 是不是就没事了?」 向量化操作在 C 层执行且释放 GIL,确实不受限。但如果你写的是 for 循环逐元素处理 Python 对象,那每一步都在解释器里,GIL 照卡不误。瓶颈在「Python 层的循环」还是「C 层的批量运算」,是两回事。
绕开 GIL 的几条路
按改造成本从低到高:
1. 把计算交给释放 GIL 的库。 能用 NumPy 向量化就别写 Python 循环,往往比任何并发方案都快。
2. 多进程 multiprocessing / ProcessPoolExecutor。 每个进程有独立的解释器和 GIL,真并行。代价是进程间内存不共享,数据要靠序列化传递,适合任务粒度大、传输数据小的场景。开头基准里的 0.57s 就是这条路。
3. asyncio。 只解决 IO 密集,且要求全链路用异步库。和多线程一样救不了 CPU 密集,但单线程内切换比线程更轻,适合超高并发的 IO 场景(比如一次发几千个 API 请求)。
4. 换解释器或新构建。 PyPy、Jython 没有传统意义的 GIL(但有各自的问题和兼容性代价);更现实的是 free-threaded CPython,后文细说。
选了多进程,下一个问题马上就来:进程之间怎么交换数据?
多进程的代价:进程间内存不共享
线程共享同一份进程内存,一个线程改了全局变量,其他线程立刻可见。进程不是这样:每个进程有独立的解释器和独立的内存空间,你在子进程里改一个全局变量,父进程那边毫无反应。
所以多进程并行的每一步数据交换,都得走显式的进程间通信(IPC)。multiprocessing 给了四种主要方式,下面按常用程度一个个过,所有示例都在 Python 3.12 上实际跑过。
Queue:最常用的生产者-消费者管道
multiprocessing.Queue 是进程安全的队列,支持多生产者、多消费者,是绝大多数场景的首选:
import multiprocessing as mp
def worker(q, name): while True: item = q.get() if item is None: # 毒丸:通知 worker 退出 break print(f"{name} 处理 {item}")
if __name__ == "__main__": q = mp.Queue() workers = [mp.Process(target=worker, args=(q, f"w{i}")) for i in range(2)] for w in workers: w.start()
for i in range(4): q.put(f"task-{i}") for _ in workers: q.put(None) # 每个 worker 发一颗毒丸
for w in workers: w.join()输出类似(顺序不保证,这正是并发的样子):
w0 处理 task-0w0 处理 task-2w0 处理 task-3w1 处理 task-1要注意:放进 Queue 的对象会被 pickle 序列化,通过管道传到对端再反序列化。传小消息很便宜,传大对象就要掂量了——后面有实测。
Pipe:点对点的双向通道
Pipe() 返回一对连接对象,默认双向(duplex),适合恰好两个进程对话的场景:
import multiprocessing as mp
def child(conn): conn.send("ping from child") print("子进程收到:", conn.recv()) conn.close()
if __name__ == "__main__": parent, child_conn = mp.Pipe() p = mp.Process(target=child, args=(child_conn,)) p.start() print("父进程收到:", parent.recv()) parent.send("pong from parent") p.join()父进程收到: ping from child子进程收到: pong from parentPipe 比 Queue 轻量(少了一层队列管理),但只服务两个端点,且两个进程同时写同一端会损坏消息。需要多对多就回 Queue。
共享内存:绕过序列化的大开销
Queue 和 Pipe 都是「复制数据」,共享内存是「让两个进程看同一块内存」,省掉了序列化和拷贝。
小块数据用 Value / Array:
import multiprocessing as mp
def add(counter, lock, n): for _ in range(n): with lock: # 去掉锁就是竞态,下面有实测 counter.value += 1
if __name__ == "__main__": counter = mp.Value("i", 0) # 共享的 int lock = mp.Lock() ps = [mp.Process(target=add, args=(counter, lock, 100_000)) for _ in range(4)] for p in ps: p.start() for p in ps: p.join() print(counter.value) # 400000counter.value += 1 是读-改-写三步,不是原子操作。我把 with lock 去掉跑了一遍:4 个进程各加 10 万次,结果只有 146401,一大半加法被互相覆盖。共享内存不会帮你解决并发安全,锁还是得自己加。
大块数据用 shared_memory(Python 3.8+):
from multiprocessing import Process, shared_memory
def worker(name): shm = shared_memory.SharedMemory(name=name) # 按名字挂到同一块内存 shm.buf[:5] = b"WORLD" shm.close()
if __name__ == "__main__": shm = shared_memory.SharedMemory(create=True, size=16) shm.buf[:5] = b"HELLO" p = Process(target=worker, args=(shm.name,)) p.start() p.join() print(bytes(shm.buf[:11])) # b'WORLD\x00\x00\x00\x00\x00\x00' shm.close() shm.unlink() # 释放,否则会残留在 /dev/shm差距有多大?我在同一台机器上实测传 100MB 数据:
| 方式 | 耗时 |
|---|---|
| Queue 传 100MB | 1.41s |
| shared_memory 挂接(只传名字) | 0.02s |
约 70 倍的差距,而且数据越大差距越夸张。数据处理管道里要在进程间传大数组、大 DataFrame 时,共享内存几乎是唯一合理的答案(配 NumPy 时可以直接 np.frombuffer 挂上去,零拷贝)。
Manager:能共享 dict,但慢
Manager() 起一个服务进程,返回 dict、list 等对象的代理,操作起来像普通容器:
import multiprocessing as mp
def worker(d, key): d[key] = key * key
if __name__ == "__main__": with mp.Manager() as m: d = m.dict() ps = [mp.Process(target=worker, args=(d, i)) for i in range(4)] for p in ps: p.start() for p in ps: p.join() print(dict(d)) # {0: 0, 1: 1, 2: 4, 3: 9}好用,但每次读写都是一次到服务进程的远程调用,比直接操作本地 dict 慢几个数量级。适合低频的配置共享、状态汇总,别放热路径上。
多进程通信的几个坑
这一节都是从官方文档和常见事故里整理的,提前知道能省一下午:
- Queue 的经典死锁:子进程往 Queue 里
put了一个很大的对象,父进程却先join()等子进程退出再get()。管道缓冲区写满后子进程阻塞在退出阶段,父进程在等子进程退出——互相等,程序挂死。官方文档明确警告过这个顺序问题:先取数据,再 join。 - 共享内存不加锁等于没共享:前面 146401 那个数字就是下场。
Value/Array可以用get_lock()拿内置锁,shared_memory则要自己配Lock。 shared_memory用完要unlink():它落在/dev/shm(Linux)上,进程崩溃没清理的话会一直占着内存。- 别把 Queue 当共享内存用:高频传大对象时,pickle 序列化的开销会直接吃掉多进程省下来的时间。通信成本是选型的一部分,不是免费的。
Python 3.13/3.14 的 free-threaded 模式
PEP 703 给 CPython 做了一套「无 GIL」构建,进展分两步:
- Python 3.13:free-threaded 构建首次可用,但是实验性质,需要单独装
python3.13t(或编译时--disable-gil)。 - Python 3.14(2025 年 10 月发布):按 PEP 779 转为官方支持,不再是实验特性。单线程性能开销从 3.13 的明显回退降到约 5–10%。
注意三点:
- 默认构建仍然带 GIL,free-threaded 是可选变体,预计短期内不会成为默认。
- C 扩展需要适配,装了没适配的扩展会在运行时自动把 GIL 加回来(可用
-X gil=0或环境变量PYTHON_GIL=0强制关闭,后果自负)。 - 可以用
sys._is_gil_enabled()在运行时确认当前 GIL 是否真的关着。
如果今天要在生产里用,我会再等等生态;但个人项目、内部批处理任务已经值得试试。
怎么判断自己的程序受不受 GIL 限制
收尾给一个简单的判断路径:
一句话版本:先用 cProfile 或 py-spy 确认热点在 Python 层的计算循环,再考虑动并发方案。瓶颈都没找准就开线程,就是我开头那次失败优化的翻版。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时