Python GIL详解:多线程为何跑不满多核?何时该用多进程?

发布时间:2026/9/8 9:29:43
Python GIL详解:多线程为何跑不满多核?何时该用多进程? 在实际项目中使用 Python 做并发处理时最常被问到的一个问题就是明明创建了 4 个线程机器也有 4 个核为什么任务耗时几乎和串行一样top 里也只看到一个核心在忙这类现象背后通常绕不开 GIL 这个关键词。很多人刚开始接触 Python 多线程、多进程时会把“线程”和“并行执行”画上等号但在 CPython 解释器下这种直觉并不成立。这篇文章会围绕一个非常具体的问题展开GIL 到底限制了什么什么情况下 Python 多线程依然有效什么情况下必须换多进程读完以后你可以用实验代码复现结果也能在后续项目里快速判断“该用 ThreadPoolExecutor 还是 ProcessPoolExecutor”。1. GIL 到底是什么它护住了哪条底线1.1 全局解释器锁的通俗解释GIL 的全称是 Global Interpreter Lock中文常见译法是“全局解释器锁”。它是 CPython 解释器进程级别的一把互斥锁。通俗地说在同一个进程内同一时刻只允许一个线程执行 Python 字节码。注意这里说的是“同一个进程内”。如果你启动 4 个 Python 进程每个进程都有自己独立的一份 GIL它们是可以真正并行执行 Python 字节码的。但如果你在一个进程里创建 4 个线程这 4 个线程仍然在争抢同一把锁。从操作系统的角度看Python 启动的线程就是普通操作系统线程操作系统会为它们分配调度资源。但从 CPython 解释器的角度看字节码的执行被 GIL 串行化。所以你不能说“Python 线程是假的”线程本身是真实的只是解释器层面的并行度被 GIL 限制住了。GIL 保护的是解释器内部的核心状态比如运行时对象、内存分配器、线程安全相关的引用计数操作。它并不保护你自己写的业务对象。也就是说即使有 GIL你的代码里依然可能出现数据竞争依然需要threading.Lock或queue.Queue来做同步。1.2 为什么 CPython 需要这样一把锁CPython 使用引用计数来管理内存。当对象被某个变量引用时引用计数加一当引用被销毁时引用计数减一。当引用计数归零时对象的内存会被回收。问题的关键在于引用计数的增减必须保证原子性。如果两个线程同时操作同一个对象的引用计数可能发生少记一次、多减一次的情况轻则内存泄漏重则对象被提前释放导致进程崩溃。为了避免这种情况最省事的办法就是给解释器加一把全局锁。持有 GIL 的线程在执行字节码时引用计数操作自然是安全的因为其他线程不会在这段期间同时操作同一个对象。这个选择在当时非常务实。Python 早期的主要目标之一是开发效率高、语法简洁、可移植性好而不是榨干多核 CPU 的并行能力。引入 GIL让解释器内部的内存管理逻辑大幅简化也让 C 扩展模块的编写难度降低。很多 C 扩展直接操作 Python 对象内部结构不需要去担心“另一个线程此刻是否正在修改同一个对象”因为 GIL 已经保证了解释器级别的互斥。1.3 为什么一直没把 GIL 彻底移除很多人会问既然 GIL 限制了多线程并行为什么不把它删掉原因是删除 GIL 的工程量非常大。首先需要引入比“全局一把锁”更细粒度的锁机制比如每个对象一把锁或者设计更复杂的内存模型。其次海量现有 C 扩展在编写时默认持有 GIL这些扩展在无 GIL 的环境下是否安全需要逐个审计和改造。最后移除 GIL 后单线程性能很可能下降因为更细粒度的锁和同步开销会增加。社区也确实做过相关尝试比如“gilectomy”项目目标是让 Python 在多线程场景下真正并行但复杂度很高迟迟没有进入主线。更现实的方向是在 Python 3.13 里提供实验性的 free-threading 构建也就是可以关闭 GIL 的版本。但这个构建目前更适合做技术验证生产环境落地前必须充分评估依赖兼容性、稳定性以及性能回退风险。从工程角度看GIL 不是一个“设计错误”而是一个取舍。它保证了简单、稳定和兼容代价是同一进程内的纯 Python 字节码无法并行执行。2. 用对比实验验证CPU 密集场景下的多线程为什么更慢2.1 设计任务和实验代码要理解 GIL 的实际影响最好的方法不是背结论而是跑一组对比实验。我们设计一个纯 CPU 密集任务统计一定范围内质数的数量。这个任务里没有文件操作、没有网络请求、没有数据库交互纯粹消耗 CPU能反映解释器层面字节码执行的速度。实验代码如下import os import time from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor def count_primes(limit: int) - int: 统计 2 到 limit 之间有多个质数模拟 CPU 密集计算。 count 0 for number in range(2, limit 1): is_prime True divisor 2 while divisor * divisor number: if number % divisor 0: is_prime False break divisor 1 if is_prime: count 1 return count def run_with(pool_factory, name: str, tasks): print(f开始运行: {name}) start time.perf_counter() with pool_factory(max_workers4) as executor: futures [executor.submit(count_primes, task) for task in tasks] total sum(future.result() for future in futures) elapsed time.perf_counter() - start print(f{name}: {elapsed:.2f}s, total{total}) return elapsed if __name__ __main__: print(CPU 核数:, os.cpu_count()) # 4 个相同的 CPU 密集任务 tasks [200000, 200000, 200000, 200000] run_with(ThreadPoolExecutor, ThreadPoolExecutor(4), tasks) run_with(ProcessPoolExecutor, ProcessPoolExecutor(4), tasks)这段代码里count_primes是纯 Python 循环不会释放 GIL。ThreadPoolExecutor会在当前进程里创建 4 个线程4 个任务同时提交。ProcessPoolExecutor会创建 4 个子进程每个子进程跑一个任务。2.2 三组结果对比我在一台 4 核机器上运行一次典型结果如下执行方式耗时结果单线程逐个执行 4 个任务约 1.4 秒total21598ThreadPoolExecutor(4)约 1.4 到 1.6 秒total21598ProcessPoolExecutor(4)约 0.4 到 0.5 秒total21598不同机器、不同 Python 版本、不同任务量结果会有波动但相对趋势是稳定的4 个任务串行执行约 1.4 秒。4 个线程并行执行也差不多 1.4 到 1.6 秒偶尔比串行更慢。4 个进程并行执行约 0.4 到 0.5 秒接近串行的三分之一。我们还可以额外跑一个单线程对照组用来作为基准。把串行、多线程、多进程放在一起看意义更明显。2.3 4 个线程没有跑满 4 个核的原因为什么 4 个线程没有跑满 4 个核因为这 4 个线程都在同一个 Python 进程里它们执行的是同样的 Python 字节码。GIL 规定同一时刻只能有一个线程执行字节码所以 4 个线程只能轮流占用 CPU。每个线程都有自己的事务但解释器全局只有一把锁其他线程即使被操作系统调度到某个核上也只能在等待 GIL 的状态里空转。从 top 或任务管理器可以看到进程总体 CPU 占用大致等于一个核的占用率。在 4 核机器上如果单核满载是 100%这个 Python 进程整体只有 100% 出头而不是 400%。如果使用 top 的线程视图可以看到多个线程处于 R 状态但它们的执行时间几乎只集中在一个线程上。这解释了标题里的现象CPU 密集任务下4 个线程跑不满 4 个核。多线程在这里不仅没有带来并行收益反而要承担线程创建、调度和 GIL 竞争的开销。如果任务本身很轻线程切换的开销甚至会让总耗时比串行更长。3. GIL 的切换机制和观察方法3.1 检查间隔和 sys.setswitchintervalGIL 并不是“一个线程必须完全执行完才能释放锁”。CPython 会设置一个检查间隔每隔一段时间强制让出 GIL让其他线程有机会执行。这个配置可以通过sys.getswitchinterval()查看默认值是 0.005 秒也就是 5 毫秒。import sys print(sys.getswitchinterval()) # 输出示例: 0.005简单理解一个线程持有 GIL 后最多连续执行 5 毫秒然后强制触发一次线程切换。切换时当前线程保存执行状态其他线程尝试获取 GIL重新开始执行。这个机制保证了多线程之间可以交替运行不会出现某个线程长时间独占解释器。可以用sys.setswitchinterval(interval)调整这个值。比如调小到 0.0005 秒会让切换更频繁对 I/O 密集场景也许有更好的响应性但会显著增加线程调度开销。调大到 0.1 秒会减少切换次数但其他线程可能在等待 GIL 时出现明显延迟。实际项目中不要轻易去调这个参数。大多数情况下默认值就是兼顾吞吐和响应性的选择。频繁切换会让缓存失效程序可能更慢调得太大又会让某些线程饿死。3.2 哪些操作会释放 GIL除了时间片到了强制释放 GIL还有几类操作会主动释放 GILI/O 操作文件读写、网络请求、数据库连接等待。time.sleep()。部分 C 扩展的耗时操作比如某些numpy计算底层循环使用 C 语言实现并显式释放 GIL。正因为 I/O 操作会主动释放 GIL所以多线程在 I/O 密集场景下依然有效。一个线程在等网络响应时不需要继续持有解释器锁其他线程可以去执行自己的代码。这就是 Python 多线程“处理网络请求、爬虫、文件处理”仍然好用的根本原因。如果你在写 CPU 密集任务并且不希望线程之间互相争抢 GIL一个可能的思路是把热点代码放进可以释放 GIL 的库或 C 扩展里。比如用numpy做完批量运算、用numba编译热点函数这样底层计算阶段 GIL 可以被释放多核资源才有机会被利用。3.3 用 py-spy 观察等待 GIL 的现象当你怀疑多个线程卡在 GIL 上时可以用py-spy这样的工具直接查看运行中的 Python 进程内部线程栈。安装方式pip install py-spy假设进程 PID 是 12345执行py-spy dump --pid 12345输出中会显示每个线程的调用栈。如果看到多个线程都停在PyEval_RestoreThread、PyEval_AcquireLock、或者与锁获取相关的内部函数里说明这些线程正在等待 GIL。比如你可能看到Thread 0x1234 (active) File demo.py, line 12 in count_primes Thread 0x5678 (blocked) File demo.py, line 12 in count_primes多个线程的执行位置都很相似但只有一个处于 active 状态其他处于 blocked 状态。这就是 GIL 竞争的直接证据。也可以用top -H -p PID查看线程级 CPU 占用能看出多数线程的 CPU 时间没有明显累计只有少数线程在轮换占满 CPU。4. 什么时候该换多进程什么时候不该换4.1 I/O 密集场景下的多线程仍然有效I/O 密集任务的特点是线程大量时间在等待外部资源比如网络响应、数据库查询、文件内容读取。这种等待过程会释放 GIL所以线程之间可以很好地交错执行。典型场景有爬虫大量请求发出去后等待响应的时间远大于本地解析时间。Web 服务读写数据库或调用外部 API。文件批量读写。在这些场景下使用ThreadPoolExecutor或threading.Thread依然合理。线程轻量、启动开销小、共享内存容易不需要因为 GIL 就一律换成多进程。多进程在 I/O 密集场景并不是不能用但进程启动开销、进程间通信成本都会增加代码复杂度也更高。如果只是等待 I/O多进程带来的额外成本通常不划算。4.2 CPU 密集场景下的 ProcessPoolExecutor 和 multiprocessing反过来CPU 密集任务的瓶颈是解释器持续执行字节码。只要任务本体是纯 Python 计算GIL 就一直是瓶颈。此时应使用多进程每个子进程有独立解释器也拥有独立的 GIL所以它们可以真正并行。最简单的多进程入口是concurrent.futures.ProcessPoolExecutor。它和ThreadPoolExecutor的接口几乎一致核心区别是任务会在子进程中执行。如果项目本身依赖multiprocessing也可以直接创建多个Process通过Queue或Pipe分发任务。无论用哪种方式都要注意被提交到进程池的函数必须可以被序列化而且最好以if __name__ __main__保护入口避免 Windows 和 macOS 上的递归导入问题。一个比较常见的组合是网络请求用线程池或协程处理请求拿到数据后的复杂解析和计算用进程池处理。这种混合设计能同时解决 I/O 等待和 CPU 密集计算的问题。4.3 选型速查表多线程、多进程、协程、C 扩展场景推荐方案核心原因CPU 密集纯 Python 计算ProcessPoolExecutor / multiprocessing子进程各自持有 GIL能利用多核CPU 密集可向量化计算numpy / numba / Cython底层 C 扩展可释放 GIL避免进程通信开销I/O 密集高并发请求ThreadPoolExecutor / asyncioI/O 等待会释放 GIL线程/协程轻量任务简单且数量很大先评估串行或少量并发创建进程本身有开销任务太轻时并发不划算需要大量共享数据多线程 锁或专门设计共享内存多进程传数据要序列化可能成为新瓶颈这张表可以作为初步判断工具。更进一步你还可以用 profiling 工具确认瓶颈到底是 CPU 还是 I/O。先做判断再选方案不要一遇到并发就无脑上多进程。5. 多进程不是无代价的通信、序列化与启动开销5.1 进程间传递数据为什么需要 pickle使用ProcessPoolExecutor.submit提交任务时参数会被序列化后发送到子进程子进程执行完后返回值又要序列化回主进程。这个序列化过程默认使用 pickle。如果任务参数很小比如一个整数、一个字符串序列化开销可以忽略。但如果参数是大对象比如几十 MB 的 DataFrame、List、Dict序列化时间会非常可感。更麻烦的是如果对象不能被 pickle比如某些 lambda、某些第三方对象实例进程池会直接报错。所以在使用多进程时要尽量让任务之间保持独立每次传递的数据量尽可能小。最好的方式是传一个任务编号或文件路径让子进程自己加载数据。5.2 fork 和 spawn 的差异在不同操作系统上创建 Python 子进程的方式不同Linux 默认是 fork子进程继承父进程内存快照启动较快。Windows 默认是 spawn子进程从空白状态重新导入主模块启动更慢。macOS 上 Python 3.8 之后默认也改为 spawn。fork 并不代表完全隔离。如果父进程占用大量内存fork 出来的子进程虽然会使用 copy-on-write 技术但一旦子进程修改某些内存页系统会复制这些页内存消耗会上升。spawn 则每次都从零开始解释器启动开销更大但更安全、更清晰。在实际工程中如果代码需要跨平台不建议依赖 fork 的“内存继承”特性。比如在父进程里定义了一个大变量期望子进程直接继承这在 Windows 上是靠不住的因为 spawn 方式根本不会继承父进程内存。5.3 大数据量任务应该如何处理当进程间需要共享较大数据时有几个方向把数据写入临时文件或数据库子进程按任务编号读取。使用multiprocessing.Queue或Pipe但依然要过 pickle。使用multiprocessing.shared_memory在进程间共享同一块内存区域。计算任务特别重时主动按行或按分片拆分数据每个子进程只处理自己负责的数据片段。写一个简单示例说明如何用共享内存from multiprocessing import shared_memory shm shared_memory.SharedMemory(namedemo, createTrue, size1024) # 子进程通过相同 name 打开同一块内存共享内存适合数值型数据如果是复杂的 Python 对象还是要落回 pickle 或独立存储。不要为了所谓的“共享”过度设计大多数并发场景里任务切分和结果聚合设计好了序列化开销完全可控。6. 绕过 GIL 的其他路径和落地边界6.1 asyncio 解决的是高并发等待而不是并行计算asyncio基于事件循环本质上运行在单线程里。它并不解决 CPU 密集型计算并行问题也不能利用多核 CPU。它解决的问题是高并发 I/O大量连接同时在等待数据事件循环在它们之间切换效率远高于为每个连接创建一个线程。如果你把一段耗时很长的纯 Python 计算放进 asyncio 任务里事件循环会被这段计算阻塞其他协程全部无法执行。所以在 asyncio 中使用进程池或线程池执行阻塞任务是常见的组合方式。判断标准很简单如果任务是“等网络、等磁盘、等数据库”多线程或 asyncio 都可能合适如果任务是“把 CPU 算满”就必须考虑多进程或 C 扩展。6.2 numpy、numba、Cython 等 C 扩展路径很多 Python 库底层是 C、C 或 Rust 实现。这些扩展在执行底层计算时可以选择暂时释放 GIL。释放 GIL 之后运行在多个线程上的计算任务就有机会真正并行。典型例子是 numpy。某些大型向量和矩阵运算numpy 底层会用 C 循环处理并在适当位置释放 GIL。同样一段代码如果拆成多个线程来跑不再完全受限于 GIL加速效果就要看具体底层实现是否释放了锁。numba 是另一个常用工具。可以用njit把纯 Python 函数编译成机器码如果函数逻辑不依赖 Python 对象操作还可以设置nogilTruefrom numba import njit njit(nogilTrue) def compute(limit: int) - int: count 0 for number in range(2, limit 1): is_prime True divisor 2 while divisor * divisor number: if number % divisor 0: is_prime False break divisor 1 if is_prime: count 1 return count在nogilTrue情况下这个函数被调用时不会一直持有 GIL多个线程就可以并行执行不同范围的计算。使用它的前提是函数本身不再操作 Python 可变对象并且你能确认编译后的代码在自己的环境里能正常工作。Cython 的思路也类似把热点代码用 Cython 编译成 C 扩展并在扩展函数里显式释放 GIL。这样做的成本是开发复杂度上升适合性能关键路径确实非常热、而且用现成库无法解决的场景。6.3 实验性的 free-threading 构建可以关注但生产要谨慎CPython 3.13 开始提供实验性的 free-threading 构建也就是 Python 可以运行在没有 GIL 的配置下。对这个特性现阶段比较稳妥的态度是可以拿来学习原理做技术验证但生产环境引入前需要做充分的兼容性测试。无 GIL 构建并不是“删掉 GIL 那么简单”。没有了全局锁解释器内部需要使用更细粒度的同步机制某些依赖 GIL 做隐式保护的 C 扩展可能不再安全。一些第三方库需要重新编译或改造才能保证在 free-threading 环境下行为正确。如果你的项目绝大多数代码是纯 Python依赖较少未来可以关注这个方向的成熟度和生态兼容情况。如果项目重度依赖第三方扩展不要在产品环境贸然启用无 GIL 构建至少先跑通压测、并发测试和内存监控再说。7. 实战排查清单与最佳实践7.1 怀疑 GIL 时按这三步确认第一步先确认任务是不是真的 CPU 密集。用cProfile或py-spy record看看热点函数是什么。如果热点集中在requests、read、write这类调用上说明瓶颈在 I/O不需要换多进程调大线程池、使用协程甚至优化请求数量都可能更有效。python -m cProfile -s cumulative demo.py第二步用系统工具看进程的 CPU 利用率。在 Linux 上执行top -H -p PID或者pidstat -p PID -t 1。如果你的多线程进程整体 CPU 占用只有 100% 出头说明大概率被 GIL 限制了。真正用满 4 核的进程整体占用会接近 400%按单核 100% 计算。第三步用py-spy dump --pid PID查看线程栈。如果多个线程都停在与 GIL 获取相关的内部函数上且 active 状态的线程始终只有一个就可以确定 GIL 是主要瓶颈。确认之后按成本从低到高选择优化手段先用现有库替代纯 Python 循环例如 numpy、pandas。再考虑把计算任务拆到多个进程。I/O 等待较多时优先考虑 asyncio 或线程池。热点函数确实无法用库替代时再考虑 numba、Cython 等编译方案。7.2 五个常见坑坑现象原因推荐做法在 ThreadPoolExecutor 里跑 CPU 密集计算多线程比串行还慢GIL 限制了字节码并行换成 ProcessPoolExecutor主模块没有if __name__ __main__保护Windows 下进程池反复启动或报错spawn 方式会重新导入主模块用if __name__ __main__包裹启动逻辑往进程池里传大对象任务运行很久CPU 不高pickle 序列化和反序列化耗时传任务编号或文件路径子进程自行加载以为全局变量可以在进程池里共享子进程修改了变量父进程没变化每个进程有独立内存空间使用 Queue、Pipe、共享内存或文件持久化调小 sys.setswitchinterval 想让线程更公平性能反而下降线程切换和 GIL 竞争开销增加保持默认值优先改并发模型7.3 我现在写并发代码的几条原则现在拿到一个并发需求我一般会先问三个问题任务是等外部资源还是在做纯 Python 计算数据量多大跨进程传递是否划算单个任务执行时间是毫秒级还是秒级如果任务是 I/O 密集比如爬虫、批量请求、文件处理用线程池或 asyncio。如果任务是 CPU 密集比如循环计算、复杂数值处理、文本解析用进程池或底层 C 扩展库。如果任务里既有 I/O 又有计算先把 I/O 部分和计算部分拆开再分别选择方案。不要把“多进程”当成万能解药。进程数超过 CPU 核数时收益并不会继续增加任务太轻、切分太细时进程启动和通信成本反而会吞掉所有收益。最合理的做法是先跑一个最小实验用数据决定方案。最后任何时候都先做 profile 再优化。GIL 是 Python 并发绕不开的限制但业务系统里真正被 GIL 卡住的场景往往只是少部分热点代码。定位到热点再决定是换并发模型还是用底层库改写是收益最高的一条路径。