先给结论:GIL 是 CPython 解释器里的一把全局锁,它限制的不是“Python 不能并发”,而是“同一个解释器进程在同一时刻只能有一个线程执行 Python 字节码”。所以你在 8 核机器上开 4 个 Python 线程去算 CPU 密集型任务,看到的往往是 CPU 只跑了一个核多一点,甚至 4 线程比单线程更慢。这个问题不是代码写得差,而是 CPython 的内存安全和执行模型决定的。
这篇文章会直接摆现象、拆原因、给代码。你可以照着跑一遍,确认自己手上的任务是该继续用多线程,还是应该换成多进程。重点放在“怎么判断”和“换完之后怎么验证效果”。
1. 直接看现象:4 个线程为什么没跑满 4 个核
1.1 先跑一个最小复现脚本
我建议你直接在本地建一个gil_demo.py,把下面的代码放进去。它做的事情很简单:用一个纯 Python 函数做大量数值循环,分别用单线程和 4 个线程跑同样的总量。
import threading import time def heavy(rounds): total = 0 for i in range(rounds): total += i * i total -= i % 7 return total def run_single(total_rounds): start = time.perf_counter() heavy(total_rounds) print(f"单线程耗时: {time.perf_counter() - start:.3f}s") def run_threads(total_rounds, n=4): threads = [] start = time.perf_counter() part = total_rounds // n for i in range(n): t = threading.Thread(target=heavy, args=(part,)) threads.append(t) t.start() for t in threads: t.join() print(f"{n} 线程耗时: {time.perf_counter() - start:.3f}s") if __name__ == "__main__": run_single(20_000_000) run_threads(20_000_000, 4)这是我跑过多次的经典现象。单线程跑完 2000 万次循环,耗时大约 0.7 到 0.9 秒;你以为 4 个线程并行,能跑到 0.2 秒左右,但结果往往和下面这张表接近。
| 方案 | 耗时 | CPU 核占用情况 | 直观结论 |
|---|---|---|---|
| 单线程 | 0.78s 左右 | 1 个核 | 正常 |
| 4 线程 | 0.86s 左右 | 1 个多一点的核 | 没有跑满 4 核 |
| 4 进程 | 0.21s 左右 | 4 个核 | 接近线性加速 |
不同机器波动会有差异,但趋势非常一致:纯 Python 数值计算,多线程不仅没加速,还因为抢锁开了倒车。
1.2 GIL 限制的是并行执行,不是并发
很多人把 GIL 理解成“Python 多线程是假的,完全不能并发”,这个说法不准确。
准确的理解是:CPython 的解释器在执行 Python 字节码时,会要求当前线程先持有 GIL。这把锁保证同一时刻只有一个线程能运行字节码,所以纯 Python 计算无法在多核 CPU 上并行。但线程并不等于完全死掉,当线程遇到 I/O 等待、sleep、网络请求阻塞时,会主动释放 GIL,让其他线程继续跑。也就是说,Python 多线程做 I/O 密集型任务时依然有并发能力。
我经常用一个比喻来解释:GIL 像公司里唯一一间会议室。有人进去做计算时,其他人只能在门口等;但如果里面的人是在等电话、等快递,他会先出来,让另一个人进去等。
1.3 为什么 CPU 密集多线程反而更慢
多线程跑 CPU 密集任务,本质上是在做“多人轮流抢一把锁”。每个线程拿到 GIL 后执行一小段时间,然后被切走,下一个线程再抢。这个切换不是免费的。
每轮切换至少包括几类成本:
- 线程上下文切换。
- 在等待 GIL 时锁的争抢。
- 调度器相关的开销。
- 不同线程同时运行可能触发缓存失效。
CPython 默认的线程切换间隔可以通过sys.getswitchinterval()查看,通常是 5 毫秒。也就是说,一个线程最多持续持有 GIL 大约 5 毫秒,就会被提醒让出。对纯计算任务来说,这种频繁让出没有意义,只会让总时长增加。
结论先放在这里:如果你的任务是“绝大部分时间在运行 Python 循环和运算”,多线程的方向大概率是错的。
2. GIL 为什么存在:历史包袱还是设计必然
2.1 引用计数是 GIL 出现的重要原因
CPython 的内存管理机制里,每个 Python 对象都有一个引用计数,记录当前有多少个地方引用它。当一个对象的引用计数变成 0,解释器就会立即回收它占用的内存。
这个机制本身很快,而且简单直接。但问题来了:如果两个线程同时操作同一个对象的引用计数,就可能出现计数错乱。最典型的情况是,一个线程正在使用对象 A,另一个线程误把引用计数减到了 0,导致对象被回收,第一个线程就炸了。
为了避免这种竞争,CPython 选择用一把全局锁,让同一时刻只有一个线程执行 Python 字节码。只要字节码执行是串行的,引用计数的增删就不会互相干扰。
你可能会问,为什么不给每个对象单独加锁?理论上可以,但会导致两个问题:锁本身占用大量内存,而且对象之间互相引用时很容易出现死锁。相比来说,GIL 是一种更简单、短期内更不容易出错的方案。
2.2 C 扩展的兼容性也是重要原因
CPython 除了运行 Python 代码,还经常调用 C 扩展库。很多底层库、科学计算库、图像处理库都依赖 GIL 来保证线程安全。
如果直接移除 GIL,这些 C 扩展的线程安全模型可能需要重写。现实中很多第三方库几十年没有按“无 GIL”来设计,这是 Python 官方在推进“自由线程”时非常谨慎的原因。
所以,GIL 不是一个“当初拍脑袋留下的 bug”,而是一个为了内存安全和扩展生态而做的历史设计。当然,它带来的副作用也很明显:Python 纯代码在多核 CPU 上的并行能力被严重限制。
2.3 C 扩展里有一个例外:某些计算会自动释放 GIL
这里有个容易混淆的点。你在 Python 里调用numpy的大矩阵运算时,有时候能看到多核占用升高,但这不表示 GIL 消失了。
原因是许多 C 扩展在执行真正耗时的计算时,会主动释放 GIL。因为这时候 C 代码不碰 Python 对象,不需要锁保护。计算完成之后再重新获取 GIL,把结果转回 Python 对象。
这也是为什么很多实际项目里,多线程跑numpy、pandas、PIL的部分操作时,性能并没有想象中那么差。需要注意:释放 GIL 是第三方扩展自己的行为,不是 Python 字节码自动做到的事情。
3. 多线程真正的价值:I/O 密集场景,GIL 不会拖后腿
3.1 什么是 I/O 密集任务
I/O 密集任务指的是“大量时间花在等待外部输入输出上”的任务。典型特征如下:
- 等待网络接口返回数据。
- 等待数据库查询完成。
- 等待文件读写。
- 等待消息队列消费。
- 等待外部服务响应。
这类任务的特点是:真正消耗 CPU 的时间很短,大部分时间线程都在阻塞等待。在等待期间,线程不使用 CPU,也不需要执行字节码,所以 GIL 的负面影响不明显。
3.2 用一个模拟脚本实测 I/O 场景
我一般不用真实接口做首次测试,因为网络不稳定会影响判断。先用time.sleep模拟一次外部等待,效果更可控。
import threading import time def remote_request(task_id): # 模拟一次网络请求或数据库等待 time.sleep(0.5) return task_id def run_single(total=8): start = time.perf_counter() for i in range(total): remote_request(i) print(f"单线程耗时: {time.perf_counter() - start:.3f}s") def run_threads(total=8, n=4): start = time.perf_counter() threads = [] for i in range(total): t = threading.Thread(target=remote_request, args=(i,)) threads.append(t) t.start() if len(threads) >= n: for t in threads: t.join() threads = [] for t in threads: t.join() print(f"多线程耗时: {time.perf_counter() - start:.3f}s") if __name__ == "__main__": run_single(8) run_threads(8, 4)单线程跑 8 次,每次等待 0.5 秒,总耗时约 4 秒。改成 4 个线程后,4 个等待可以重叠,总耗时明显下降,通常在 1 到 2 秒之间。如果你的任务实际是网络请求,差距会更明显。
3.3 多线程适合的应用场景
Python 多线程在实际项目里不是没有价值,关键是找对场景。安全且常见的用法包括:
- 批量调用第三方 HTTP 接口。
- 并发读取多个文件。
- 并发查询多个数据库表。
- 对接多个消息队列。
- 需要同时监听多个 socket 或端口。
在这些场景里,多线程代码比异步代码更容易理解,也能获得明显收益。我的建议是:先把任务分成“等待型”和“计算型”两类。等待型的优先考虑多线程或异步;计算型的优先考虑多进程或底层库。
4. 需要多核:改用多进程,并把效果测出来
4.1 用 multiprocessing.Pool 直接替换
如果确认任务是 CPU 密集型,正确路线是换多进程。每个进程都有独立的 Python 解释器和独立的内存空间,也各自持有一把 GIL,所以 4 个进程可以在 4 个核上并行跑。
把刚才的例子改成多进程,代码并不复杂:
from multiprocessing import Pool import time def heavy(rounds): total = 0 for i in range(rounds): total += i * i total -= i % 7 return total def run_processes(total_rounds=20_000_000, workers=4): start = time.perf_counter() part = total_rounds // workers with Pool(workers) as pool: result = pool.map(heavy, [part] * workers) print(f"{workers} 进程耗时: {time.perf_counter() - start:.3f}s") print(f"结果: {result}") if __name__ == "__main__": run_processes()你留意到if __name__ == "__main__"了吗?多进程在启动子进程时,会重新导入当前脚本。如果没有这个保护,Windows 环境或 spawn 模式下会无限递归创建子进程。Linux 默认 fork 模式通常不报错,但保持这个写法是稳妥的。
4.2 怎么确认 4 个核真的跑满了
只看耗时还不够,建议同时打开系统监控工具。
- Linux 下用
top或htop,按1查看每个核的占用率。 - macOS 下用
Activity Monitor。 - Windows 下用任务管理器,或者直接在 Python 里用
psutil查。
用psutil的优点是能直接拿到每个核心的占用百分比,方便后续做成性能基线。
import psutil # 每隔 1 秒打印一次每个 CPU 核心的占用 for cpu in psutil.cpu_percent(interval=1, percpu=True): print(f"CPU 核心占用: {cpu}%")多进程运行阶段,你大概率能看到 4 个核心同时接近 100%。这才是“多核并行”的正常表现。
4.3 更现代的方式:ProcessPoolExecutor
除了multiprocessing.Pool,concurrent.futures.ProcessPoolExecutor也是常见选择。它的接口更统一,如果你以后想在线程池和进程池之间切换,只需要改一行导入。
from concurrent.futures import ProcessPoolExecutor import time def heavy(rounds): total = 0 for i in range(rounds): total += i * i total -= i % 7 return total def run_processes(total_rounds=20_000_000, workers=4): start = time.perf_counter() part = total_rounds // workers with ProcessPoolExecutor(max_workers=workers) as executor: results = list(executor.map(heavy, [part] * workers)) print(f"{workers} 进程耗时: {time.perf_counter() - start:.3f}s") print(f"结果: {results}") if __name__ == "__main__": run_processes()executor.map会自动帮你把任务分配到进程池。这里有一个容易被忽略的点:返回值必须能被 pickle 序列化。如果你的任务返回的是 lambda 函数、本地类实例或其他不能序列化的对象,就必须先做一次转换,否则会报错。
4.4 多进程的注意事项
多进程不是简单的“把 Thread 换成 Process”就完事了,有几个点必须提前想清楚。
第一,主进程和子进程之间不能直接共享普通 Python 变量。它们的内存空间是隔离的,需要通过multiprocessing.Queue、Pipe、Manager或共享内存来传递数据。
第二,进程启动时间比线程长。线程创建成本低,进程创建需要加载一个新的解释器,可能还需要重新导入依赖包。因此不要在一个循环里反复创建和销毁进程。正确做法是提前创建进程池或执行器,复用里面的 worker。
第三,注意任务粒度。如果单个任务本身只需要几毫秒,而你把上万个这样的小任务丢给进程池,进程通信和序列化的开销会超过计算本身,最终反而更慢。遇到这种情况,尽量在任务内部做合并,比如一次处理一批数据。
第四,multiprocessing在不同平台的默认启动方式不同。Linux 默认是 fork 模式,Windows 和 macOS 默认是 spawn 模式,Python 3.14 起 spawn 会进一步成为更多平台的默认选项。你写代码时不要依赖 fork 独有的行为,尽量保证代码同时兼容两种模式。
5. 多进程不能无脑上:开销、序列化和数据同步
5.1 进程创建成本比线程高一到两个数量级
线程是在同一个进程里创建的,共享内存空间,切换时开销相对小。进程则完全独立,每个进程都需要加载解释器状态、初始化模块、分配内存。
如果你在循环里频繁创建进程,比如处理 10 万个文件时每个文件都开一个新的Process,那程序大部分时间会花在创建和销毁上,而不是执行任务。正确做法是使用固定大小的进程池,让多个任务复用已有的 worker。
5.2 数据传递才是真正的隐藏瓶颈
多进程并行,计算本身可能只需要 0.1 秒,但如果每个任务要从主进程传 100MB 数据过去,传数据的时间可能会是计算时间的几十倍。
Pool.map会把输入列表拆成多个元素,分发给各个进程,然后把结果传回主进程。这个过程中数据需要序列化和反序列化。如果输入数据很大,或者结果里包含复杂对象,瓶颈就很明显。
遇到大数据量场景,我建议先思考几个问题:
- 数据能不能在子进程里各自生成,而不是主进程传过去?
- 数据能不能用只读共享内存,而不是复制?
- 结果能不能只传汇总值,而不是把完整对象传回?
如果确实需要在多个进程之间共享大数据,可以了解multiprocessing.shared_memory。但也要注意,共享内存本身有同步和生命周期管理成本,不是所有场景都值得。
5.3 什么时候该谨慎使用多进程
并不是所有 CPU 密集任务都能获得理想加速。下面这些情况,多进程实际收益可能很小:
- 任务本身非常小,调用开销占比高。
- 任务数量远大于核数,导致频繁排队和切换。
- 任务之间依赖关系很强,必须频繁通信。
- 每个任务需要加载大量前置数据,加载时间远大于计算时间。
- 子进程需要访问复杂的不易序列化的对象。
我之前遇到一个案例,用多进程处理 1 万个小文件的格式转换,结果比单线程还慢。后来把 100 个文件合并成一个批次再交给进程,速度才稳定下来。原因是单个文件处理只需要几毫秒,但进程池分发和结果回收的开销比任务本身还大。
6. 接到任务时如何做技术选型:从判断类型到小样本验证
6.1 第一步:判断任务是 CPU 密集还是 I/O 密集
一个很简单的判断方法:把任务里所有等待外部响应的部分去掉,看剩下的计算量到底大不大。
如果你想跑 100 个 HTTP 请求,等响应的时间占 90%,那这是 I/O 密集,适合多线程或异步。如果你要对 100 万个字符串做复杂解析,解析本身就要消耗大量 CPU,那这是 CPU 密集,适合多进程。
更细一点说,可以看任务的“阻塞等待时间”和“CPU 运算时间”的比例。等待时间明显大于运算时间,优先考虑多线程;运算时间明显大于等待时间,优先考虑多进程。
6.2 第二步:先看底层库能不能自己释放 GIL
如果你的计算任务用的是numpy、pandas、PIL、opencv这类带 C 扩展的库,情况会不一样。它们内部在耗时计算时可能会释放 GIL,所以多线程有时也能跑出不错的效果。
这并不意味着你不需要了解 GIL。相反,你更需要先做实验。我见过不少项目,看到多线程没加速就断定是 GIL 问题,结果换了多进程后反而更慢。真正原因可能是任务太小,进程间通信成为瓶颈。
技术选型不要靠猜测。你至少要跑一个最小实验,记录单线程、多线程、多进程三种方案的耗时和资源占用,再决定用哪一套。
6.3 第三步:组合使用线程和进程
实际项目中,任务常常混合了 I/O 和计算。比如一个文件处理流程,先读取文件,再对内容做复杂计算,最后写回结果。
这时候可以这样设计:外层用多线程处理文件读取和写入,因为 I/O 等待适合线程并发;内层用一个进程池处理复杂计算,因为计算需要真正的多核并行。二者通过队列或执行器串起来,就能同时吃满 I/O 吞吐和多核算力。
6.4 第四步:用小样本先压测
不管选哪个方案,我都建议先拿一个小样本跑通流程,再逐步扩大到完整数据量。所谓小样本,不是让你只跑一次,而是跑一个包含 5 到 10 个典型任务的小批次,分别记录:
- 单条任务的平均耗时。
- 并发数设置下总耗时。
- CPU 核心占用。
- 内存峰值。
- 失败任务数量和对应原因。
把这些数据记录下来,再决定要不要加并发。不要一开始就把 worker 数开到最大。比如机器有 16 个核,进程数不是只能开 16,但也不是越大越好。超过物理核心数后,进程切换反而会拖慢速度。对纯计算任务,通常先按物理核心数设置 worker,再根据实测结果微调。
7. 调试和观测:并发任务到底跑在哪一个环节
7.1 用统一的时间测量
写并发程序时,我习惯给测试脚本加一个简单计时器,保证每次对比都在相同条件下进行。
import time def timing(func): def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) print(f"{func.__name__}: {time.perf_counter() - start:.3f}s") return result return wrappertime.perf_counter比time.time更适合做精确耗时统计,因为它不受系统时钟调整影响。
7.2 观察日志和任务进度
多进程的日志容易混在一起,建议每个 worker 在日志里带上进程 ID 或任务编号。Python 的multiprocessing模块可以直接看到当前进程名称。
import multiprocessing import os def work(task_id): pid = os.getpid() print(f"task {task_id} 正在进程 {pid} 中执行") return task_id如果任务长时间没有输出,优先排查三个位置:输入数据是否被正确分发,worker 是否在等待主进程数据,以及是否有进程直接崩溃但没有留下异常。
7.3 区分“功能正常”和“性能正常”
并发程序最常见的问题是:功能上任务都跑完了,没有报错,但整体性能没有提升。
这时候不要急着改并发数。先看 CPU 占用和各阶段日志:
- 如果 CPU 占用一直很低,说明任务大部分时间在等待 I/O 或数据传递。
- 如果 CPU 占用已经很高,但总耗时还是很长,说明任务量本身太大,并发模型没有问题,需要从算法和库层面优化。
- 如果多个核心占用不均衡,说明任务分配不均,需要调整任务切分粒度。
我见过一个团队,多进程跑批处理任务,发现 16 个核里只有 2 个核在满负荷工作。最后排查发现,他们把一个大任务分成了不均匀的片段,大部分 worker 很快跑完,只有一小部分 worker 在长时间计算。解决方案是改成动态任务队列,让空闲进程可以从队列里取新的小任务。
7.4 常见错误排查顺序
如果你遇到“并发后反而更慢”的问题,我建议按这个顺序排查:
- 先确认任务类型。是 CPU 密集还是 I/O 密集。
- 再确认数据量。单个任务太多还是太少。
- 再看序列化成本。传输的数据是不是过大。
- 再看进程池配置。worker 数是否超过物理核心数很多。
- 最后看第三方库。它是否已经释放 GIL,还是必须用多进程。
- 查看日志和系统监控,确认资源占用发生在哪个阶段。
很多时候问题不是出在“GIL 没有移除”,而是出在“任务粒度不合适”或“数据传递太慢”。
8. 关于 GIL 的未来:现在该怎么选
8.1 Python 3.13 的自由线程构建是怎么回事
Python 3.13 引入了实验性的自由线程构建,也就是允许关闭 GIL 的版本。这个方向确实值得关注,但目前它仍然是一个需要单独选择编译方式的功能,并非所有第三方包都验证过在新模式下的稳定性。
如果你使用的是常见数据分析库、Web 框架或深度学习框架,建议先关注这些库对自由线程的兼容性声明。不要在没有验证的情况下把生产环境切到实验性构建上。
8.2 现在该不该等 GIL 移除
我的观点是:可以用新特性做实验和研究,但正式项目不要因为“以后可能没有 GIL”而改变当前方案。
如果你现在需要处理 CPU 密集型任务,多进程依然是最稳妥的选择。它不依赖解释器内部实现,不依赖第三方库是否适配自由线程,在任何主流 CPython 版本上都能工作。
如果你的任务主要是 I/O 密集,多线程和异步依然有效。即使未来 GIL 被移除,多线程在 I/O 场景下的收益也不会消失。
8.3 我用下来最稳妥的几条经验
最后整理几条实际项目里反复验证过的经验。
第一,先写单线程版本。单线程跑通且结果正确,这是所有优化的大前提。很多并发问题其实是在“没有基线”的情况下叠加出来的,结果出了问题都不知道找谁。
第二,不要迷信技术方案。4 进程不一定比 4 线程快。如果你的任务本身很小或数据传递成本很高,多进程可能更慢。判断依据是小样本压测,不是什么先进方法论。
第三,把耗时、CPU 占用、内存峰值、失败率记录下来。每次调整并发数或任务粒度时,用同样的测试样本对比,这样才能看到真正的变化。
第四,I/O 和计算混合的场景,可以采用“多线程处理 I/O + 进程池处理计算”的组合方式。不要只盯着某一种并发模型。
第五,学习阶段可以从threading的简单例子入手,理解 GIL 的行为;生产阶段再根据任务类型选择ProcessPoolExecutor或asyncio。理解 GIL 不是目的,目的是搞清楚你手头的任务为什么慢,以及应该把优化资源放到哪里。
GIL 不是一道需要“破解”的墙,更像是一块必须绕开的石墩。清楚它的边界之后,你会发现 Python 的多线程和多进程各自都有明确用途。CPU 稠密计算走多进程,I/O 等待走多线程,复杂管线就组合着用,关键是把每次调整都建立在可重复的实测结果上。