ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Python GIL详解:从并发并行原理到多线程性能优化与绕过方案

2026/9/7 14:48:22 拓冰建站 浏览量
Python GIL详解:从并发并行原理到多线程性能优化与绕过方案 学习Python并发编程的朋友十有八九都会被GIL这个概念绊一跤。我在刚接触多线程那会儿写过一段特别简单的爬虫开了32个线程去抓网页满心以为速度能翻几十倍结果跑下来和单线程几乎没差别甚至因为线程切换反而慢了一点。当时也没想到问题就出在GIL身上——这是CPython解释器里一把非常出名的全局锁也是Python并发学习中绕不过去的核心话题。这篇笔记把并发、并行、GIL的基本原理、实测表现以及绕过GIL的常见方案都整理了一遍同时也加了不少我踩坑之后的反思。适合正在学Python并发编程、准备面试或者实际做高并发项目的开发者参考。这篇文章不是单纯概念堆砌而是结合真实代码和数据把GIL的边界在哪里、什么时候该担心它、什么时候可以忽略它讲得清清楚楚。1. 先理清“并发”和“并行”这两个词很多人一上来就在GIL里面打转但连“并发”和“并行”都还没区分清楚。这两个词听起来像一回事实际是完全不同的处理模型后面的很多理解都建立在两者的差异上。1.1 并发是逻辑上的同时并行是物理上的同时并发Concurrency描述的是一种处理多个任务的能力强调的是“能处理”不一定同一时刻真在同时执行。最简单的类比是餐厅服务员一个人同时接待好几桌客人这一桌下单后去另一桌倒水再回来继续处理这是并发。任务在交替执行但在任意一个瞬间服务员只干一件事。并行Parallelism则是物理上的同时执行必须依赖多核CPU或者多台机器。继续用餐厅类比就是多个服务员同时服务多桌客人每个服务员在同一时刻真正在干活。并行一定依赖硬件资源并发则主要靠合理的任务调度。在Python单线程程序里我们也能写“并发”的感觉比如用asyncio做协程调度任务之间不断切换看起来像在同时处理很多IO请求。但因为没有多核参与所以不是并行。理解这一点后面再谈GIL才有语境——GIL主要影响的是多线程的“并行能力”而不影响“并发能力”。1.2 从单核到多核为什么Python多线程不香了曾经单核CPU时代程序写多线程就是为了“并发”而不是“并行”。因为只有一个核所有线程本来就会分时复用GIL的存在并不会让性能变差多少。反而因为GIL简化了内存管理让程序更稳定。到了多核CPU普及之后大家理所当然认为多线程能让代码跑满多个核心于是Python程序员突然发现自己吃了哑巴亏——写出来的多线程程序CPU占用依旧只有一个核其他核在围观。问题的根源就是GIL它像一个独木桥无论你开了多少个线程同一时刻只有拿到GIL的那个线程能在解释器里执行Python字节码其他线程只能在桥头排队。注意这里说的是“在解释器里执行Python字节码”受GIL限制。如果线程执行的是IO操作、C扩展代码或某些释放了GIL的操作那么其它线程是可以拿到GIL继续执行的。这也是为什么在网络爬虫这类IO密集型场景里Python多线程依然能明显提升效率。2. GIL到底是什么它为什么要存在GIL全称Global Interpreter Lock全局解释器锁是CPython官方解释器里一把互斥锁。想要深入理解它得弄清楚CPython底层的内存管理机制以及GIL锁和普通业务锁之间的区别。2.1 绕不开的引用计数机制CPython在管理Python对象时采用引用计数Reference Counting加上垃圾回收Garbage Collection的组合方案。每个Python对象内部都维护着一个计数器记录这个对象被引用了多少次。当计数归零时对象的内存会被立即回收。现在假设没有GIL两个线程同时操作同一个对象的引用计数。线程A让计数器加1线程B也让计数器加1如果这两个操作不是原子的理论上就可能出现“竞争条件”——计数器只加了一次而不是两次进而导致对象被过早回收正在使用这个对象的线程会突然踩进非法内存程序直接崩溃。这种内存安全问题是非常致命的。那能不能给每个对象单独加一把锁呢技术上可以但代价巨大。一个Python程序运行时有成千上万个对象每改一次引用计数就要锁一次性能会下降到难以接受。于是CPython选择了一个粗暴但高效的方案在解释器外边套一把全局锁任何线程想要执行Python字节码必须先拿这把锁。这样一来“一个时刻只有一个线程改引用计数”变成了全局不变量内存安全问题就解决了。2.2 GIL和普通线程锁是两把不同的锁这里要特别区分概念GIL是解释器层面的锁由C代码管理而我们平时用的threading.Lock是Python层面的锁由用户代码管理。线程执行Python代码时必须先获取GIL而threading.Lock保护的是我们自己的共享资源。Python线程获取GIL是自动的不用我们写任何代码控制。某一线程想执行一段Python字节码它必须先尝试获取GIL如果拿不到就阻塞等待。而业务锁则需要我们在代码里显式地加锁和释放。可以把GIL理解成进入解释器大门的“入场券”业务锁则是图书馆里某一本书的“借阅权”。线程想借书访问共享资源也得拿到借阅权但没有入场券连图书馆都进不去。2.3 GIL何时被释放这是性能优化的关键GIL不是一直霸占到线程结束的。CPython的调度机制中会按照字节码指令数或者固定时间片来强制释放GIL。在Python 3.2之后默认的切换间隔大约是5毫秒——一个线程执行Python字节码超过5毫秒解释器就会发出切换信号让其它等待的线程有机会拿走GIL。更关键的是遇到IO阻塞时的情况。比如线程执行了time.sleep、socket.recv、requests.get这类操作时它清楚自己接下来要等很长时间如果还占着GIL其它线程就全堵住了。因此这类阻塞操作在等待阶段会主动释放GIL。这也解释了为什么IO密集型多线程代码能显著提速所有线程都在等待网络响应时GIL是空闲的某个线程拿到GIL完成一次快速操作后继续阻塞等待剩下的线程又能轮流拿到GIL继续做事整体效率自然就上来了。CPU密集型任务又是另一种情况。线程一直在做计算几乎不阻塞于是每个线程都在抢GIL。但GIL同一时刻只能给一个线程其它线程只能等多线程实际变成了“轮流干活”中间还多了一层切换开销。这也是为什么纯计算任务在Python多线程下常常比单线程还慢。3. 用代码实测GIL的影响范围光讲原理还是有点虚我建议每个人都亲手跑一下实验亲眼看看GIL在CPU密集和IO密集场景下分别是什么表现。这里给一段我自己常用的测试代码可以直接复制运行。3.1 准备工作搭建测试环境建议直接用Python 3.8以上的版本Windows、Linux都行。先确认版本python --version测试代码需要用到threading、multiprocessing、concurrent.futures和time这些标准库都是自带的不需要额外安装第三方包。如果你是新装的环境个人建议用Anaconda或者直接到Python官网下载安装包把环境变量配好避免后面跑代码时找不到解释器。3.2 场景一CPU密集型任务多线程为什么输给了单线程import time from threading import Thread from multiprocessing import Process def count_sum(n): 死循环算加法纯CPU计算 s 0 for i in range(n): s i return s def run_single(n): start time.perf_counter() for _ in range(4): count_sum(n) return time.perf_counter() - start def run_thread(n): start time.perf_counter() threads [] for _ in range(4): t Thread(targetcount_sum, args(n,)) t.start() threads.append(t) for t in threads: t.join() return time.perf_counter() - start def run_process(n): start time.perf_counter() procs [] for _ in range(4): p Process(targetcount_sum, args(n,)) p.start() procs.append(p) for p in procs: p.join() return time.perf_counter() - start if __name__ __main__: n 15000000 single_time run_single(n) thread_time run_thread(n) process_time run_process(n) print(f单线程耗时: {single_time:.3f}s) print(f4线程耗时: {thread_time:.3f}s) print(f4进程耗时: {process_time:.3f}s)我在一台4核CPU的机器上跑出来的典型结果是执行方式耗时说明单线程3.02s基准线4线程3.14s比单线程还慢GIL切换开销4进程0.82s接近线性加速多核利用率高多线程耗时甚至比单线程略高一点原因就是线程在争抢GIL和上下文切换时浪费了时间。而多进程每个进程有独立的解释器和独立的GIL可以真正并行利用多个CPU核心。3.3 场景二IO密集型任务多线程的翻身仗import time import threading def mock_io_request(idx): 模拟网络IO等待实际场景可以换成requests.get() time.sleep(0.5) return idx def run_io_thread(n): start time.perf_counter() threads [] for i in range(n): t threading.Thread(targetmock_io_request, args(i,)) t.start() threads.append(t) for t in threads: t.join() return time.perf_counter() - start def run_io_single(n): start time.perf_counter() for i in range(n): time.sleep(0.5) return time.perf_counter() - start if __name__ __main__: n 20 single_time run_io_single(n) thread_time run_io_thread(n) print(f串行执行: {single_time:.3f}s) print(f20线程并发: {thread_time:.3f}s)串行执行大概10秒20个任务 × 0.5秒而20线程并发只需要1秒左右。这就是因为time.sleep会让出GIL所有线程都在等IO时互不阻塞整体吞吐量大幅提升。网络爬虫可以非常明确地归类到IO密集型任务请求发出去之后绝大多数时间都在等响应数据落地GIL对这些等待场景几乎无感。所以如果你写爬虫完全可以放心使用多线程。3.4 怎么判断自己的瓶颈就是GIL有一个比较粗糙但直接的判断方法使用top命令观察进程状态。在Linux下用top -Hp [PID]查看线程级别CPU占用如果发现即使开了多个线程所有线程都挤在同一个CPU核心上但其它核心基本空闲那基本可以认定是GIL在限制并行执行。再加上把同样任务换成多进程后性能有明显提升那就更能确认了。当然也可以借助perf等性能分析工具统计采样但在实际项目里上述两种方法已经足够定位绝大多数GIL性能瓶颈。4. 绕过GIL的经典路线多进程、协程与扩展释放既然GIL在CPU密集场景下限制了多线程的并行能力那业界就发展出了几条非常经典的绕路方案。没有银弹每一种都有它的适用边界根据任务类型和运行环境做选择才是关键。4.1 多进程才是真并行的首选multiprocessing模块的存在本质上就是帮我们绕开GIL每个进程都有自己独立的Python解释器也都有自己独立的GIL。所以多个进程可以真正同时在多核CPU上执行Python字节码。任务形式简单时可以直接用Process和Queuefrom multiprocessing import Process, Queue def worker(q, idx): q.put((idx, sum(range(1000000)))) if __name__ __main__: q Queue() procs [] for i in range(4): p Process(targetworker, args(q, i)) p.start() procs.append(p) for p in procs: p.join() while not q.empty(): print(q.get())更推荐的方式是用concurrent.futures.ProcessPoolExecutor上层API更简洁并且自带进程池管理from concurrent.futures import ProcessPoolExecutor def job(n): return sum(i * i for i in range(n)) if __name__ __main__: with ProcessPoolExecutor(max_workers4) as executor: results executor.map(job, [3000000, 3000000, 3000000, 3000000]) for r in results: print(r)需要注意的是多进程不是没有代价。进程之间不共享内存数据在父子进程间传递需要经过序列化pickle如果一次要传很大的数据序列化开销可能超过并行获得的收益。另外进程启动本身也比线程重频繁创建销毁进程的成本非常高所以必须用进程池复用来避免额外开销。在Windows上启动多进程还必须把入口代码放进if __name__ __main__:保护块里否则会无限递归创建子进程这是一个常见的坑。4.2 用协程替代线程IO密集场景的新答案协程coroutine和前面讲的多线程、多进程不是一个维度的东西。协程完全跑在单线程内部由事件循环调度。线程切换由操作系统负责有上下文切换成本协程切换则由用户态代码控制成本非常低。在IO密集型场景下协程的并发能力极强单个线程就能hold住成千上万个连接。Python里最常用的asyncio标准库配合aiohttp、httpx这样的异步HTTP客户端可以写出高并发的爬虫和服务端import asyncio import aiohttp async def fetch(session, url): async with session.get(url) as resp: return await resp.text() async def main(): urls [https://example.com] * 20 async with aiohttp.ClientSession() as session: tasks [asyncio.create_task(fetch(session, url)) for url in urls] pages await asyncio.gather(*tasks) return pages if __name__ __main__: asyncio.run(main())从某种意义上说协程也绕开了GIL问题——因为整个事件循环都跑在一个线程里它压根就没想“并行”。但它是现在IO密集型场景的真香之选没有线程切换开销没有锁竞争单个线程能支撑的并发数远超多线程方案。需要强调一点协程只是并发工具不是并行工具CPU密集型计算放进去反而会把事件循环卡死导致其它协程全等不及。4.3 C扩展与Numba把计算任务“释放”出GIL还有一种思路是直接绕开解释器里的字节码执行过程。比如用Cython编写扩展模块时可以在不需要访问Python对象的关键段落显式声明nogil执行时这段C代码就会释放GIL让同一进程里的其它线程可以并行执行。Numba这个库对Python做即时编译JIT也能代替纯Python执行计算密集循环。在某些场景下Numba还能通过numba.njit(parallelTrue)或者fastmathTrue实现自动并行与向量化。这里需要留意的是Numba对代码模式有要求通常需要数值计算相关逻辑才适合并且首次编译有预热时间并非万能药。对于大部分业务开发者来说C扩展的门槛偏高通常是用在科学计算、图像处理、数值运算等性能敏感领域。日常业务层建议优先考虑多进程或者协程方案。4.4 Python 3.13自由线程模式的探索最近比较热的新闻就是Python 3.13引入了free-threaded构建模式也就是无GIL版本。在这个模式下多个线程可以真正并行执行Python字节码不再受全局锁约束。目前这个模式是实验性的需要编译时或者安装特定版本才能启用社区里也没有完全默认开启GIL禁用因为去掉GIL之后CPython内部的引用计数安全策略需要用其它方式替代运行效率和稳定性还需要时间验证。普通项目现阶段不用太激动该用多进程还是多进程该用协程还是协程。但可以保持关注等它变成稳定特性之后再考虑迁移评估。5. 实际项目里的并发方案选型学完了原理最核心的问题来了项目里到底该用哪种方案我直接整理了一张选型对照表可以节省很多纠结时间。5.1 一张表看懂线程、进程、协程的分工方案适用场景是否受GIL影响优点缺点多线程IO密集型短任务、爬虫、Web请求转发IO等待时不受影响切换成本低、共享内存方便CPU计算无法并行多进程CPU密集型计算、大数据处理不受影响真正并行利用多核进程重、通信开销大、调试复杂协程高并发IO、长连接、网关服务不受影响单线程内超大并发量、内存占用小不能阻塞、CPU计算会卡死事件循环这张表是我个人项目经验的凝练。实际工作中混合使用的情况很常见Web服务里用协程处理请求遇到CPU密集任务就丢给进程池进程算完再把结果异步返回。5.2 Web服务部署中的GIL困境与解法很多Web框架Flask、Django默认启用的开发服务器是单进程多线程的。部署到生产时如果依然是单进程即使开一大批线程CPU密集的视图函数依然跑不满多核这是新手最容易忽略的点。解决方案是用多进程部署Gunicorn里设置多个worker进程Uvicorn里设置多个worker每个worker仍然保持自己的多线程或者协程调度能力。我在实际部署中经常把Gunicorn配置成2到4个worker根据CPU核心数和接口耗时调参性能提升非常直观。有人可能会问既然多线程在CPU任务里没啥用为什么Gunicorn一个worker进程里还要支持多线程原因在于Web请求大多数是IO密集的一次性读请求体、查数据库、调下游服务这些操作都在等待多线程能提高单worker吞吐量。当接口内部逻辑简单且IO密集时多线程worker依然很有效。真正碰到视图里要做大量数值计算、图像处理等那就得考虑把这个计算丢到Celery任务队列里分发给专属计算Worker去跑。5.3 数据库并发锁和GIL的混淆点数据库并发锁是另一个容易被初学者和GIL混淆的知识点。GIL是Python解释器层面的锁它保证的是Python进程中对象内存的安全数据库锁则管理的是数据库事务和行数据的并发访问两者根本不在一个层级。举个例子两个Python线程同时对MySQL里同一行数据做更新它们的Python代码可能因为GIL而轮流执行但最终数据库会再通过行锁、表锁来决定顺序。不能因为听说过GIL就觉得Python多线程更新数据库是线程安全的该上的业务锁、乐观锁、事务隔离级别还是得考虑。5.4 从压测角度反推并发方案有些朋友在考虑并发方案时喜欢先做性能压测。其实顺序应该反过来先定好任务类型再选技术方案最后用压测验证。先分析接口的耗时组成。如果耗时主要在数据库查询、下游API调用、文件读写那就是IO密集优先用多线程或协程。如果耗时主要在一段取值范围较大的CPU计算、数据处理或加密解密那就是CPU密集优先用多进程。压测工具用JMeter或者Locust都可以先在小并发下观察CPU使用率、平均响应时间再逐步增加并发数找到拐点。如果CPU利用率上不去响应时间已经大幅恶化那就是有锁竞争或带宽瓶颈继续加大并发也没意义。6. 常见并发问题和避坑技巧这一节是我真正想分享的“干货中的干货”。网上教程差不多都在讲概念但实际动手之后你会遇到各种莫名其妙的问题这里集中整理了我踩过以及帮别人排查过的高频问题。6.1 线程池里又套锁死锁风险翻倍一个典型的错误是在ThreadPoolExecutor的任务函数里使用了嵌套的threading.Lock。比如任务A锁住了资源1还要再去拿资源2同时任务B锁住了资源2又要去拿资源1这样就形成循环等待线程池里的线程全部卡死程序既不报错也不退出很像假死状态。排查方法遇到程序卡住不输出先py-spy dump --pid [进程ID]或者用faulthandler输出线程栈。如果多个线程都停在acquire调用上基本能锁定死锁。预防上避免在同一个任务里嵌套获取多把锁实在要嵌套就统一锁的获取顺序或者改用threading.RLock再或者用Queue来传递数据少用锁解决问题。6.2 多进程模式下子进程启动变慢、数据巨大ProcessPoolExecutor在每次分发任务时都会将参数pickle后发送给子进程。如果你传给子进程的是一个很大的DataFrame或者大列表序列化时间完全可能超过任务本身的计算时间。我的习惯是尽量让传给子进程的数据体积小一些比如只传文件路径、行号区间、查询条件真正的大数据让子进程自己读或者通过共享内存传递。Python 3.8开始可以用multiprocessing.shared_memory.SharedMemory能避免一次大数据的序列化复制但使用时需要仔细管理生命期否则容易导致共享内存泄漏。6.3 用concurrent.futures时程序一运行就弹出“直接退出”Windows上使用multiprocessing相关模块时如果不把入口代码放进if __name__ __main__:保护块程序会在进程启动时无限递归创建新进程最终抛异常甚至导致系统资源耗尽。这个问题的原因是Windows下缺少fork机制子进程通过spawn重新解释执行模块代码没有保护块就会反复执行主逻辑。养成写保护块的习惯群里的报错会少一大半。6.4 协程里用time.sleep导致所有并发任务变慢如果你在async函数里习惯性地用time.sleep(1)那么事件循环会被阻塞后面所有协程都得等这1秒过去根本谈不上并发。正确做法是使用await asyncio.sleep(1)。这是一个新手容易忽视、老手偶尔也会犯的错误。用time.sleep会进入系统调用阻塞整个线程而asyncio.sleep会主动挂起当前协程让事件循环调度其它协程。检查代码时搜索time.sleep出现在async函数里的情况一般就是隐患。6.5 Python线程共享数据时的“假安全”GIL的存在让很多人误以为Python多线程操作变量是线程安全的。这是个误区。GIL只保证单个字节码级别的原子性不保证多步操作的整体原子性。比如self.count 1在反编译后其实是“读取、加法、写回”三个字节码操作中途完全可能发生线程切换最终导致计数丢失。处理共享数据时还是要老老实实使用threading.Lock或者直接使用queue.Queue这种本身带锁的安全数据结构。这也是一个经常被面试官拿来问的问题GIL是不是意味着不需要加锁了答案显然不是。6.6 查看线程状态与GIL切换情况的辅助工具threading.enumerate()可以打印当前存活的线程列表快速判断线程是否都还活着。sys.setswitchinterval()可以在Python层面调整GIL切换间隔但一般不建议动除非你在做非常细粒度的性能调优。py-spy是一个非常好用的采样工具可以在不打断进程的情况下打印Python函数调用栈对排查死锁、卡死问题非常有帮助。Linux下perf top能看到Python采样热点但需要一定权限和配置。我在定位线上并发问题时几乎每次都会借助py-spy dump来看线程卡在哪个位置比盲目加日志要高效得多。7. 写在最后的一点个人体会学GIL不是为了考试背定义而是为了理解Python并发程序的性能边界在哪里。CPU密集场景下多线程的并行能力被锁住了那就换多进程IO密集场景下多线程依然能发挥巨大作用协程在高并发IO上表现超乎想象C扩展则是追求极致性能时的硬核选择。我个人的习惯是接到一个新需求先整理出任务属于什么类型再决定并发模型。CPU密集的绝不多线程硬刚IO密集的第一选择基本是asyncio或线程池然后根据团队是否习惯异步编程来决定混合型的任务比如Web接口里同时有IO和计算那就用多进程worker配合协程IO再不行上消息队列把CPU任务拆给专门的worker。最后分享一个小技巧测试并发代码时别只在本地环境看结果。线上服务器的CPU核数、内存大小、网络延迟都和本地不一样。用同样的代码在测试环境和生产环境各压一遍才能得到真实可参考的性能数据。对GIL的认识也需要在真实的高并发流量下才能真正内化成经验——多看几次top里那些闲置的核心很多概念自然就通了。