Python GIL揭秘:为什么多线程跑不满多核CPU,多进程才是正解
之前在处理一个批量数据清洗脚本时我发现一个非常奇怪的现象机器是 4 核 CPU任务也确实是数据密集型计算为了提速我用threading开启了 4 个 Python 线程分别处理 4 份切片数据。结果监控面板显示CPU 总占用率始终在 100% 到 120% 之间徘徊4 个核根本没有跑满任务耗时也没有被压到原来的四分之一。一开始我以为是任务拆分不均、线程启动异常或者是死循环卡住了某个线程。排查了很久最后才发现问题并不在业务代码层而在 Python 解释器深处的一把锁——GIL。相信很多 Python 开发者也遇到过类似情况明明开了多线程CPU 密集型任务却没有真正利用多核甚至比单线程还慢。这篇文章我想从头梳理 GIL 的来龙去脉用几个可复现的实验说明“4 个线程没跑满 4 个核”的根本原因再给出 CPU 密集场景下正确的并发方案选型。内容适合 Python 初学者理解 GIL 概念也适合已经接触过threading和multiprocessing但踩过“多线程不加速”的开发者参考。1. 一个真实的“4 线程没跑满 4 核”场景1.1 现象描述假设我们有一台 4 核服务器需要处理一个非常大的列表例如对 4 份独立数据进行求和、排序、统计等纯计算操作。我之前的第一反应是4 个核那就开 4 个 Python 线程每个线程处理一份数据理论上总耗时能缩短到单线程的四分之一。但实际操作后现象非常打脸4 个线程都成功启动了没有报错。每个线程的日志都在正常输出任务也正常完成。任务总耗时并没有显著缩短甚至比单线程还慢。系统监控里CPU 总占用率只是从单线程的 100% 左右变成了 100% 到 120% 之间。从监控上看并没有出现“4 个核同时被占满”的情况也就是总 CPU 占用率没有逼近 400%。这说明开启的 4 个线程并没有真正做到并行计算。1.2 最初排查方向遇到这种情况一开始最容易怀疑几个方向线程是否真的启动了任务是否被均匀拆分线程之间是否有锁竞争导致互相等待是否因为频繁打印日志拖慢了速度这些我都检查过逻辑上没有明显问题。线程确实启动了打印的线程 ID 也都不同任务也是并行提交的。但 CPU 利用率和耗时数据依然难看。后来定位到关键点CPython 解释器在执行 Python 字节码时同一时刻只允许一个线程执行。也就是说Python 线程虽然由操作系统调度但在解释器层面被强制“排队执行”。这就是 GIL 的作用。1.3 定位到 GILGIL 是 Global Interpreter Lock 的缩写翻译为“全局解释器锁”。它是 CPython 解释器内部的一把互斥锁作用是保证同一进程内任意时刻只有一个线程在执行 Python 字节码。这就是为什么 4 个线程在 CPU 密集任务中无法跑满 4 核。因为无论你创建多少个线程CPython 解释器都只允许一个线程运行 Python 代码其他线程只能等待 GIL。所以“多线程并行计算”在纯 Python 的 CPU 密集场景下本质上是做不到的。2. GIL 到底是什么为什么存在2.1 通俗解释单车道工厂可以把一个 Python 进程想象成一个工厂车间。车间里有 4 名工人线程但是通往原料加工区的通道只有一条而且这条通道一次只能通过一个人。工人 A 进入通道后其他工人只能在通道外等待。即使车间外排了 4 个人通道的吞吐量依然是“一次一人”。如果工人 A 在里面待 5 毫秒就出来工人 B 再进去再出来如此循环整体加工的绝对时间并没有因为人数增加而缩短反而因为“换人”和“排队”增加了额外时间。GIL 就是这条“单人通道”。它保证了 CPython 内存管理的安全但代价是牺牲了多线程并行执行 Python 代码的能力。2.2 GIL 解决了什么问题CPython 的内存管理主要依赖引用计数Reference Counting。每个 Python 对象都有一个计数器记录当前有多少个变量引用它。当计数器变成 0 时对象占用的内存会被立刻回收。如果多个线程同时创建、修改、销毁同一个对象引用计数器的增减就会发生竞争。假设没有 GIL线程 A 正在读取对象的引用计数线程 B 同时修改引用计数最后导致计数错乱可能提前释放了仍然被引用的对象造成程序崩溃或内存安全问题。为了避免这种复杂的竞争问题早期 CPython 选择了一种简单粗暴但有效的方案在解释器层面加一把大锁让 Python 字节码的执行串行化。这样任何时刻只有一个线程在操作引用计数内存管理就安全了。需要说明的是并不是所有 Python 实现都有 GIL。常见的 CPython 有 GIL像 Jython、IronPython 等实现基于 Java 或 .NET 的运行时并不存在同样语义的 GIL。但生产环境中最常用的还是 CPython所以讨论 GIL 时默认针对 CPython。2.3 GIL 的切换机制有人可能会问既然同一时刻只有一个线程执行 Python 字节码那其他线程什么时候有机会执行CPython 采用了一种“时间片轮转”机制。默认情况下当前持有 GIL 的线程执行一段很短的时间后会主动释放 GIL让其他线程获得执行机会。这个时间间隔可以通过sys.getswitchinterval()查看默认是0.005秒也就是 5 毫秒。import sys print(sys.getswitchinterval()) # 默认输出 0.005同时当线程执行 IO 操作时比如文件读取、网络请求、数据库查询CPython 也会主动释放 GIL让其他线程运行。这一点非常关键正是因为 IO 操作会释放 GIL所以多线程在 IO 密集场景下依然能带来明显的并发收益。还需要特别说明的是部分 C 扩展库在执行耗时计算时会主动释放 GIL。例如numpy的很多底层数组运算、pandas的某些向量化操作、cv2的图像处理函数都会在进入 C 扩展代码时释放 GIL。遇到这类库时多线程可能会表现出“近似并行”的效果但这并不代表纯 Python 循环也能并行。3. 用实验证明CPU 密集场景下多线程比单线程慢3.1 环境准备与说明下面的实验只需要 Python 标准库不依赖第三方包。使用的 Python 版本以 3.10 及以上的常见版本为例。代码可以跨平台运行在 Linux、macOS、Windows 上均可。需要提醒的是实验中的任务量和耗时取决于机器的 CPU 性能不同机器跑出来的绝对时间会有差异。但结论方向是稳定的纯 Python 的 CPU 密集任务多线程不会比单线程快反而可能更慢。为了便于观察我们先写一个 CPU 密集型函数让每个线程执行大量 Python 字节码def cpu_task(n): total 0 for i in range(n): total i return total这个函数内部是纯粹的整数累加没有 IO 操作也没有调用 C 扩展库是最典型的“纯 Python CPU 密集任务”。3.2 实验一单线程与 4 线程对比先来看单线程执行 4 次任务import threading import time def cpu_task(n): total 0 for i in range(n): total i return total def run_single_thread(): start time.perf_counter() for _ in range(4): cpu_task(50_000_000) print(f单线程耗时: {time.perf_counter() - start:.3f}s) def run_multi_thread(): start time.perf_counter() threads [] for _ in range(4): t threading.Thread(targetcpu_task, args(50_000_000,)) threads.append(t) t.start() for t in threads: t.join() print(f4线程耗时: {time.perf_counter() - start:.3f}s) if __name__ __main__: run_single_thread() run_multi_thread()运行方式python gil_test.py从结果可以看到4 线程版本并没有显著快于单线程更不可能缩短到单线程耗时的四分之一。在很多机器上4 线程版本反而会慢 20% 到 60%。为什么多线程反而更慢因为线程之间切换需要额外开销线程创建和销毁需要时间。每个线程执行一段时间后操作系统会触发上下文切换。线程切换到另一个线程时需要保存和恢复线程的寄存器、栈等信息。在 GIL 机制下线程还要竞争 GIL当前线程释放 GIL其他线程要去抢这把锁。这些额外操作都是纯开销放到 CPU 密集任务中完全不会带来收益只会拖慢整体速度。3.3 实验二查看 4 个线程的 PID为了更直观地理解“4 个线程其实属于同一个进程”我们可以让每个线程打印自己的threading.get_ident()和os.getpid()。import os import threading import time def worker(worker_id): print( fworker-{worker_id}, fpid{os.getpid()}, fthread_id{threading.get_ident()} ) time.sleep(5) if __name__ __main__: print(fmain process pid{os.getpid()}) for i in range(4): threading.Thread(targetworker, args(i,)).start()运行后会发现4 个线程打印的pid完全相同说明它们属于同一个进程共享同一个解释器实例也共享同一把 GIL。即使底层有多个 CPU 核这 4 个线程也无法同时执行 Python 字节码。4. 多进程才是 CPU 密集场景的正解4.1 从 threading 到 ProcessPoolExecutor既然多线程无法绕开 GIL那 CPU 密集任务应该怎么利用多核答案是使用多进程。每启动一个 Python 进程解释器都会创建一个独立的内存空间和一个独立的 GIL。也就是说4 个进程就有 4 个线程调度单位在 4 个核上并行执行互不干扰。使用concurrent.futures.ProcessPoolExecutor可以很方便地创建进程池import time from concurrent.futures import ProcessPoolExecutor def cpu_task(n): total 0 for i in range(n): total i return total def run_multi_process(): start time.perf_counter() with ProcessPoolExecutor(max_workers4) as pool: futures [pool.submit(cpu_task, 50_000_000) for _ in range(4)] for future in futures: future.result() print(f4进程耗时: {time.perf_counter() - start:.3f}s) if __name__ __main__: run_multi_process()运行后只要机器至少有 4 个 CPU 核CPU 总占用率会明显上升接近 400% 左右总耗时也会接近单线程的四分之一。4.2 多进程为什么能跑满 4 核我们来验证多进程之间 PID 不相同from concurrent.futures import ProcessPoolExecutor import os def worker(n): print(fprocess pid{os.getpid()}, result{n * n}) return n * n if __name__ __main__: with ProcessPoolExecutor(max_workers4) as pool: futures [pool.submit(worker, i) for i in range(4)] for future in futures: future.result()运行后能看到 4 个不同的 PID说明 CPU 密集任务分散到了 4 个独立进程中。每个进程拥有自己的 GIL因此 4 个进程可以真正并行执行计算。补充说明在 Linux 上ProcessPoolExecutor默认使用fork方式启动子进程启动成本相对较低。在 Windows 和 macOS 上默认使用spawn方式子进程会重新导入主模块因此必须把进程池的创建代码放在if __name__ __main__:保护中否则会出现无限递归创建子进程的报错。4.3 多进程的注意事项多进程虽然能跑满多核但也有一些代价进程启动开销比线程大。每个进程有独立内存空间不能直接共享普通 Python 对象。提交任务和返回结果需要通过序列化传输数据任务如果太小、太频繁通信开销可能反而超过并行收益。进程数量不是越多越好。进程数超过 CPU 核数后操作系统需要在进程之间切换收益会下降。所以CPU 密集任务更推荐“大任务、少进程”的模式把一份大任务拆成和 CPU 核数相近的几份然后交给进程池处理。5. IO 密集场景多线程依然有价值5.1 为什么 IO 操作会释放 GIL前面提到CPython 在执行 IO 操作时会主动释放 GIL。为什么这么做因为 IO 操作通常是阻塞的。比如发起一个网络请求需要等待服务器的响应这期间线程什么都不做只是干等。如果线程在等待时还一直持有 GIL其他线程就没法执行任何 Python 代码效率会非常低。所以 CPython 在底层检测到线程进入 IO 等待状态时会先释放 GIL让其他线程运行。当 IO 操作完成后当前线程再重新获取 GIL 并继续执行后续代码。这就意味着多线程在 IO 密集场景下可以真正实现并发一个线程在等待网络响应时另一个线程可以继续发请求或处理数据。整体耗时主要取决于最耗时的 IO 操作而不是所有操作的累加时间。5.2 IO 密集多线程实验我们可以用time.sleep模拟一个 IO 等待过程。虽然sleep不是真正的网络请求但在“阻塞线程并让出 GIL”这个行为上效果是类似的。import threading import time def io_task(seconds): time.sleep(seconds) return seconds def run_single_thread(): start time.perf_counter() for _ in range(4): io_task(1) print(f单线程耗时: {time.perf_counter() - start:.3f}s) def run_multi_thread(): start time.perf_counter() threads [] for _ in range(4): t threading.Thread(targetio_task, args(1,)) threads.append(t) t.start() for t in threads: t.join() print(f4线程耗时: {time.perf_counter() - start:.3f}s) if __name__ __main__: run_single_thread() run_multi_thread()实验结果通常是单线程版本耗时接近 4 秒。4 线程版本耗时接近 1 秒。因为 4 个线程都进入sleep等待时解释器会让出 GIL4 个线程的等待时间重叠了。多线程在 IO 密集场景下的优势本质上不是加快了单次 IO 速度而是让多个 IO 请求同时进行。5.3 asyncio 的补充对于 IO 密集场景除了多线程还可以考虑asyncio协程。它同样是单线程但在遇到 IO 等待时会切到其他协程执行不需要操作系统创建线程资源开销更小。import asyncio async def io_task(seconds): await asyncio.sleep(seconds) async def main(): await asyncio.gather(*[io_task(1) for _ in range(4)]) if __name__ __main__: asyncio.run(main())asyncio适合大量并发网络请求、爬虫、API 调用等场景。它的限制是代码需要写成异步风格而且不能直接调用阻塞的同步库否则会卡住整个事件循环。6. 混合场景如何选择线程、进程、协程6.1 决策标准在实际项目中任务往往不会只有“纯计算”或“纯 IO”两种。我们可以把任务分为几类场景推荐方案原因CPU 密集多进程ProcessPoolExecutor每个进程独立 GIL真正多核并行IO 密集多线程ThreadPoolExecutor或 asyncioIO 等待时释放 GIL并发效果好计算 IO 混合协同 / 混合模式计算交进程池IO 交给协程或线程大量并发网络请求asyncio协程开销最小适合高并发已有同步阻塞库改造成本高多线程隔离用线程池避免阻塞主流程6.2 线程 进程组合示例一个常见的组合是主流程使用asyncio处理 IO 任务同时使用ProcessPoolExecutor处理计算任务。这样既能处理并发网络请求又能利用多核 CPU。import asyncio from concurrent.futures import ProcessPoolExecutor def cpu_bound(n): return sum(i for i in range(n)) async def io_bound(seconds): await asyncio.sleep(seconds) return seconds async def main(): loop asyncio.get_running_loop() with ProcessPoolExecutor() as pool: # CPU 密集任务交给进程池 cpu_result await loop.run_in_executor(pool, cpu_bound, 10_000_000) # IO 密集任务用协程并发执行 io_results await asyncio.gather(io_bound(1), io_bound(1)) print(cpu_result:, cpu_result) print(io_results:, io_results) if __name__ __main__: asyncio.run(main())这里用loop.run_in_executor把 CPU 任务提交给进程池而 IO 等待部分用asyncio.gather并发执行。两件事不冲突也不需要额外创建大量线程。7. 常见问题与排查清单7.1 高频问题表问题现象常见原因解决思路4 线程 CPU 占用只有 100%~120%GIL 导致字节码串行执行CPU 密集任务改用多进程多线程比单线程还慢GIL 切换开销、线程创建开销减少线程数或换多进程numpy 矩阵运算能并行底层 C 库释放了 GIL确认对应库接口是否释放 GIL普通 Python 循环仍受 GIL 限制multiprocessing 在 Windows 上启动报错spawn 方式需要if __name__ __main__保护增加主模块保护进程池提交很多小任务很慢序列化和进程间通信开销减少任务数量、加大单任务粒度程序无法退出线程一直卡住线程循环未设置退出条件使用 daemon 线程或join(timeout)多线程访问同一变量出现奇怪结果线程间共享全局变量缺少同步使用lock保护临界区7.2 排查 GIL 相关问题的步骤先判断任务是 CPU 密集还是 IO 密集。纯 Python 循环、数值计算、字符串处理大规模循环属于 CPU 密集文件读写、网络请求、数据库查询属于 IO 密集。用单线程版本跑一遍记录性能基线。用threading版本跑一遍对比耗时和 CPU 占用。如果 CPU 使用率始终在一个核附近基本可以确认是 GIL 限制。改用ProcessPoolExecutor跑一遍观察耗时和 CPU 占用是否明显改善。如果多进程仍然没有改善检查是否有频繁的进程间通信和序列化开销并尝试调大单任务粒度。如果使用了第三方 C 扩展查阅文档或源码确认它是否在计算过程中释放 GIL。8. 最佳实践与工程建议8.1 先评估任务类型再选并发方案写并发代码前先问自己一个问题这个任务是 CPU 密集还是 IO 密集很多性能问题的根因不是“用了什么技术”而是“在错误的场景用错了技术”。CPU 密集场景默认优先考虑多进程IO 密集场景优先考虑asyncio或线程池。8.2 避免频繁创建线程和进程无论是线程还是进程创建和销毁都有成本。实际项目中应该使用线程池或进程池而不是在循环里反复创建新线程和进程。from concurrent.futures import ThreadPoolExecutor def handle_request(item): # 模拟处理任务 return item * 2 with ThreadPoolExecutor(max_workers8) as pool: results list(pool.map(handle_request, range(100)))使用线程池/进程池的好处是复用工作线程/进程减少重复创建开销也方便统一管理并发数量。8.3 合理设置进程数进程池的max_workers并不是越大越好。一般可以参考multiprocessing.cpu_count()但要注意在容器环境里cpu_count()返回的可能是宿主机核数而不是容器可用的核数。如果同时还有其他服务在运行进程数要预留一部分资源。过大的进程数会导致频繁上下文切换性能反而下降。import multiprocessing from concurrent.futures import ProcessPoolExecutor workers max(1, multiprocessing.cpu_count() - 1) with ProcessPoolExecutor(max_workersworkers) as pool: ...8.4 进程间数据共享要谨慎多进程之间默认不能直接共享内存对象。如果多个进程需要协作处理同一个数据结构有几种常见方案使用multiprocessing.Queue传递任务和结果。使用multiprocessing.Pipe实现双向通信。使用multiprocessing.Manager创建共享列表、字典等高级对象但性能开销较大。将任务设计为“分片处理最后合并结果”的模式尽量避免进程间共享可变状态。from multiprocessing import Process, Queue def worker(task_queue, result_queue): while True: item task_queue.get() if item is None: break result_queue.put(item * 2) if __name__ __main__: task_queue Queue() result_queue Queue() for i in range(10): task_queue.put(i) for _ in range(4): task_queue.put(None) processes [ Process(targetworker, args(task_queue, result_queue)) for _ in range(4) ] for p in processes: p.start() for p in processes: p.join() results [] while not result_queue.empty(): results.append(result_queue.get()) print(results)这里用None作为结束信号是multiprocessing常见的一种优雅退出方式。8.5 使用锁保护多线程共享状态如果多线程场景下确实需要修改同一个全局变量必须加锁import threading counter 0 lock threading.Lock() def increment(): global counter for _ in range(1000000): with lock: counter 1 threads [ threading.Thread(targetincrement) for _ in range(4) ] for t in threads: t.start() for t in threads: t.join() print(counter)不加锁的情况下多线程同时执行counter 1可能丢失更新最终结果不是预期的 4000000。加锁之后虽然牺牲了一定的性能但保证了结果正确。8.6 善用 faulthandler 排查卡死问题如果程序出现卡死可以用faulthandler定期打印所有线程的调用栈快速定位问题线程。import faulthandler import threading import time faulthandler.dump_traceback_later(10, exitTrue) def worker(): while True: pass t threading.Thread(targetworker, daemonTrue) t.start() time.sleep(30)程序运行 10 秒后faulthandler会打印当前各线程的调用栈帮助我们判断线程卡在哪个位置。这是排查线程死循环、死锁等问题的好用工具。9. 总结与下一步学习建议Python 的并发模型并不复杂难点在于理解不同层级对“并行”的定义。GIL 让多线程在 CPU 密集任务中无法真正并行所以在纯 Python 计算场景下多进程才是利用多核的正确方案而在 IO 密集场景中多线程和协程依然是非常高效的并发手段。这篇文章从一个典型的“4 个线程没跑满 4 个核”问题切入梳理了 GIL 的机制、实验对比、多进程方案以及混合场景选型。建议你把自己机器上的几个测试脚本跑一遍不同 CPU 架构、不同 Python 版本的表现会有差异但结论方向是稳定的遇到 CPU 密集任务第一时间想到用ProcessPoolExecutor而不是把线程数堆满。下一步可以继续学习几个方向multiprocessing的Queue、Pipe、Manager进阶用法。asyncio的事件循环与协程调度机制。C 扩展库如numpy的 GIL 释放机制。Python 3.13 中实验性的 free-threading 构建模式的理解与评估。如果文中某些细节和你的实测环境有差异欢迎在评论区留言交流一起把 Python 并发这块彻底弄明白。
