Python GIL深度解析:多线程为什么慢,以及如何绕过?
发布时间:2026/10/13 16:37:03 锦皓数字建站

写Python的人应该都遇到过这个场景为了提高处理速度把一批计算任务塞进8个线程然后打开任务管理器发现CPU只有一两个核在卖力其他核心全程围观。更反直觉的是线程数量从8加到16程序不但没变快偶尔还会更慢。很多人第一反应是自己代码写错了或者怀疑调度器出了问题但真正的问题大概率出在Python解释器自带的GILGlobal Interpreter Lock全局解释器锁上。它就像一个房间里只放了一把钥匙不管进程里启动了多少线程同一时刻只有拿到钥匙的那一个线程能执行Python字节码。纯计算类的多线程任务在CPython下面很难吃满多核也不奇怪。这个主题值得系统拆开讲一遍因为网上关于GIL的说法分歧很大有人说是智商税有人说它是Python性能的万恶之源还有人拿到I/O密集任务开多线程发现提速很明显于是又觉得GIL都是被冤枉的。这些说法都有一定道理但都要看场景。接下来我会把GIL的底层机制、它为什么存在、哪些场景真的被锁死、哪些场景能绕过它讲清楚最后结合我自己的项目经验说说现场排查和并发模型选型给同样被多线程折磨过的人一个可落地的参考。1. GIL是什么以及它为什么存在1.1 一把解释器级别的锁和业务锁不是一回事很多人第一次听说GIL会下意识把它理解成Python内置的一个全局互斥锁以为用它保护共享变量或者加锁解锁的时机不对才会导致性能差。这里先纠正一个概念线程锁是用来保护你写的业务数据的而GIL保护的是解释器自身的内存状态。CPython在运行Python代码时会把源码编译成字节码然后在一个字节码循环里逐条执行。这个循环是解释器内部的“主路”所有线程要执行Python操作都得先拿到这把全局锁。为什么叫“全局”是因为一个解释器进程范围内只有一把任何线程执行一个字节码指令之前都要先判断自己有没有持有它。换一个更生活化的类比整个进程就像一个大办公室里面有很多员工线程但只有一个会议室解释器谁想进去发言都得先拿话筒。业务锁则是你们为了抢某个打印机抢某份合同而设的排队规则和话筒无关。还有一个容易被忽略的点GIL只在CPython这个主流实现中存在。Python本身其实是一门语言规范不同实现可以有自己的并发方案比如PyPy也有类似机制但历史实现Jython直接基于JVM的线程模型就没有传统意义上的GIL。之所以大家平时讨论Python多线程都默认提GIL是因为绝大多数人用的都是CPython。1.2 为什么当初要设计出这么一把“大锁”回到1992年CPython刚出现多线程支持时选择以牺牲多线程并行度为代价换取解释器实现的简单和稳定这个决定在当时其实很合理。核心原因在于CPython的内存管理依赖引用计数。Python里每个对象都有个引用计数当计数归零时这块内存就会被回收。如果两个线程同时操作同一个对象一个增加引用计数另一个减少引用计数在底层是两条非原子指令中间一旦被调度打断计数可能被写乱轻则内存泄漏重则直接崩溃。要解决这个问题有两种可选路一是给所有对象和内部结构加上细粒度锁二是干脆加一把全局大锁保证整个字节码执行过程一次只有一个线程进入。前者设计复杂、锁重叠很容易出现死锁且调试非常痛苦后者简单粗暴牺牲了并行度但解释器内部状态一下就安全了。另外还有一层原因当时的CPython非常依赖C扩展比如各种图像处理、数值计算库。C扩展作者大部分不太熟悉Python内部复杂的锁机制GIL让C扩展在默认情况下不需要自己做线程同步只要遵循特定规则就能安全运行这极大降低了生态开发门槛。说句公道话GIL为Python早期的生态繁荣立过功只是后来CPU从单核走向多核这个设计成了众矢之的。1.3 GIL是怎么切换和让出的既然同一时刻只能有一个线程执行字节码那多线程怎么轮换早期CPython按字节码指令数切换每执行一定数量的字节码就检查一次是否需要换人这叫“ticks”大概每100条指令切一次。后来从某个版本开始改为基于时间片的切换你可以通过sys.setswitchinterval()查看或修改切换间隔默认是5毫秒。但这里有个重要细节线程并不是到时间就一定被强切而是操作系统和解释器配合的结果。当一个线程在纯Python计算里连续跑解释器会在切换间隔到期后主动让出GIL更关键的是当线程遇到I/O、睡眠、文件读写、网络收发这些阻塞操作时会主动释放GIL让其他线程趁这段时间运行。这也是为什么I/O密集任务用多线程能看到明显并发效果而CPU密集任务却很难看到并行加速。提示sys.setcheckinterval()和sys.setswitchinterval()是两个时代的API前者按指令计数切换后者按时间间隔切换。如果你看到老代码里出现setcheckinterval不要照搬新项目统一用sys.setswitchinterval()并且只在明确需要的时候调整。2. 多线程为什么会被GIL“按住”2.1 先跑一次最小复现眼见为实与其背结论不如直接做个实验。下面是一段计算密集型任务每个线程做同样的纯Python循环累加然后分别启动1、2、4、8个线程看整体耗时变化。import threading import time def cpu_work(): total 0 for i in range(10_000_000): total i return total def run_with_threads(count: int): threads [threading.Thread(targetcpu_work) for _ in range(count)] t0 time.perf_counter() for t in threads: t.start() for t in threads: t.join() return time.perf_counter() - t0 if __name__ __main__: for n in (1, 2, 4, 8): print(f线程数 {n}: {run_with_threads(n):.3f} 秒)不同版本的Python、不同型号的CPU跑出来的数值会有差异但结论非常一致线程数越多总耗时不仅没有等比下降有时还会小幅上升。比如我本机实测的结果大致是线程数总耗时秒相对单线程12.511.0x22.681.07x43.121.24x83.451.37x看出问题了吗正常情况下两个线程在双核机器上跑无论如何也应该比单线程快一点但纯Python计算场景里GIL让每个线程只敢拿话筒几分钟然后又被迫让路。加上线程创建、调度、上下文切换带来的额外开销多线程反而成了负优化。2.2 线程切换的成本不只是上下文切换看到上面的数据有人可能会想虽然只有一把锁但切换间隔5毫秒任务分10份轮流做和一份一份做总时间该差不多吧实际上远没有这么简单。首先线程切换本身有成本。操作系统要把当前线程的执行现场保存下来再从等待线程里挑一个加载进来这一步有CPU寄存器、栈指针、内存缓存等一系列开销。Python线程在GIL背景下切换频率会变得很高每当5毫秒到期解释器会唤起其他线程争抢这把锁。旧的GIL实现里线程频繁争抢会产生一种“惊群”现象多个核心同时被唤醒却只有一个能拿到GIL其他核心白忙一场最后释放锁时还会把自己的缓存弄脏。更隐蔽的是缓存局部性问题。如果只有一个线程它处理同一块内存区域的连续任务CPU各级缓存利用率很高一旦切换到4个线程轮流跑这些线程直接又不可避免地被塞进复杂度。频繁切换导致缓存被反复清空重载同样的数据在不同核之间踢来踢去额外开销会集中放大。这也是为什么total i这种最简单的循环在多线程下偶尔会比单线程慢而不是快。2.3 CPU密集和I/O密集到底谁才是GIL的真正受害者GIL对I/O密集任务的影响要小得多。原因不复杂当线程执行sleep、发起网络请求、读写磁盘时底层系统调用往往会释放GIL让解释器可以接着跑其他线程的字节码。也就是说只要一个线程停在等待外部事件上另一个线程就能趁这个时间执行自己的代码片段。用一个非常简单的例子假设有8个任务每个任务都是time.sleep(0.1)。串行执行需要约0.8秒如果开8个线程几乎可以在0.1秒出头完成全部任务因为睡眠本身不占用GIL8个线程同时进入睡眠然后同时醒来。拿真实场景对照就是大量HTTP请求、读取几十个小文件、调用数据库查询等这些操作耗时大头都在等待上根本没吃满CPU。用多线程或协程可以让它们重叠执行瓶颈通常在并发数和网络带宽而不是GIL。那GIL真正锁死的是纯Python层面进行的大量计算、字符串处理、正则替换、循环逻辑这类任务。哪个环节让CPU满载哪个环节就会被GIL掐住脖子。3. 绕过GIL的三条主流路线3.1 直接上multiprocessing每个进程一份独立GIL多进程是最稳妥、也最符合直觉的绕过方案。既然一个进程只有一把锁那把任务拆到多个进程每个进程都有自己的解释器和自己的GIL自然就能同时执行Python字节码真正用到多核。用concurrent.futures.ProcessPoolExecutor做任务分发特别方便from concurrent.futures import ProcessPoolExecutor def heavy_calc(n): s 0 for i in range(n): s i return s if __name__ __main__: with ProcessPoolExecutor(max_workers4) as ex: results list(ex.map(heavy_calc, [10_000_000] * 4)) print(results)这里要注意两个坑。一是进程之间不共享内存任务需要的参数和返回值必须能被序列化也就是要能被pickle处理。如果你往进程池里扔一个大对象序列化和反序列化成本可能直接把并行收益吃光。二是启动方式的差异默认情况下在Linux用fork在Windows和macOS用spawn。spawn会重新导入模块、重新加载环境携带大量全局状态的任务更适合用进程池但如果全局变量初始化很重就得慎重评估。需要共享状态的时候可以改用multiprocessing.Manager、Queue甚至共享内存但通信成本会升高。从经验看最省心的方式是尽量让每个进程独立计算最后汇总减少进程间大流量交互。3.2 asyncio协程I/O密集任务的更优解asyncio是单线程事件循环通过协程让多个I/O操作并发执行。很多人以为用asyncio也能解决CPU密集的多线程性能问题这是误解。计算类的协程在同一个事件循环里仍然是一个接着一个跑和普通单线程基本没区别。不过对I/O密集场景asyncio非常强悍。比如大量网络请求用asyncio.gather并发等待比开大线程池经济得多内存占用也小得多import asyncio async def fetch_one(i): await asyncio.sleep(0.1) # 模拟网络或磁盘I/O return i async def main(): results await asyncio.gather(*(fetch_one(i) for i in range(10))) print(results) asyncio.run(main())如果你的业务里混着CPU密集和I/O密集正确姿势是把I/O部分交给asyncio把计算密集部分丢给进程池执行再通过loop.run_in_executor把结果接回事件循环。不要指望asyncio把纯计算也并行化它解决的是等待而不是计算。3.3 C扩展和Cython在计算时主动释放GIL前面说GIL只锁Python字节码执行不锁外部系统的代码。因此C扩展如果在自己内部跑一段耗时计算只要正确使用Py_BEGIN_ALLOW_THREADS和Py_END_ALLOW_THREADS这对宏包裹计算过程就能在计算期间释放GIL让其他线程并行运行。这也是为什么numpy、pandas、Pillow这些库在做大规模矩阵运算或图像处理时多个Python线程能惊人地接近多核并行因为它们底层在C/C里主动放开了锁。如果你自己写扩展或者用Cython加速热点函数也可以显式让计算“无GIL”运行。Cython里可以这样写def compute(double[:] arr): cdef int i, n arr.shape[0] with nogil: for i in range(n): arr[i] arr[i] * arr[i] 1注意with nogil块里不能再调用Python对象操作否则会再次拿锁。这种做法的本质不是“绕过GIL”而是把真正的性能热点下沉到不依赖Python运行时的地方。日常开发中如果发现某个纯Python函数占用90%以上的耗时别急着并发先考虑能不能用numpy替换循环、用Cython/Lua或其他方案实现热点。3.4 往未来看PEP 703与Python 3.13的free-threaded实验一直有人在推进“废掉GIL”的计划。PEP 703提出了CPython的free-threaded构建在没有全局锁的情况下通过更细粒度的锁和原子引用计数来保证线程安全。这个提案已经被接受并从Python 3.13开始以实验性选项形式提供。也就是说未来某个版本里CPython确实可以在解释器级别真正并行执行多个Python线程。但这并非一蹴而就。移除GIL后整个解释器的内部结构要换成更细粒度锁很多依赖GIL隐式保护的C扩展需要适配并重新编译。第三方生态大量二进制扩展在free-threaded构建下可能无法直接工作。从我的实际观察看眼下生产环境里盲目启用实验性无GIL构建风险不小除非你的系统非常简单、依赖都是纯Python或已适配。更现实的做法还是结合场景选择进程、线程、协程或者把热点下沉到C扩展里让计算远离解释器锁的管辖范围。4. 在实际项目中如何做并发选型4.1 先分清瓶颈计算密集还是I/O密集很多人一上来就想“Python多线程不行那我全换成multiprocessing”结果引入一堆进程间通信问题调试到崩溃。我的经验是先分清楚自己属于哪种特性再选工具。场景类型推荐方案GIL的影响程度纯CPU计算纯Python代码ProcessPoolExecutor、多进程严重多线程无用纯CPU计算可调用C库/GPU库线程NumPy/C扩展微弱底层释放GILI/O密集比如HTTP、数据库、文件asyncio或ThreadPoolExecutor较小等待时让出GIL混合型既有等待又有计算asyncioProcessPoolExecutor适中各干各的拿批量下载来举例对几百个URL发起下载其中可能有10%需要解密、解压或解析JSON。这部分计算如果集中在Python端那么光靠asyncio或者多线程都不理想最好把“下载”和“解析”分离下载用协程解析用进程池。这样既允许大量并发等待又不会让某个线程长时间拿着GIL卡住整个流程。4.2 用sys.setswitchinterval当“探针”判断GIL竞争判断一段代码到底是不是GIL在拖后腿有个很实用的土办法把切换间隔临时调大观察耗时变化。比如默认是0.005秒你把它调到0.05秒然后跑多线程。如果执行时间大幅缩短说明很多时间浪费在频繁的线程换人上反之如果几乎没有变化说明瓶颈不在GIL在别的地方。import sys import threading import time sys.setswitchinterval(0.05) # 然后跑你的多线程任务但是请注意这只是个探针不是优化手段。生产环境中不建议为了“避免切换”把间隔疯狂调大否则一个线程长时间霸占解释器会让其他线程的响应时间恶变。毕竟GIL的存在还有实时性方面的考虑让出锁太频繁会增加开销让出太少又容易饿死其他线程。拿这个技巧来判断代码性质比直接盲调更有意义。4.3 一个真实的取舍经验线程并不是一无是处网上很多文章把GIL说成“线程无用论”这种说法过于极端。线程在Python里仍然有价值尤其在共享状态复杂、数据体积大、I/O和计算混合出现的业务里。举个例子你需要从同一个内存结构里不停地读取一批临时状态同时对这些状态做轻量计算。如果换成多进程每次都要序列化状态IPC开销会非常难看线程直接访问共享内存的方式更舒服。再比如标准库的ThreadPoolExecutor和queue.Queue组合起来写生产者消费者模型比multiprocessing那套直观得多而且不会遇到模块导入问题。对于100ms级别的阻塞I/O开几十个线程已经能覆盖绝大多数常规后端系统的并发需求。所以我通常的建议是先用最简单的模型。如果任务里有阻塞等待先用线程池或asyncio如果纯计算成为瓶颈再把计算部分单独拆到进程池里。不要让“GIL”这个标签挡住理性选型的路。5. 常见误解与排错实录5.1 四个经常被误会的GIL问题关于GIL的传言太多我把日常被问到最多、也最容易误导人的几个整理在这里误解一GIL让Python的线程完全不能并发。严格说不对。并发的本质是“同时处理多个任务”I/O等待期间线程可以重叠执行只是执行Python字节码的流不是并行的。所以网络请求场景里多线程确确实实提升了并发能力。误解二移除GIL之后多线程纯Python代码一定会乘上N倍性能。没那么乐观。即使GIL移除线程之间对解释器对象、垃圾回收、内存分配的锁冲突依然存在。很多时候瓶颈会变成细粒度锁竞争和缓存一致性而不是一把锁的排队问题。误解三所有Python实现都有GIL。错。前面说过Jython没有传统意义上的GILPyPy的GIL实现也类似但不是同一套设计。GIL是CPython实现细节不是Python语言层面的规定。误解四GIL导致Python单线程也慢。这口锅不能让GIL完全背。单线程时期的性能问题更主要是动态类型解释执行开销和第三方库适配问题GIL并不会让单个线程跑得更慢它只是阻碍了多个纯Python线程吃满多核。5.2 一次数据批处理优化的排查过程我在工作中遇到过这样一个任务要给一批大批量的本地文件做格式转换每个文件都需要读取、解析、拼字段、写回。最初用ThreadPoolExecutor开了16个线程并行处理结果是CPU占用率始终在130%左右约一个半核总耗时和串行比没多大改善。当时我心里第一反应就是GIL但光凭感觉不够还是做了几轮验证。第一步把线程数从4、8、16、32一个个跑观察耗时曲线发现增加线程没有带来收益而且越往后反而更糟说明并行度很可能被解释器锁卡住了。第二步用perf_counter分别统计纯读取I/O阶段和解析计算阶段的时间占比发现大头在字符串解析和字段拼接。第三步把解析函数单独放进ProcessPoolExecutor跑一轮CPU占用率直接逼近多核总耗时下降明显。最后为了减少进程间传输大文件的开销改成先并发读文件到内存、再把解析任务提交给进程池整体吞吐量比原来提升了好几倍。这个过程想说明的其实不只是GIL而是“先测量再定方案”。如果你一上来就换multiprocessing可能因为倒腾通信细节浪费一整天如果被“GIL无用论”吓住把所有I/O也改成进程并发又会踩一遍序列化大坑。把瓶颈准确切出来比单纯记住某个锁的名字有价值得多。6. 我的最后一点使用心得每次看到有人写“Python多线程就是摆设”我都觉得这个说法太简单。GIL确实让纯计算的多线程成了半摆设但它并没有阻止我们通过正确的方式解决问题。I/O密集用线程池或协程计算密集根据计算量选择进程池还是C扩展优化前先用探针和profiler确认瓶颈位置——这套思路在我自己处理过的多个项目里反复验证过大部分性能问题都能借此找到出路。最后分享一个小经验如果某天你发现自己的多个线程并发起来不仅没快还越来越慢先不要急着改架构。把共享对象减少把锁的范围缩到最小重新测量一遍。很多时候“GIL背了不少锅”但真正拖慢你的是线程之间无节制的数据竞争和频繁切换。先清理干净的代码再决定要不要上多进程或者换语言实现热点。这条顺序比什么都重要。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。