Python GIL揭秘:为什么多线程跑不满多核CPU,多进程才是正解
2026/9/8 9:29:56 网站建设 项目流程

之前在处理一个批量数据清洗脚本时,我发现一个非常奇怪的现象:机器是 4 核 CPU,任务也确实是数据密集型计算,为了提速,我用threading开启了 4 个 Python 线程分别处理 4 份切片数据。结果监控面板显示,CPU 总占用率始终在 100% 到 120% 之间徘徊,4 个核根本没有跑满,任务耗时也没有被压到原来的四分之一。

一开始我以为是任务拆分不均、线程启动异常,或者是死循环卡住了某个线程。排查了很久,最后才发现问题并不在业务代码层,而在 Python 解释器深处的一把锁——GIL。相信很多 Python 开发者也遇到过类似情况:明明开了多线程,CPU 密集型任务却没有真正利用多核,甚至比单线程还慢。

这篇文章我想从头梳理 GIL 的来龙去脉,用几个可复现的实验说明“4 个线程没跑满 4 个核”的根本原因,再给出 CPU 密集场景下正确的并发方案选型。内容适合 Python 初学者理解 GIL 概念,也适合已经接触过threadingmultiprocessing但踩过“多线程不加速”的开发者参考。

1. 一个真实的“4 线程没跑满 4 核”场景

1.1 现象描述

假设我们有一台 4 核服务器,需要处理一个非常大的列表,例如对 4 份独立数据进行求和、排序、统计等纯计算操作。我之前的第一反应是:4 个核,那就开 4 个 Python 线程,每个线程处理一份数据,理论上总耗时能缩短到单线程的四分之一。

但实际操作后,现象非常打脸:

  • 4 个线程都成功启动了,没有报错。
  • 每个线程的日志都在正常输出,任务也正常完成。
  • 任务总耗时并没有显著缩短,甚至比单线程还慢。
  • 系统监控里,CPU 总占用率只是从单线程的 100% 左右,变成了 100% 到 120% 之间。

从监控上看,并没有出现“4 个核同时被占满”的情况,也就是总 CPU 占用率没有逼近 400%。这说明开启的 4 个线程并没有真正做到并行计算。

1.2 最初排查方向

遇到这种情况,一开始最容易怀疑几个方向:

  1. 线程是否真的启动了?
  2. 任务是否被均匀拆分?
  3. 线程之间是否有锁竞争导致互相等待?
  4. 是否因为频繁打印日志拖慢了速度?

这些我都检查过,逻辑上没有明显问题。线程确实启动了,打印的线程 ID 也都不同,任务也是并行提交的。但 CPU 利用率和耗时数据依然难看。

后来定位到关键点:CPython 解释器在执行 Python 字节码时,同一时刻只允许一个线程执行。也就是说,Python 线程虽然由操作系统调度,但在解释器层面被强制“排队执行”。这就是 GIL 的作用。

1.3 定位到 GIL

GIL 是 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(target=cpu_task, args=(50_000_000,)) threads.append(t) t.start() for t in threads: t.join() print(f"4线程耗时: {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( f"worker-{worker_id}, " f"pid={os.getpid()}, " f"thread_id={threading.get_ident()}" ) time.sleep(5) if __name__ == "__main__": print(f"main process pid={os.getpid()}") for i in range(4): threading.Thread(target=worker, 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_workers=4) as pool: futures = [pool.submit(cpu_task, 50_000_000) for _ in range(4)] for future in futures: future.result() print(f"4进程耗时: {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(f"process pid={os.getpid()}, result={n * n}") return n * n if __name__ == "__main__": with ProcessPoolExecutor(max_workers=4) 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 多进程的注意事项

多进程虽然能跑满多核,但也有一些代价:

  1. 进程启动开销比线程大。
  2. 每个进程有独立内存空间,不能直接共享普通 Python 对象。
  3. 提交任务和返回结果需要通过序列化传输数据,任务如果太小、太频繁,通信开销可能反而超过并行收益。
  4. 进程数量不是越多越好。进程数超过 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(target=io_task, args=(1,)) threads.append(t) t.start() for t in threads: t.join() print(f"4线程耗时: {time.perf_counter() - start:.3f}s") if __name__ == "__main__": run_single_thread() run_multi_thread()

实验结果通常是:

  • 单线程版本耗时接近 4 秒。
  • 4 线程版本耗时接近 1 秒。

因为 4 个线程都进入sleep等待时,解释器会让出 GIL,4 个线程的等待时间重叠了。多线程在 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 相关问题的步骤

  1. 先判断任务是 CPU 密集还是 IO 密集。纯 Python 循环、数值计算、字符串处理大规模循环属于 CPU 密集;文件读写、网络请求、数据库查询属于 IO 密集。
  2. 用单线程版本跑一遍,记录性能基线。
  3. threading版本跑一遍,对比耗时和 CPU 占用。
  4. 如果 CPU 使用率始终在一个核附近,基本可以确认是 GIL 限制。
  5. 改用ProcessPoolExecutor跑一遍,观察耗时和 CPU 占用是否明显改善。
  6. 如果多进程仍然没有改善,检查是否有频繁的进程间通信和序列化开销,并尝试调大单任务粒度。
  7. 如果使用了第三方 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_workers=8) 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_workers=workers) 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(target=worker, 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(target=increment) 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, exit=True) def worker(): while True: pass t = threading.Thread(target=worker, daemon=True) t.start() time.sleep(30)

程序运行 10 秒后,faulthandler会打印当前各线程的调用栈,帮助我们判断线程卡在哪个位置。这是排查线程死循环、死锁等问题的好用工具。

9. 总结与下一步学习建议

Python 的并发模型并不复杂,难点在于理解不同层级对“并行”的定义。GIL 让多线程在 CPU 密集任务中无法真正并行,所以在纯 Python 计算场景下,多进程才是利用多核的正确方案;而在 IO 密集场景中,多线程和协程依然是非常高效的并发手段。

这篇文章从一个典型的“4 个线程没跑满 4 个核”问题切入,梳理了 GIL 的机制、实验对比、多进程方案以及混合场景选型。建议你把自己机器上的几个测试脚本跑一遍,不同 CPU 架构、不同 Python 版本的表现会有差异,但结论方向是稳定的:遇到 CPU 密集任务,第一时间想到用ProcessPoolExecutor,而不是把线程数堆满。

下一步可以继续学习几个方向:

  • multiprocessingQueuePipeManager进阶用法。
  • asyncio的事件循环与协程调度机制。
  • C 扩展库(如numpy)的 GIL 释放机制。
  • Python 3.13 中实验性的 free-threading 构建模式的理解与评估。

如果文中某些细节和你的实测环境有差异,欢迎在评论区留言交流,一起把 Python 并发这块彻底弄明白。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询