Python GIL 深度解析:从多线程翻车到并行方案
我第一次在 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。

相关新闻

Dev Container 并行生命周期脚本执行:object 语法原理、规范与实战

Dev Container 并行生命周期脚本执行:object 语法原理、规范与实战

开发工具 【免费下载链接】spec Development Containers: Use a container as a full-featured development environment. 项目地址: https://gitcode.com/gh_mirrors/spec2/spec 点击查看 免费下载 导读 本文围绕 Development Container Specification&#xff0…

2026/10/12 3:48:18 阅读更多 →
LangChain检索与文档:文档加载、切分、向量化与检索调优实践

LangChain检索与文档:文档加载、切分、向量化与检索调优实践

1. 从模型对话到检索增强:为什么这块值得单独拎出来讲如果你已经跟着这个系列用 LangChain 搭过几次基本对话链,大概率会产生一个疑惑:模型生成能力都这么强了,上下文窗口也越做越大,为什么还要搞一套"检索与文档…

2026/10/12 3:48:17 阅读更多 →
Visual Studio Code 1.53(2021 年 1 月)更新全解:工作台、调试、Notebook 与扩展 API 深度指南

Visual Studio Code 1.53(2021 年 1 月)更新全解:工作台、调试、Notebook 与扩展 API 深度指南

文档教程 【免费下载链接】vscode-docs Public documentation for Visual Studio Code 项目地址: https://gitcode.com/gh_mirrors/vs/vscode-docs 点击查看 免费下载 本文全面解读 VS Code 2021 年 1 月发布的 1.53 版本(对应仓库文档 release-notes/v…

2026/10/12 3:47:17 阅读更多 →

最新新闻

量子开发者人才缺口百万?入门技能图谱与实操路径全解析

量子开发者人才缺口百万?入门技能图谱与实操路径全解析

一份“2030年量子开发人才缺口达百万”的预测,最近在朋友圈被转得很猛。我第一反应是:这数字靠不靠谱先放一边,“量子开发者”到底是个什么工种,多数人其实说不清楚。作为写过几年经典软件、又花了不少时间钻进量子计算这个交叉领…

2026/10/12 4:44:49 阅读更多 →
闲置PS5变身标准媒体终端与测试机:AnyPS5配置指南

闲置PS5变身标准媒体终端与测试机:AnyPS5配置指南

把“AnyPS5”这个名字扔上来的时候,估计会有人下意识往越狱、固化那类方向想。我先把话说清楚:我这里的AnyPS5不是破解工具,也不是某个隐藏系统,而是我这段时间在工作室里把几台PS5翻来覆去折腾之后,沉淀出来的一套“任…

2026/10/12 4:44:49 阅读更多 →
Excel 关键指标 Top-N 高亮导出实战:SenseNova-Skills top-value-coloring 技能深度解析

Excel 关键指标 Top-N 高亮导出实战:SenseNova-Skills top-value-coloring 技能深度解析

AI 技能人工智能深度研究数据分析媒体生成 【免费下载链接】SenseNova-Skills Modular SenseNova skills for building AI-powered office assistants and productivity workflows 项目地址: https://gitcode.com/gh_mirrors/se/SenseNova-Skills 点击查看 免费下载…

2026/10/12 4:44:49 阅读更多 →
OpenCore Legacy Patcher 完整指南:2007—2017 老 Mac 免费安装 macOS Big Sur 到 Sequoia 新系统

OpenCore Legacy Patcher 完整指南:2007—2017 老 Mac 免费安装 macOS Big Sur 到 Sequoia 新系统

OpenCore Legacy Patcher 完整指南:2007—2017 老 Mac 免费安装 macOS Big Sur 到 Sequoia 新系统 【免费下载链接】OpenCore-Legacy-Patcher Experience macOS just like before 项目地址: https://gitcode.com/GitHub_Trending/op/OpenCore-Legacy-Patcher …

2026/10/12 4:44:49 阅读更多 →
Claude Code + MCP 实操:从空文件夹到可玩 Unity 游戏的全自动开发闭环

Claude Code + MCP 实操:从空文件夹到可玩 Unity 游戏的全自动开发闭环

开年这几个月,AI 辅助编程的玩法算是彻底变天了。以前大家讨论的是"AI 能不能帮你写代码",现在的问题已经变成"AI 能不能直接替你完成一个独立开发者的全套工作流"。我今天想聊的,就是我最近反复折腾又实测过好几轮的完整…

2026/10/12 4:44:49 阅读更多 →
OpenClaw技能合集:从Clawdbot到Moltbot的Agent Skill精选与TaoToken接入实践

OpenClaw技能合集:从Clawdbot到Moltbot的Agent Skill精选与TaoToken接入实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 4:43:48 阅读更多 →

日新闻

复古胶片颗粒感噪点合成器:Canvas ImageData 像素高斯杂色注入算法

复古胶片颗粒感噪点合成器:Canvas ImageData 像素高斯杂色注入算法

在数码相机、高清显示屏与现代矢量图形技术高度发达的今天,画面可以做到绝对的锐利、平滑与无瑕。然而,当一张秋日手账插画或拍立得照片过于“平整无瑕”时,往往会散发出一种冰冷生硬的“数码塑料感(Digital Plasticity&#xff0…

2026/10/12 0:00:59 阅读更多 →
活字印刷古籍线装排版:Canvas 竖排文字与栏线自适应算法

活字印刷古籍线装排版:Canvas 竖排文字与栏线自适应算法

在现代网页与移动端设计中,横排(Horizontal Layout)早已经成为了绝对的主流。然而,当我们翻开泛黄的线装古籍、宋版木刻诗集,或是欣赏一张茶道雅集的手写便签时,那种**自上而下纵向书写、自右向左逐列铺展&…

2026/10/12 0:00:59 阅读更多 →
周日晚间的“精神松绑减震器”:无压力情绪倾倒箱与温和轻声陪伴

周日晚间的“精神松绑减震器”:无压力情绪倾倒箱与温和轻声陪伴

每到周日的晚上八点到十点,很多人心里都会悄悄亮起一盏警示灯。 在心理学上,这种现象有一个专门的称谓——“周日夜晚焦虑症(Sunday Scaries)”。明天又是周一,闹钟又要重新在七点响彻卧房;脑海里仿佛有一个…

2026/10/12 0:00:59 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/12 0:16:30 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/12 0:16:38 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/12 0:16:43 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 10:45:37 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 14:36:53 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 14:36:54 阅读更多 →