mobile wallpaper 1
mobile wallpaper 2
mobile wallpaper 3
mobile wallpaper 4
3067 字
8 分钟
GIL 详解:Python 多线程为什么快不起来
2026-08-26

CPU 密集任务在 Python 里开多线程,不会变快,有时还更慢。这不是玄学,是 GIL(Global Interpreter Lock,全局解释器锁)在起作用。

我之前写一个批量数据处理脚本时就踩过:单线程跑要一分多钟,心想 8 核机器开 4 个线程总能快点,结果耗时几乎没变,还略涨了一点。排查一圈代码没问题,问题出在对 GIL 的理解上。

一次失败的多线程优化#

当时的脚本本质上是纯计算:循环里做数值累加。简化后的基准代码如下,可以直接跑:

gil_bench.py
import time
from 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.84s1.00x
4 线程1.89s1.03x(更慢)
4 进程0.57s0.31x(快 3 倍多)

4 个线程把任务切成 4 份并行,总耗时和单线程跑整份几乎一样,还多付了线程切换的开销。4 个进程则真正吃到了多核。

同样是「并行」,线程和进程的表现完全相反,差异就来自 GIL。

GIL 锁住的到底是什么#

GIL 是 CPython(官方 Python 解释器)里的一把全局互斥锁:同一时刻,同一个进程里只允许一个线程执行 Python 字节码

注意它锁的不是「你的数据」,而是「解释器本身」。哪怕你的代码里没有任何共享变量、完全不需要加锁,GIL 依然在每个线程执行字节码之前生效。

graph TD T1[线程 1] -->|抢到 GIL| GIL{{GIL}} T2[线程 2] -->|等待| GIL T3[线程 3] -->|等待| GIL GIL -->|同一时刻只放行一个| E[执行 Python 字节码]

所以上面的基准里,4 个线程并没有真正并行执行 total += i * i,而是在 4 个核之间轮流抢一把锁,串行地执行各自的字节码。切片的总工作量没变,耗时自然不会降,还多出了抢锁和上下文切换的成本。

CPython 为什么要有 GIL#

GIL 不是设计失误,而是一个历史包袱很重的工程取舍。

CPython 用引用计数管理内存:每个对象记录有多少引用指向自己,归零就释放。如果没有 GIL,两个线程同时增减同一个对象的引用计数,就会出现竞态——要么计错数导致内存泄漏,要么把还在用的对象提前释放。

给每个对象的引用计数都加锁,正确但慢得没法接受。GIL 用一把大锁换来了两个好处:

  1. 单线程快。解释器内部不用为每个对象操作加解锁,单线程路径非常干净。
  2. 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-0
w0 处理 task-2
w0 处理 task-3
w1 处理 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 parent

Pipe 比 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) # 400000

counter.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 传 100MB1.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 慢几个数量级。适合低频的配置共享、状态汇总,别放热路径上。

多进程通信的几个坑#

这一节都是从官方文档和常见事故里整理的,提前知道能省一下午:

  1. Queue 的经典死锁:子进程往 Queue 里 put 了一个很大的对象,父进程却先 join() 等子进程退出再 get()。管道缓冲区写满后子进程阻塞在退出阶段,父进程在等子进程退出——互相等,程序挂死。官方文档明确警告过这个顺序问题:先取数据,再 join
  2. 共享内存不加锁等于没共享:前面 146401 那个数字就是下场。Value/Array 可以用 get_lock() 拿内置锁,shared_memory 则要自己配 Lock
  3. shared_memory 用完要 unlink():它落在 /dev/shm(Linux)上,进程崩溃没清理的话会一直占着内存。
  4. 别把 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%。

注意三点:

  1. 默认构建仍然带 GIL,free-threaded 是可选变体,预计短期内不会成为默认。
  2. C 扩展需要适配,装了没适配的扩展会在运行时自动把 GIL 加回来(可用 -X gil=0 或环境变量 PYTHON_GIL=0 强制关闭,后果自负)。
  3. 可以用 sys._is_gil_enabled() 在运行时确认当前 GIL 是否真的关着。

如果今天要在生产里用,我会再等等生态;但个人项目、内部批处理任务已经值得试试。

怎么判断自己的程序受不受 GIL 限制#

收尾给一个简单的判断路径:

graph TD A[程序慢] --> B{瓶颈在 CPU 还是 IO?} B -->|IO: 等网络/磁盘/数据库| C[多线程或 asyncio 直接有效<br/>GIL 不挡路] B -->|CPU| D{计算能否交给 C 层?<br/>NumPy 向量化 / C 扩展} D -->|能| E[库内部释放 GIL<br/>多线程有效] D -->|不能| F[ProcessPoolExecutor 多进程<br/>按数据量选 Queue / 共享内存<br/>或评估 free-threaded 构建]

一句话版本:先用 cProfile 或 py-spy 确认热点在 Python 层的计算循环,再考虑动并发方案。瓶颈都没找准就开线程,就是我开头那次失败优化的翻版。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

GIL 详解:Python 多线程为什么快不起来
https://l1ngg.info/posts/tech/python-gil/
作者
L1ngg
发布于
2026-08-26
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录