资讯动态

Python GIL 深度解析:多线程为何变慢?性能实测与突围方案

发布时间:2026/10/9 7:32:53 来源:尧图企业网站定制
多线程加速是不是神话这是每个 Python 开发迟早会在某个深夜撞上的问题。你郑重地写下ThreadPoolExecutor把十个 CPU 密集型任务丢进去结果在八核机器上跑出了比单线程还慢的耗时——那一刻你第一次听见GIL这个名字又爱又恨却没几个人能讲清楚它到底锁了些什么。作为常年和并发任务打交道的 Python 开发这篇文章我会一次性把 CPython 全球解释器锁Global Interpreter Lock的由来、工作机制、性能实测和突围方案全部盘清楚。不管你是刚装好 Python 准备入门多线程的新手还是正为候选方案里进程、协程、线程池发愁的老手这篇文章都值得先收藏再看。1. 为什么 CPython 要给自己焊死一把锁GIL 的由来与设计取舍1.1 引用计数Python 对象内存模型里的一颗地雷要理解 GIL 为什么存在得先看 CPython 的对象内存模型。每个 Python 对象在底层是一个PyObject结构体它最重要的头部字段之一就是ob_refcnt引用计数。当你写b a时Python 只是在给同一个对象增加一次引用对应的ob_refcnt加 1当某个引用被销毁时ob_refcnt减 1直到减到 0这个对象的内存才被回收。这套机制在单线程时代非常优雅但一旦进入多线程问题立刻出现两个线程同时操作同一个对象时ob_refcnt的增减必须在硬件层面是原子的否则可能出现两个线程同时减引用实际少减了一次导致对象提前被回收的灾难。用一段代码就能验证引用计数的存在import sys a [] b a print(sys.getrefcount(a)) # 输出一般是 3因为传参本身又多计了一次问题在于CPython 的每个解释器内部有大量共享对象小到空元组、None大到标准库的某些全局状态。如果给每个对象分别加一把细粒度锁牵一发动全身不仅性能开销巨大而且锁的嵌套顺序稍有不慎就是死锁。所以在 1992 年前后CPython 选择了最省事的方案——全局只留一把锁任何线程要执行 Python 字节码先拿这把锁。1.2 细粒度锁的失败尝试与全局锁的妥协你可能会想既然细粒度锁方案太复杂那把 GIL 去掉让对象各自用原子操作保护引用计数能不能行这个问题的答案不是技术上的不能而是工程成本上的极高。CPython 的核心开发者在多个 PEP 里反复讨论过这个问题结论一直很一致在完全去掉 GIL 之前解释器内部的内存管理、垃圾回收、异常传播、调试 hook 等机制都需要重新设计。GIL 的妥协价值在于它把解释器状态一致性这个复杂问题降维成了一个简单的互斥问题。任何 C 扩展作者写代码时只要遵守持有 GIL 期间对 Python 对象进行操作这条规则就能保证不会出现并发冲突。这让 Python 的 C 扩展生态在过去三十多年里快速膨胀从socket到numpy都受益于这种简洁的内存安全模型。1.3 GIL 的隐性收益很多人没意识到谈论 GIL 的代价的同时也该承认它的隐性收益。最典型的是因为 GIL 的存在dict.get、list.append、set.add这类简单操作天然具备原子性单线程程序不需要为内存安全额外加锁。还有CPython 的 分配器allocator可以在不考虑并发竞争的情况下高效工作这直接推动了 Python 在文本处理、脚本自动化等常见场景里拥有出奇稳定的性能下限。我并不想美化 GIL它的代价也是实打实的在多核 CPU 时代同一进程内无法并行执行 Python 字节码任何 CPU 密集型任务一旦用多线程就会撞上这堵墙。但理解了它带来过什么你才能准确判断自己项目里到底该不该去撞墙。2. GIL 到底锁住了什么执行模型与切换机制的深层拆解2.1 锁的是字节码执行不是整个进程一个最常见的误解是GIL 让 Python 一次只能做一件事。准确说GIL 锁住的是执行 Python 字节码这件事而不是进程内的所有操作。当解释器执行到系统调用、IO 操作、time.sleep或者某些被刻意设计为不持有 GIL的 C 扩展调用时它会主动把 GIL 释放掉让其他线程有机会继续执行。我可以用一个简单的类比GIL 就像公司里唯一的一台打印机。执行 Python 字节码 这台打印机只能同时打一份文件但当你把文件通过网络发送给客户IO 操作时打印机就空闲了其他同事完全可以去打印自己的材料。所以多线程同时发网络请求这种场景GIL 基本没法真正卡住你。2.2 从 checkinterval 到 switchinterval切换机制的进化GIL 的切换策略并非一成不变。Python 2 时代解释器按 指令数 来强制切换线程sys.setcheckinterval(100)表示每执行 100 条字节码指令就会检查一次是否需要让出 GIL。到了 Python 3.2核心开发者重写了 GIL改用基于时间的切换默认sys.setswitchinterval()返回0.005也就是每 5 毫秒检查一次是否切换到其他线程。import sys print(sys.getswitchinterval()) # 默认 0.005 秒 sys.setswitchinterval(0.001) # 尝试缩短切换间隔注意可能加大切换开销这个改动看似细微实则影响巨大基于时间的切换让 GIL 的处理更加公平避免某些极端指令序列长时间霸占锁同时它还引入了条件式切换的优化当一个线程准备释放 GIL 时会先检查是否有其他线程真的在等待 GIL如果没有就不做无谓的切换动作。这也是为什么 Python 3.2 之后运行多线程程序即使 CPU 密集也不会见到灾难级的性能雪崩。2.3 多线程竞争 GIL 时的乒乓效应当两个线程同时在跑 CPU 计算时它们会不停竞争同一把 GIL。线程 A 拿到锁跑大约 5 毫秒后释放线程 B 抢到跑 5 毫秒后再释放。这个过程中操作系统需要频繁执行线程上下文切换CPU 缓存也会因为执行流切换而反复失效。结果就是两个线程合起来的有效吞吐量往往低于一个线程连续跑同样任务。这就是为什么网上几乎所有人都在说Python 多线程跑 CPU 任务是负优化。2.4 在运行时观察 GIL 的争夺如果你想亲眼看到 GIL 竞争的过程py-spy这个工具非常合适。安装后运行py-spy dump --pid 进程号你会看到线程分布大致是一个线程处于PyEval_EvalFrame正在执行 Python 字节码其他线程大多阻塞在take_gil或者pthread_cond_wait上。这个画面直观地展示了 GIL 是如何让多个线程排队打太极的。3. 实测 GIL怎样的代码会被囚禁怎样的代码能自由奔跑3.1 CPU 密集型任务的实测多线程反而更慢为了让大家对 GIL 的影响有直观认识我用同一段代码做了四组对比测试。环境是 4 核 8 线程的 Intel i7、Python 3.11.4、Windows 11。先看 CPU 密集场景import time from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor def cpu_heavy(n10_000_000): s 0 for i in range(n): s i * i return s if __name__ __main__: t0 time.perf_counter() for _ in range(4): cpu_heavy() print(f单线程: {time.perf_counter() - t0:.2f}s) t0 time.perf_counter() with ThreadPoolExecutor(max_workers4) as ex: list(ex.map(cpu_heavy, [10_000_000] * 4)) print(f4线程: {time.perf_counter() - t0:.2f}s) t0 time.perf_counter() with ProcessPoolExecutor(max_workers4) as ex: list(ex.map(cpu_heavy, [10_000_000] * 4)) print(f4进程: {time.perf_counter() - t0:.2f}s)我跑了多次结果相对稳定方案耗时秒相对单线程加速比单线程串行 4 个任务1.961.0x4 线程并行 4 个任务2.050.96x4 进程并行 4 个任务0.982.0x注意看第一行和第二行的对比线程池不仅没有加速反而因为 GIL 竞争和切换开销比串行还慢了约 5%。进程池则拿到了接近 2 倍的加速。这就是典型的被 GIL 囚禁的代码。3.2 IO 密集型任务的实测多线程的自由天地接下来用time.sleep模拟网络等待测试 IO 密集型任务import time from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor def io_heavy(n20): for _ in range(n): time.sleep(0.02) if __name__ __main__: t0 time.perf_counter() for _ in range(4): io_heavy() print(f单线程: {time.perf_counter() - t0:.2f}s) t0 time.perf_counter() with ThreadPoolExecutor(max_workers4) as ex: list(ex.map(io_heavy, [20] * 4)) print(f4线程: {time.perf_counter() - t0:.2f}s) t0 time.perf_counter() with ProcessPoolExecutor(max_workers4) as ex: list(ex.map(io_heavy, [20] * 4)) print(f4进程: {time.perf_counter() - t0:.2f}s)结果才是真正让人觉得Python 多线程也不是完全没用的关键方案耗时秒相对单线程加速比单线程串行 4 个任务1.021.0x4 线程并行 4 个任务0.128.5x4 进程并行 4 个任务0.137.8x为什么 IO 密集任务几乎不受 GIL 影响因为每个线程在time.sleep(0.02)期间都会主动释放 GIL等待的过程中没有锁竞争其他线程可以趁机执行。真实的网络爬虫、接口轮询、文件读写都属于这一类所以 Python 多线程在 IO 场景确实很能打。3.3 如何快速判断自己的任务会被 GIL 卡住一个经验法则如果任务的大部分时间花在等待外部资源网络响应、磁盘 IO、数据库返回多线程效果明显如果任务的大部分时间花在计算循环、数学运算、数据处理多线程几乎注定不如单线程。判断方法很简单——先跑一次cProfile看看热点函数消耗的时间占比如果 CPU 时间远大于 IO 等待时间就果断考虑进程池或改算法。3.4 诊断 GIL 争用的常用工具链如果你想在真实项目里确认是否被 GIL 拖累推荐这三板斧py-spy dump --pid pid实时看每个线程的栈确认是否有大量线程阻塞在 GIL 获取处。cProfile配合pstats定位纯 Python 计算的热点函数看它是不是占据了主要运行时间。Linux 下perf stat -e context-switches,cpu-migrations -p pid观察上下文切换频率和 CPU 迁移次数数值异常高大概率是 GIL 竞争导致的乒乓效应。4. 冲出 GIL 的几种正经方案多进程、协程、C 扩展与原生线程4.1 multiprocessing绕开 GIL 最成熟的方式既然 GIL 锁死在解释器内最简单的思路就是把任务拆到多个进程里每个进程拥有独立解释器和独立 GIL。concurrent.futures.ProcessPoolExecutor是我日常项目里最常用的方案from concurrent.futures import ProcessPoolExecutor def run_task(x): return sum(i * i for i in range(x)) if __name__ __main__: with ProcessPoolExecutor(max_workers4) as pool: results list(pool.map(run_task, [10_000_000] * 4))不过要记住进程池的代价不仅仅是 GIL 的消失还包括进程启动开销尤其 Windows 上用spawn方式启动开销远大于线程任务太小反而赚不回成本。数据序列化任务参数和返回值必须通过 pickle 在不同进程间传递大对象会被频繁复制。跨进程通信需要额外的multiprocessing.Queue、Manager或共享内存复杂度直线上升。我的经验是进程并行适合任务耗时在秒级以上的场景。如果单个任务耗时只有几十毫秒进程间通信和派发的开销就可能吞噬掉并行收益。这时候应该反过来思考——是不是任务粒度太细了能否批量发送比如把一个 list 切片后整块丢给进程。4.2 asyncio把等待变成协作式切换协程是另一种绕过 GIL 的方案而且它干脆不用多线程。asyncio在单线程事件循环里运行多个协程遇到await点就主动让出控制权给事件循环。因为全程只有一个线程根本不发生 GIL 竞争import asyncio async def fetch_one(url): # 模拟网络请求 await asyncio.sleep(0.1) return url async def main(): tasks [fetch_one(fhttp://example.com/{i}) for i in range(10)] results await asyncio.gather(*tasks) print(len(results)) asyncio.run(main())协程比线程好在哪切换成本更低一个进程里可以轻松跑成千上万个协程。但它的局限也非常清晰如果一个协程内部执行的是纯 CPU 计算事件循环会被这个协程阻塞住其他协程全部干瞪眼。对于IO 等待 少量计算的爬虫、网关、Web 后端asyncio 是最优解对于真正吃 CPU 的逻辑要用loop.run_in_executor(None, func)丢给线程池或进程池。4.3 把计算下沉到 C 层numpy、numba、Cython、ctypesGIL 的规则有一条明显的后门C 扩展代码执行期间可以主动释放 GIL。于是把纯 Python 计算变成 C 扩展调用就成了对冲 GIL 的经典姿势。举几个我实际验证过的例子numpy矩阵运算np.dot(a, b)这类操作底层走 BLAS 库C 层执行时不会持有 GIL甚至 BLAS 内部会用多个原生线程并行效果像坐火箭。numba在njit(nogilTrue)装饰的函数里只要模式是nopythonTrue整个函数执行期间都会释放 GIL多线程同时调用时能够真正并行。Cython用with nogil:块包住纯 C/C 代码段在块里不许操作 Python 对象。ctypes调用外部 C 动态库期间默认会释放 GIL适合做封装。from numba import njit, prange njit(nogilTrue) def heavy_sum(arr): total 0.0 for i in prange(arr.size): total arr[i] * arr[i] return total路线其实很清晰把热循环尽量压到 numpy 向量化操作里实在压不下去再用 numba/Cython 做成 C 级别的计算单元。这样即使 GIL 还在它也锁不住你真正耗时的部分。4.4 正在路上的自由线程PEP 703 与 Python 3.13 实验构建2023 年底开始CPython 社区正式把无 GIL 构建提上议程PEP 703Making the GIL Optional in CPython被接受为实验特性。Python 3.13 的 free-threaded build也叫3.13t就可以用--disable-gil选项编译出来在这一版里每个线程并行执行字节码不再受 GIL 限制。但我要给一句冷静的话无 GIL 版本目前并不能直接当作性能银弹。去掉 GIL 之后解释器内部大量曾经依赖锁即安全的隐式保障全部要变成细粒度原子操作和锁某些场景的性能可能比有 GIL 还差。而且大量 C 扩展库需要显式声明支持 free-threaded 模式否则依然可能出问题。作为应用层开发者这几年老老实实掌握上面四类方案比盼着 GIL 消失更实际。5. 判断自己该不该焦虑 GIL真实项目的选型逻辑与避坑经验5.1 先测量再优化瓶颈到底是什么太多人一提到 Python 多线程性能就直接想到都怪 GIL。但我在优化过多个项目后最大的体会是先看瓶颈在哪再决定要不要跟 GIL 硬刚。很多所谓多线程慢实际是共享锁、数据库连接池、日志写入、内存分配这类资源竞争造成的跟 GIL 无关。先跑一轮cProfile -s cumulative把耗时 TOP 10 列出来。我自己的判断流程是如果热点函数名字基本是socket、select、sleep、recvIO 瓶颈直接用 asyncio 或线程池不必为 GIL 焦虑。如果热点名字是int.__add__、numpy之外的纯 Python 循环计算瓶颈要么算法优化要么进程池要么 C 层下沉。如果热点是pickle、Lock.acquire、Queue.get资源调度瓶颈先优化架构再谈语言级并发。5.2 按场景选择并发方案的推荐组合根据我自己的实践把典型任务和推荐方案整理成一张表可以作为初期选型参考任务特征推荐方案理由IO 密集爬虫、Web 请求、文件轮询asyncio 或 ThreadPoolExecutor等待期间释放 GIL线程/协程越多越好CPU 密集、任务可独立分解ProcessPoolExecutor每进程独立 GIL真正并行CPU 密集、计算可向量化numpy / pandas 的内置方法C 层并行无需自定义进程高频小计算、逻辑复杂numba / Cython / C 扩展释放 GIL 的同时保持原生性能混合型IO 为主 少量计算asyncio run_in_executor主循环管 IO子执行器兜底 CPU 任务5.3 我在实际项目里踩过的几个具体坑第一千万别用ThreadPoolExecutor跑纯 Python 循环计算。有一次我把一个特征工程函数丢进 16 线程的线程池结果比 4 线程还慢一度以为服务器 CPU 有问题。后来用py-spy一看16 个线程全部在排队拿 GIL真正执行的永远只有一个。这种代码换成ProcessPoolExecutor后耗时直接降了 60%。第二ProcessPoolExecutor也有调优陷阱。任务粒度太小的时候进程间传递参数的 pickle 开销会超过并行收益。我现在的经验是尽量在map调用时把chunksize调大一些比如一次传 10 个任务给一个进程减少通信次数。另外在 Windows 上进程池默认用spawn启动每次都要重新导入主模块所以启动阶段务必保证主模块里没有重量级初始化逻辑。第三不要在生产代码里随手改sys.setswitchinterval()。我有次为了公平调度把切换间隔从 5ms 调成 0.1ms结果线程上下文切换开销剧增整体性能反而掉了 30%。切换间隔是 CPython 专家们长期调优出来的默认值没有足够的 profiling 证据最好不要动。第四GIL 能提供的线程安全非常有限不要依赖它写并发代码。list.append固然是原子的但检查-修改这种读改写过程完全不是原子的。我用多线程写过一个计数器self.count 1在 GIL 下照样丢更新进一步证明了老老实实加锁或用threading.Lock才是正路。最后分享一个小技巧当我需要在同一份代码里既享受多进程并行又保留 asyncio 的异步管理能力时我会用asyncio.get_running_loop().run_in_executor(ProcessPoolExecutor(...), func, args)把重计算完全托管出去。这样主循环保持极低的调度成本重活又绕开了 GIL 和事件循环的双重限制。这个模式我反复用了两年几乎覆盖了大多数服务端混合负载场景。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑