拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深入理解Python GIL:原理剖析、性能影响与并发优化方案

深入理解Python GIL:原理剖析、性能影响与并发优化方案 两年前我在给一个数据清洗服务做并发优化当时想着Python多线程简单省事直接ThreadPoolExecutor开 8 个线程跑一轮批量计算。结果呢8 个线程跑完比单线程还要慢 30%。后来我才意识到自己撞上的正是 Python 里最出名、也是被吐槽最多的那个设计——全局解释器锁也就是 GIL。GIL 是 CPython 解释器的一个核心机制也是无数 Python 开发者面试时绕不开的考点。它决定了你的多线程程序到底是真并行还是只是“看起来在并行”。这篇文章我会把自己排查和解决 GIL 问题的过程完整拆开从内存管理层面说说它为什么存在从线程调度层面讲讲它怎么工作再用可复现代码展示它对性能的真实影响最后给出多进程、协程、本地扩展这几条实用解决路线。无论你是刚入门 Python、正在写爬虫或者做数据分析还是在背面试题准备跳槽这篇文章都值得你花十分钟看完。1. GIL是什么先搞清楚它保护的是什么1.1 从引用计数说起要理解 GIL得先从 Python 的对象内存管理机制讲起。CPython 对每个对象都维护了一个引用计数这个计数表示当前有多少个变量、容器、参数在引用这个对象。当引用计数降为 0 的时候对象会被立即回收它占用的内存也会被释放。问题就出在这个计数上。obj.ref_count 1和obj.ref_count - 1这两操作在底层会被编译成多条机器指令不是一个原子操作。如果两个线程同时对一个对象的引用计数做加减就可能出现竞态条件——计数被覆盖、内存被提前释放甚至出现双重释放导致解释器崩溃。最简单的解法就是给整个解释器加一把大锁任何时刻只允许一个线程在执行 Python 字节码。这就是 GIL 的由来了。你可以把 GIL 理解成一个单位门口的闸机每次只放一个人进去干活出来之后下一个人才能进去。至于谁是下一个人由调度机制决定。这里要区分一个概念GIL 保护的是解释器内部状态的一致性不是你的业务数据。它保证的是“引用计数不会因为多线程而错乱”至于你的共享变量、数据结构逻辑是否正确它完全不管。所以很多新手误以为“有 GIL 我就可以不用加锁了”这个想法非常危险后面我会专门讲。1.2 为什么GIL始终没有消失既然 GIL 限制并行性能大家自然会问为什么 Python 不用别的方式实际上 GIL 的诞生有历史原因。1992 年 Python 刚引入多线程支持时多核 CPU 并不普及单线程安全加上简单实现是最务实的选择。而且很多 C 扩展库——比如早期的第三方模块——并不是线程安全的。它们依赖 GIL 来自动获得互斥保护如果贸然去掉 GIL整个生态都要重写。还有一个更现实的原因移除 GIL 会显著降低单线程性能。为了保证引用计数在多线程下的正确性只要不持有 GIL 的线程去访问对象就需要为对象加更细粒度的锁这等于每次对象操作都要付出额外的开销。近几年的测试表明在标准 CPython 中实现无 GIL 的版本单线程性能大概会下降 10% 到 30%。对于大多数应用来说这个牺牲太大收益却不明显。所以 GIL 成了一种“历史遗留包袱”讨论移除它的声音一直没停过但直到近两年才真正出现实验性的无 GIL 构建版本。在可预见的未来**** 依然是绝大多数 Python 从业者每天都会遇到的机制。我们不一定要喜欢它但必须理解它。2. GIL的工作原理抢锁、干活、让位2.1 线程执行的真正流程GIL 的调度机制其实并不复杂。CPython 每执行一段时间的字节码就会去检查当前线程是否应该释放 GIL。这个时间间隔是可以用sys.getswitchinterval()查到的在 Python 3 里默认是 5 毫秒。也就是说一个线程最多连续持有 GIL 5 毫秒之后就会被要求让位让其他线程有机会运行。除了时间到点之外还有一类情况会主动释放 GIL那就是线程遇到阻塞操作。比如time.sleep()、文件读写、网络请求、数据库查询以及threading.Lock的等待都会让线程立刻交出 GIL。这个设计非常关键也是“GIL 影响不是一刀切”的根本原因。我用一个生活化的类比来解释在食堂排队打饭GIL 就像是打饭窗口的唯一阿姨。她给每个学生打饭最多打 5 秒时间一到就必须轮到下一个人。如果某个学生说要吃一份特别复杂的菜阿姨需要等着厨师慢慢做那阿姨就不会傻站着等而是先给下一个人打饭等菜做好了她再回来。放到 Python 里纯 CPU 计算任务就是那个“做特别复杂的菜”线程哪怕死循环 1 分钟也算不完其他线程就得干等。而 I/O 操作是主动松开阿姨的行为其他线程可以趁机执行。这直接导致了大家在实战中最常遇到的结论GIL 对 CPU 密集型多线程是灾难对 I/O 密集型的多线程影响不大。2.2 为什么有了GIL你的代码还是需要锁我遇到太多初学者问这个问题“既然同一时刻只有一个线程能执行那我程序里的共享变量不是天然安全吗为什么教程里还要讲threading.Lock”原因在于GIL 的释放是发生在字节码执行期间的两个指令之间而不是整个函数执行完之后。一个看起来像原子的操作比如balance 1在 Python 层面是一行代码但底层是“读取数值-计算加法-写回结果”三步。如果第一个线程在“读取数值”之后、“写回结果”之前被切换出去第二个线程也去读了同一个旧值两边算完再写回来最终结果就会少加一次。这类问题在一个计算量较大、步骤较多的多线程程序里很容易复现。我自己写过一个小 demo开 10 个线程每个线程对同一个列表里的计数器i 1执行一万次最终结果永远不是期望的十万大概率会缺几百到几千次。加了锁之后结果才稳定。GIL 保证的是解释器状态不崩溃threading.Lock保证的是业务数据逻辑不出错。两者层级不同并不冲突。不要因为有了 GIL 就忽略锁的使用这是写多线程程序最基础也最容易踩的坑。3. GIL对项目性能的真实影响别再让多线程白等了3.1 先分清CPU密集和I/O密集聊 GIL 的最严重后果之前先帮大家建立一个判断模型。任何程序的核心任务基本都能被归类为两种CPU 密集型计算密集型和 I/O 密集型。CPU 密集型的特征是任务的大部分时间都消耗在计算上比如数值模拟、图像处理、复杂算法的循环遍历、大数据集的运算。这类任务期待真正用到多核并行。I/O 密集型的特征是任务大量时间都在等待外部系统返回比如网络爬虫等响应、文件读写、数据库查询。这些等待不消耗 CPU线程往往是被“闲置”的。GIL 影响的只有 CPU 密集型。因为 I/O 密集型任务在等待期间会自动释放 GIL其他线程完全可以利用这段时间去执行。这也就是为什么很多人用 Python 多线程写爬虫可以把效率提升好几倍而用多线程做复杂数组运算反而越开越慢的原因。方向搞反了一半优化就会起反效果。3.2 实测多线程跑CPU密集任务反而更慢下面这段代码我建议你直接复制到自己机器上跑一遍眼见为实。我用一个循环求和来模拟 CPU 密集任务分别用单线程、多线程、多进程三种方式执行同一批任务import time from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor def heavy_calc(n): total 0 for i in range(n): total i ** 2 return total def run_single(): start time.perf_counter() for _ in range(8): heavy_calc(2_000_000) return time.perf_counter() - start def run_thread(): start time.perf_counter() with ThreadPoolExecutor(max_workers8) as pool: list(pool.map(heavy_calc, [2_000_000] * 8)) return time.perf_counter() - start def run_process(): start time.perf_counter() with ProcessPoolExecutor(max_workers8) as pool: list(pool.map(heavy_calc, [2_000_000] * 8)) return time.perf_counter() - start if __name__ __main__: print(f单线程: {run_single():.3f}s) print(f多线程: {run_thread():.3f}s) print(f多进程: {run_process():.3f}s)我当时在一台 8 核机器上的实测结果大概是单线程 0.31 秒多线程 0.42 秒多进程 0.09 秒。多线程不仅没有加速反而比单线程还慢了 30% 左右因为线程之间的上下文切换、GIL 的反复争夺带来了额外开销。多进程则是直接把 8 个 CPU 核全部用起来了时间只有单线程的四分之一不到。你再试试把任务换成time.sleep(0.2)这种模拟 I/O 等待的场景单线程跑 8 次要 1.6 秒多线程只需要 0.2 秒。两组数据放一起GIL 的特性就非常直观了。3.3 受GIL影响最大的几类场景结合上面的测试我梳理一下在实际项目里最容易受 GIL 影响的操作纯 Python 实现的算法逻辑比如大规模循环、递归计算、动态规划、暴力搜索。字符串处理、正则匹配、数据清洗中的复杂转换。使用纯 Python 数据结构做高频读写和排序。机器学习特征工程中大量自定义 Python 函数处理数据。混合场景里的瓶颈环节比如 Web API 里有一个耗时很长的同步计算接口。值得注意的是并不是所有数值计算都会触发 GIL。比如用了 NumPy 的数组运算底层是 C 实现并且会在计算时主动释放 GIL。这时候多线程反而能并发执行多个独立的 NumPy 操作充分利用多核。很多人只记得“GIL 限制多线程”却忘了“很多 C 扩展会放锁”这一点这也是我后面要讲的解决方案之一。4. 解决GIL问题的四种主流路线4.1 方法一改用多进程绕开限制对付 GIL 最直接、应用最广泛的方案就是多进程。每个 Python 进程都有自己独立的解释器和内存空间也都有自己的 GIL互不干扰。concurrent.futures.ProcessPoolExecutor是上手最快的工具把上一节代码里的ThreadPoolExecutor换成ProcessPoolExecutor就能享受到多核加速。但多进程不是万能钥匙它有几个代价需要注意。首先是进程间通信IPC和序列化开销。传入和返回的数据都要通过 pickle 序列化和反序列化所以如果你的任务参数、结果是特别大的 DataFrame 或者复杂嵌套对象传输成本可能直接抵消并行收益。其次每个进程的内存开销比线程大得多任务量很大的时候需要控制进程数量不能无脑开几十个。第三Windows 上使用多进程必须把入口代码放在if __name__ __main__下面否则会因为重新导入模块导致无限递归创建子进程。我自己的经验是当单个任务的数据交换量不大但计算时间明显很长大于几百毫秒时多进程的收益最明显。如果任务是“切分大块数据、每个子进程处理完再汇总”推荐用multiprocessing.Pool配合imap做懒加载迭代可以避免一次性把海量结果全部怼回内存。4.2 方法二协程与异步把等待时间还回去如果任务的瓶颈在 I/O 等待上你其实不需要多核你需要的是单线程内的高效调度。协程和异步编程正好解决这个问题。asyncio是 Python 官方的异步框架它只有一个线程通过事件循环在协程之间切换。当一个协程遇到网络请求或者 sleep 等耗时操作时会主动让出控制权让其他协程运行。这样单线程就能处理成千上万个并发连接。我做一个简单的对比用asyncio.gather发起 8 个每个需要 0.2 秒的模拟请求整体耗时接近 0.2 秒而不是 1.6 秒。import asyncio async def io_task(): await asyncio.sleep(0.2) async def main(): await asyncio.gather(*(io_task() for _ in range(8))) start asyncio.get_event_loop().time() asyncio.run(main()) print(f耗时约: {asyncio.get_event_loop().time() - start:.3f}s)需要注意的是协程虽然能提供极高的并发能力但它本质还是单线程。如果你把纯 CPU 密集操作放进协程照样会被 GIL 卡住。比如在协程里写while True: total 1这种死循环整个事件循环都会被它堵死。所以合适的做法是I/O 等待用协程调度遇到真正的 CPU 计算要么交给线程池进程池要么交给本地扩展。另外很多初学者会被async def和await迷惑以为“用了就是异步”。实际上只有事件循环里没有阻塞调用协程才能发挥威力。如果你在一个协程里直接调用requests.get()这种同步阻塞库整个程序依然会被卡死。配合httpx.AsyncClient或者aiohttp才算是真正的异步请求。4.3 方法三把热点计算交给本地扩展GIL 保护的是 Python 字节码不是 C 代码。只要 C 扩展在运行时不持有 GIL就能和 Python 线程并发执行。这也解释了为什么 NumPy、Pandas 的部分运算在多线程环境下依然可以并行。最省事的路线是用好已有的库。比如能在 NumPy 里向量化运算的就不要一层层 Python for 循环去实现。NumPy 内部不仅用 C 优化还会在长时间计算时主动释放 GIL让你其他层的 Python 线程不被完全阻塞。如果确实有自定义算法要优化可以采用 Cython 或 pybind11 写本地扩展在计算段用Py_BEGIN_ALLOW_THREADS和Py_END_ALLOW_THREADS宏显式释放 GIL。下面是一个 Cython 的示意结构# cython_gil_demo.pyx from cython.parallel cimport prange cpdef double heavy_compute(double a, double b): cdef double result 0 with nogil: # 在这个块内GIL 被释放可以并行执行 result a * b a / b return result这个方法技术门槛相对高一些需要重新编译扩展但收益也非常直接计算密集代码跑在 C 层可以真正利用多核。我之前做金融风控的特征计算时就是靠 Cython 把一段 40 分钟的 Python 循环优化到了 4 分钟效果相当夸张。4.4 方法四选择其他Python实现或尝鲜无GIL版本如果你想从根上不受 GIL 约束可以考虑换 Python 实现。比如 Jython 运行在 JVM 上没有 GILIronPython 运行在 .NET 平台上也没有 GIL。但这两个实现的生态更新很慢多数第三方库和新语法特性跟不上 CPython用起来的限制比 GIL 本身还麻烦。更值得关注的是 Python 官方社区近几年的动静。PEP 703 提出了无 GIL 构建的方案后续版本已经引入了实验性的 free-threaded 构建也就是不依赖 GIL 的 CPython 特殊版本。不过这种版本目前还处于过渡期很多主流库需要重新编译适配直接用于生产环境风险很大。我的建议是从侧面观察趋势——暂时不要在生产环境上把所有希望押给“无 GIL 版本”但可以提前检查自己的核心依赖是否声明了 free-threaded 兼容性。真正能立刻落地的还是多进程和本地扩展这两条路。5. 排查、避坑和一些容易被忽略的细节5.1 误区一GIL就是线程锁GIL 和普通线程锁在概念上经常被弄混。GIL 保证的是整个解释器同时只执行一个线程的字节码它的粒度是整个进程。而线程锁比如threading.Lock保护的是特定的共享资源粒度由代码自己控制。有 GIL 不意味着共享数据绝对安全前面第 2.2 节已经用例子说明了。反过来即使你给所有共享数据都加了锁也无法突破 GIL 对 CPU 并行能力的限制。两者解决的完全不同的问题GIL 是解释器级安全线程锁是业务级安全。排查并发问题的时候要分清到底是哪种锁在起作用才能对症下药。5.2 误区二只要用多核就一定要换掉GIL很多人在服务器上给 Python 程序加上multiprocessing之后发现性能提升并不明显甚至更慢了。问题往往出在数据通信上。比如一个需要频繁消费外部消息并做复杂计算的场景如果每个子进程都要从队列里拿大块数据、算完再传回来序列化和 IPC 的损耗可能远超计算本身。这时候正确的思路是重新划分任务边界把计算密集的独立小任务分发给进程池把 I/O 密集的部分留在主进程用协程处理。不要把所有东西都切碎扔进进程池。画一张简单的线程分工图比盲目上多进程更管用。5.3 几个实用排查技巧遇到“程序变慢但搞不清是不是 GIL 的问题”时可以用下面几个方法快速定位用py-spy dump查看正在运行的 Python 程序的线程栈。如果多个线程全部卡在同一段纯 Python 运算代码里且 CPU 占用率不足 100%那大概率是 GIL 在互相等待。用cProfile或者profile对关键函数做性能剖析。如果耗时集中在纯 Python 循环、字符串处理等地方考虑向量化或本地化。观察进程 CPU 占用率。如果开了 8 个线程但整体 CPU 占用长期维持在 100% 以下单核满载状态基本可以判定多线程没有吃到多核红利。用一个最小值测试法把小任务先跑一遍再放大到大任务。如果小任务并发加速很明显大任务反而退化重点检查内存分配、GC 频率和数据复制开销。这几个手段我基本每遇到性能玄学问题都会先过一遍。多数情况下性能问题并不只在 GIL 这一个点上“数据结构没选对”“频繁创建销毁对象”“GC 被触发得太勤”也是常见元凶。5.4 如果你的项目正在被GIL折磨先别急着推翻重来最后分享一点个人经验。GIL 问题听上去很吓人但实际项目里真正卡在你的 Python 业务代码上的往往只占整体运行时的一小部分。很多应用的瓶颈在数据库、网络、磁盘 I/O 上换掉 GIL 并不会带来质的飞跃。我处理一个数据分析平台时起初也想用多进程重写全部计算逻辑后来用 profile 一分析发现 60% 以上时间花在数据库读取和网络等待。我把这部分改成异步并发后整体耗时下降了 70%核心计算函数基本没动。所以碰到性能问题第一反应不应该是“Python 有 GIL我要怪它”而是先做测量。先用cProfile定位热点再针对热点决定是切多进程、上协程还是改本地扩展。GIL 只是问题清单里的一项不是所有问题的最终答案。我记得最早啃这些概念的时候最深刻的体会就是“多线程、多进程、异步”这三兄弟没有一个是银弹。谁适合你的场景谁才是正确选择。希望这篇文章能让你少走一点我当年的弯路遇到并发和 GIL 的讨论时不再只是背结论而是能真正从原理层面给出判断。
返回列表