首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
Python GIL 深度解析:从多线程翻车到并行方案
📅 2026/10/12 3:48:12
✍️ 爱科研究院
👁 阅读 3,247
我第一次在 Python 里正儿八经写并发的时候一度怀疑是电脑坏了。任务很简单8 个线程分别跑一段纯 CPU 计算按道理就算不跑满 8 核至少也比单线程快个三四倍吧。结果 8 个线程跑完的时间不但没变短反而比串行还慢了一点点。查到最后才发现所有线程都在同一把锁上排队这把锁就是 Python 圈子里人人都听过、不少人都没搞懂的 GIL也就是全局解释器锁。GIL 是 CPython 解释器为了简化内存管理而加的一把全局大锁它规定同一时刻只有一个线程能执行 Python 字节码。所以对于纯 CPU 密集型任务多线程基本就是摆设但是对于 I/O 密集型任务它又没那么可怕甚至还有奇效。这篇文章就从这把锁出发把它的原理、影响、绕过方案、排查手段一次性讲清楚。无论你是刚写完一个慢吞吞的线程池还是正在纠结该用 threading 还是 multiprocessing都值得把下面的内容看完。1. GIL 到底锁住了什么从一次 8 线程翻车现场说起1.1 实测代码与翻车输出先说复现场景。用一段很简单的纯 Python 计算函数里面没有任何文件读写、网络请求就是纯粹的整数循环和累加。代码如下import threading import time def count(n: int) - int: result 0 for i in range(n): result i * i return result def run_threads(n_jobs: int 8) - float: numbers [2_000_000] * n_jobs threads [ threading.Thread(targetcount, args(n,)) for n in numbers ] start time.perf_counter() for t in threads: t.start() for t in threads: t.join() return time.perf_counter() - start def run_serial(n_jobs: int 8) - float: numbers [2_000_000] * n_jobs start time.perf_counter() for n in numbers: count(n) return time.perf_counter() - start if __name__ __main__: print(多线程耗时:, run_threads()) print(串行耗时:, run_serial())在我某台普通办公电脑上结果如下多线程耗时: 2.031 串行耗时: 1.874不同机器数字会有波动但趋势非常稳定多线程版几乎不可能明显快过串行版大多数时候还要略慢一点。这和你预期中的“8 个线程并行计算”完全相反。为什么会慢因为 Python 线程虽然能由操作系统调度到多个 CPU 核心上但解释器里永远只有一个线程能真正执行 Python 字节码。线程越多锁竞争越激烈额外花在切换、排队、唤醒上的时间也就越多。于是 CPU 密集型任务不仅没加速还可能被多线程拖得更慢。1.2 为什么 CPython 非要给自己上这把锁很多人第一次知道 GIL 时第一反应是“CPython 的设计者当年是不是偷懒了”。其实不是偷懒GIL 是当年在“实现复杂度”和“运行性能”之间做的一次取舍。CPython 管理对象内存靠的是引用计数。每个 Python 对象都有个引用计数当它被新的变量引用时加 1被删除时减 1计数归零就把内存还回去。问题在于如果两个线程同时在操作同一个对象的引用计数就可能出现“两个线程同时把 1 加成了 2结果应该到 0 却没到 0内存永远不释放”之类的内存泄漏甚至出现更严重的内存损坏。为了保证引用计数绝对安全最简单的办法是给每个对象都加把锁。但对象数量一多细粒度锁会带来巨大的开销和疯狂的死锁风险。GIL 的思路粗暴又实用整个解释器只有一把大锁一次只允许一个线程进来执行 Python 代码。这样引用计数的操作天然就安全了。用生活化类比来说这就像一间厨房只有一个灶台。你可以叫八个帮厨进来洗菜、切菜、备料但真正上灶开火炒菜的人永远只有一个。CPU 密集型任务大家抢的都是那个灶台I/O 密集型任务则不同大家等水烧开、等烤箱结束的时候灶台是空出来的这时候其他帮厨反而能轮流干活。1.3 GIL 在什么时机切换线程CPython 不是等一个线程跑完才换下一个那样会更糟。它会设置一个切换间隔默认大约是 5 毫秒每隔一段时间就强制当前线程释放 GIL让其他线程有机会执行。你也能通过sys.setswitchinterval()修改这个间隔。切换机制通常是在解释器执行完一定数量的字节码指令之后触发。由于强制切换Python 的多线程看起来确实在“一起跑”但不管多少核真正执行 Python 代码的永远是一条执行流。这就是常说的并发而不是并行。这里有个小陷阱如果你天真地把switchinterval调小希望线程之间切换更公平、响应更快通常只会让 CPU 密集场景更慢因为切换本身有开销。反过来调大能减少切换次数适合少量线程做长时间计算但程序对外部的响应会变差比如 GUI 程序会显得卡顿。所以日常开发我几乎不建议改这个值默认值已经算是一个相对合理的妥协。2. 三个实验看清多线程什么时候是摆设什么时候是真助手2.1 纯 CPU 计算8 个线程跑不过 1 个线程第一个实验前面已经复现了。纯 CPU 计算函数里没有任何等待每个线程一拿到 GIL 就想一直算下去但算不到 5 毫秒又被强制让位。线程越多锁被抢来抢去的次数越多额外开销越大。实际项目里纯 CPU 热点经常表现为正则表达式大量匹配、纯 Python 的数值计算、字符串解析、JSON 序列化等。如果你用多线程处理这类任务表现往往都是没有加速甚至变慢。更严谨地说GIL 锁的是“执行 Python 字节码”。如果你调用的底层库是 C 语言实现并且在执行过程中主动释放了 GIL那情况又会不同。这一点后面专门讲。2.2 I/O 等待场景线程的翻身仗接着做第二个实验。把任务改成模拟 I/O 等待用time.sleep()或者真实的网络请求、数据库查询都可以。下面用 sleep 演示因为它在等待时会释放 GIL逻辑和真实 I/O 一致import threading import time def io_task(seconds: float) - None: time.sleep(seconds) def run_io_threads(count: int) - float: start time.perf_counter() threads [threading.Thread(targetio_task, args(2,)) for _ in range(count)] for t in threads: t.start() for t in threads: t.join() return time.perf_counter() - start if __name__ __main__: print(8 个线程并发 sleep 2 秒:, run_io_threads(8))结果大概是 2 秒左右。如果你用单线程串行调用 8 次总共要 16 秒。差距一下就出来了。原因是time.sleep()的底层实现会让线程进入等待状态这时候它会主动释放 GIL操作系统也能把 CPU 让给其他线程。真实场景里的磁盘读取、网络请求、数据库查询大部分时间都花在等待上而不是花在计算上。所以线程池在多线程 I/O 场景里不仅不是摆设反而是提升吞吐量的主力。我在实际项目里就见过这样的情况某个脚本需要批量请求一批接口一开始串行请求耗时接近 60 秒。改成 8 个线程同时请求以后总耗时直接掉到 13 秒左右。当时一个组里既有老 Python 开发者也有新手不少人都以为多线程一定会被 GIL 拖死。事实证明场景对了线程非常有用。2.3 并发和并行别混淆了很多人把并发和并行当成一回事这会导致理解偏差。并行是真正地用多个 CPU 核心同时执行多条指令并发只是多个任务在时间上重叠推进可能交替执行而不是同时执行。GIL 下的多线程属于并发不是并行。一台收银机前排队虽然队伍里有好几个顾客在往前走但同一时刻只有一个人能在收银台结账多个收银台才是真正的并行。CPU 密集型任务需要的是“多个收银台”也就是多个进程I/O 密集型任务需要的大多是“让顾客在等待时不让收银台闲着”多线程就够了。把这两个定义刻在脑子里再回去看多线程性能问题思路会清晰很多。3. 绕过 GIL 的四条实战路线3.1 进程池把每个线程换成独立解释器既然每个线程共享一个解释器那最直接的思路就是启动多个解释器。每个进程拥有独立的 Python 解释器、独立的内存空间也各自有一把 GIL。多核 CPU 上跑多个进程就真的能并行。标准库提供了multiprocessing更推荐使用的是concurrent.futures.ProcessPoolExecutorAPI 友好适合任务分发。刚才那个 CPU 密集型函数改成多进程版from concurrent.futures import ProcessPoolExecutor import time def count(n: int) - int: result 0 for i in range(n): result i * i return result def run_process(n_jobs: int 8) - float: numbers [2_000_000] * n_jobs start time.perf_counter() with ProcessPoolExecutor(max_workersn_jobs) as executor: list(executor.map(count, numbers)) return time.perf_counter() - start if __name__ __main__: print(8 进程耗时:, run_process())我这里跑下来大约 0.4 秒对比前面的串行 1.8 秒提升非常明显。注意如果你的机器 CPU 核心数只有 4那开 8 个进程最多也只会有接近 4 倍的加速开多了反而会因为上下文切换变慢。进程方案也有代价。进程之间不共享常规 Python 对象要传递数据和结果只能通过序列化、进程间通信来完成。如果每个任务本身很小数据搬运的成本可能抵消并行收益。所以用进程池时要确保任务块足够大、计算量足够重否则就应该考虑能不能合并任务。3.2 让底层 C 库释放 GIL前面说 GIL 锁的是 Python 字节码但很多底层扩展库在执行真正的计算时会主动释放 GIL。Python 的 C 扩展 API 提供了Py_BEGIN_ALLOW_THREADS和Py_END_ALLOW_THREADS这样的宏允许 C 代码在计算过程中暂时放开 GIL等计算结束后再重新获取。这意味着一件很重要的事如果你的热点计算存在于numpy、zlib、hashlib、某些正则库等底层优化过的 C 扩展里线程是可以并行执行的。这不是说 Python 代码可以绕过 GIL而是说 GIL 没有在整个生命周期内锁住所有底层的计算过程。很多人在线程里用numpy做大数组运算发现多线程有加速原因就在这里。不过纯 Python 写出来的循环没有任何办法在自己代码里释放 GIL。你不可能在def my_calc():中间手动插入“释放 GIL”的指令。所以如果你的热点是手写 Python 循环要么用 C 扩展、cython重写热点函数要么改用进程方案。3.3 异步 IO单线程也能扛住成千上万连接GIL 的存在并不阻碍 Python 写出高并发网络服务。asyncio在单线程里维护一个事件循环成千上万个连接同时挂着谁的数据准备好了就处理谁。它根本没有“多线程竞争 GIL”的问题因为全程只有一个线程。对比一下多线程方案每个任务一个线程线程占用内存、上下文切换有开销线程数太多系统也调度不过来异步方案任务不是线程而是一个个事件回调或者协程对象内存占用远小于线程所以可以开很多很多并发。一个典型的异步下载示例import asyncio import aiohttp async def fetch(session, url): async with session.get(url) as resp: return await resp.text() async def main(): async with aiohttp.ClientSession() as session: tasks [fetch(session, https://example.com) for _ in range(100)] results await asyncio.gather(*tasks) return len(results) if __name__ __main__: total asyncio.run(main()) print(获取页面数量:, total)真实环境里asyncio经常和多线程混着用比如某些第三方阻塞型库不能直接放进事件循环就扔到线程池里跑异步代码再等待结果。这种组合在 I/O 密集型服务里很常见。3.4 自由线程模式Python 3.13 的新方向Python 3.13 引入了实验性的 free-threaded build也就是不启用 GIL 的构建版本。用这样的解释器跑多线程理论上 CPU 密集任务也能真正并行。但这是很新的功能还处于实验阶段第三方库的兼容性和性能都未必达标。我个人建议普通项目现阶段不要为了无 GIL 去冒险切换运行时。可以关注生态发展等主流的 C 扩展都适配完成、官方文档把成熟度再往上提一档再考虑迁移。现阶段绕过 GIL 最可靠的方案仍然是进程和底层库并行。4. 定位 GIL 瓶颈的排查清单与常见误判4.1 先判断瓶颈到底在不在 GIL看到“多线程慢”就怪 GIL是新手最容易犯的错误。实际上 I/O 慢、锁竞争、线程创建开销、任务分得过细都会导致类似表现。我自己的排查思路是这样第一步看 CPU 使用率。用一个多线程程序跑 CPU 密集型任务时如果任务管理器里只有一个核接近 100%其他核都在旁边看热闹那大概率是 GIL 或某个全局锁卡住了。如果多个核都有负载但上不去可能需要进一步分析。第二步确认热点位置。可以用cProfile看每个函数的累计耗时也可以用py-spy dump查看运行中的线程当前停在哪一帧。如果发现绝大多数线程都挤在同一段纯 Python 计算代码里那就可以怀疑 GIL。如果你看到线程卡在自定义的threading.Lock.acquire()里那是你自己的锁冲突不该让 GIL 背锅。第三步做替换实验。把线程改成进程跑一下同样任务如果耗时明显下降说明计算部分确实缺并行如果耗时还是差不多瓶颈就不在 GIL可能在磁盘 I/O、网络带宽或上游服务本身。4.2 确认是 GIL 之后还能从哪挤性能如果确认瓶颈就是 GIL而你又不想重构整个架构可以从下面几个方向入手把热点计算从 Python 循环迁到成熟库。比如用numpy代替手写数组循环很多底层实现会释放 GIL线程池就有机会并行。调整任务粒度再上进程池。进程池适合大块独立计算不要频繁提交微小任务否则序列化和进程通信会吃光收益。慎重调整切换间隔。在小规模 CPU 密集场景下sys.setswitchinterval(0.02)这类调整可以减少切换开销但会降低线程响应性不适合交互式程序。减少共享可变状态。多线程问题里自定义锁冲突比 GIL 更容易把程序拖垮。能不可变就不可变能分片就分片。4.3 一张表整理多线程性能误判我把平时最常见的误判整理成了一张速查表遇到性能问题可以快速对号入座现象容易误判的原因更可能的原因推荐手段线程池跑纯 Python 计算没提速GIL 导致无法并行热点就是解释器字节码执行换成 multiprocessing 或底层扩展线程多了反而明显变慢GIL 释放太频繁线程切换开销、系统调度压力增大减少线程数合并小任务I/O 并发仍然慢误以为被 GIL 卡死网络带宽、磁盘速度、目标服务限流异步 IO 配合限流和队列加锁后性能断崖下降以为 GIL 问题自己加的自定义锁竞争太激烈缩小临界区改用无锁数据结构多线程调用 numpy 有加速GIL 不存在了C 扩展主动释放了 GIL可继续用线程但留意线程安全这张表不是教条而是一个排查框架。真正遇到具体问题还是要结合 profiling 结果来看。5. 最后分享一个朴素判断口诀这几年代码写下来我自己总结了一句话计算密集找进程等待密集用线程海量连接上异步想要极致去写 C 扩展。每次思路乱掉我就把这句话拿出来对一对。还有一点经验很想多说不要为了并发而并发。有人看到“8 核机器”就强行开 8 个线程最后发现任务本身只有 50 毫秒计算加 5 秒 I/O 等待那并行计算根本没意义把线程数控制住让 I/O 能并发就已经很好了。反过来有人明明在做 CPU 密集计算却执念于用线程池调参调了一个晚上换成进程之后三分钟就解决了问题。先想清楚你的程序在等 CPU还是在等外设再决定要不要骂 GIL。
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/10/12 3:43:11
具身智能创新原理(40):基于TVA的潜在动力学鲁棒化与语义表征对齐策略
2026/10/12 3:43:11
具身智能创新原理(32):一种基于TVA具身架构的共享控制安全交互机制
2026/10/12 3:43:11
Agent上下文构建实战:ContextBuilder设计与工程实践
2026/10/12 4:43:16
具身智能创新原理(1):TVA架构与World模型协同创新机制研究
2026/10/12 4:43:16
【Xilem基础语法学与练】第7课:Xilem与Masonry底层构建技术解析
2026/10/12 4:43:16
【Xilem 0.4 基础语法学与练】第11课:为什么其他 Rust 框架不是一切皆设计图
2026/10/12 4:43:16
Vibe Coding 基础版毕业自测与进阶准备:五大进阶信号、全栈技术栈与 4-6 周学习路径规划
2026/10/12 4:43:16
openJiuwen agent-core 检索模块 Embedding 基类全解析:统一文本嵌入接口的设计与实战
2026/10/12 4:38:16
Psalm NullPropertyAssignment 详解:在 null 上赋值属性的检测机制与修复方案
2026/10/12 0:02:51
你的 AI 编程 CLI 配置管理工具来了:用 TaoToken 统一管理 Claude Code 与 Codex 的 Base URL
2026/10/12 0:02:51
Susi AI API实战指南:susi_alexa_skill如何用Node.js调用chat.json获取智能回答
2026/10/12 0:02:51
换新电脑了?KeyStats 恢复码数据找回完全指南,端到端加密统计一键重建
2026/10/11 0:00:10
流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南
2026/10/11 0:00:10
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别
2026/10/11 0:00:10
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容
2026/10/11 19:13:46
我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
2026/10/11 21:41:11
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026/10/11 23:43:10
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)