深入理解 Python GIL:多线程与多进程的实战选型
发布时间:2026/10/11 6:05:34 锦皓数字建站

刚开始接触Python并发编程的人十个里有九个会在同一句话里听到两个名词多线程和GIL。这个全局解释器锁就像一把悬在Python线程头上的剑让许多写惯Java、C的开发者很不适应明明是并行任务开了线程一看CPU占用还是只有单核甚至耗时反而更长了。这篇文章就围绕Python多线程与多进程的选择问题把GIL的运行机制、两类并发手段的真实用法和最实用的选型标准一次讲清楚。如果你正在写爬虫、接口服务、数据处理脚本或者准备面试中关于Python并发的问题这篇内容可以直接拿来当参考。1. GIL是什么先搞清楚这把锁从哪里来1.1 全局解释器锁不是Python语言的错先纠正一个常见的误解GIL是CPython解释器的实现细节不是Python语言本身的特性。也就是说你写的那段Python代码并不天然携带GIL是解释器在执行它的时候通过GIL限制同一时刻只有一个线程能跑到Python字节码层面。CPython之所以选择这个设计核心原因是内存管理。CPython采用引用计数来管理对象生命周期每个对象里都有一个引用计数对象被引用一次就加1删除引用就减1减到0才释放内存。如果允许多个线程同时执行字节码那么两个线程同时操作同一个对象的引用计数就可能出现计数错乱进而导致内存被提前释放或者永远不释放出现段错误或者内存泄漏这类非常隐蔽的崩溃。为了快速解决这个问题CPython选择了用一个全局锁保护对象模型的细粒度语言任何线程在执行Python字节码之前必须先拿到GIL。这种“全局粗粒度锁”的优点是实现简单、省内存、性能稳定代价就是多线程无法真正利用多核并行执行纯Python计算。你可以把它理解成一个卫生间里的总门锁谁进去谁锁门其他人只能在门口排队哪怕卫生间里装了好几个洗手台同一时间还是只能有一个人使用。1.2 GIL是如何切换和释放的既然有了GIL那Python的线程是不是就完全没有并行能力了也不是。GIL并不是一个“一次持有到线程结束”的死锁解释器给GIL设定了主动让出的机制。在Python 3.x中默认的线程切换间隔switch interval是5毫秒左右。线程拿到GIL后每执行一段时间字节码解释器就会检查是否该让出锁给其他线程。更关键的是当线程进入I/O操作比如网络请求、文件读写、数据库查询时它会主动释放GIL因为I/O操作往往需要几毫秒到几秒这么长的等待时间没必要让其他线程空等。所以多线程在I/O密集型任务里依然能获得明显的吞吐量提升。线程A在等待网络响应时线程B可以拿着GIL继续处理自己的数据线程A拿到响应后再回来抢锁。这也是为什么大家常说“Python多线程适合I/O密集型任务但不适合CPU密集型任务”。这句经验话的背后就是GIL的这个行为特征。1.3 GIL与并行、并发的直观区别这里需要区分两个概念并发和并行。并发是逻辑上同时处理多个任务的能力比如一个线程处理A请求请求阻塞的时候切换去处理B请求在宏观时间窗口内看起来像同时处理。并行是物理上同时执行多个任务比如两个CPU核在同一瞬间各跑一个线程。对于纯Python计算GIL决定了CPython线程之间只能实现并发无法实现真正的并行。但多进程不一样每个进程都有一个独立的解释器和自己的GIL每个进程都能在独立的CPU核上执行这时CPU密集型任务才能真正并行。但是我必须强调一点GIL释放和竞争在Python 3.9之后已经做了很多优化比如字节码执行跳数限制、系统调用前主动释放等但核心思路没有变化。所以基于GIL这个前提的学习和面试答案依然适用于最新主流版本。2. 多线程适合做什么I/O密集场景的真实收益2.1 一个简单的实测等待100次比串行快十倍我用一个很直观的例子说明多线程在I/O场景下的效果。假设有个函数每次执行时阻塞0.1秒模拟一次网络请求然后返回结果。串行调用100次总耗时大约10秒。如果用线程池开10个线程每个线程跑10个任务总耗时可能只有1秒出头因为所有线程都会在等待时释放GIL整体等待时间被压平了。下面这组代码是我在各种环境里反复跑过的基础测试结构简单适合你自己复现import threading import time def io_task(n): # 模拟一次网络请求等待 time.sleep(0.1) return n start time.perf_counter() results [io_task(i) for i in range(100)] print(串行耗时, round(time.perf_counter() - start, 2), 秒) start time.perf_counter() threads [threading.Thread(targetlambda x: results.append(io_task(x)), args(i,)) for i in range(100)] for t in threads: t.start() for t in threads: t.join() print(多线程耗时, round(time.perf_counter() - start, 2), 秒)在真实运行中串行版本一般稳定在10秒左右多线程版本基本在1秒左右。线程创建的个数越多只要不超过系统调度阈值速度优势就越明显。这背后就是因为每一段sleep等待都会释放GIL让其他线程插队执行。2.2 爬虫和请求类任务为什么用线程池我平时处理大量批量请求的时候用的并不是直接new Thread而是concurrent.futures.ThreadPoolExecutor。线程池本身的优势是复用线程避免频繁创建销毁的开销同时通过max_workers控制并发数不至于因为并发过高把目标服务器打挂。import concurrent.futures import requests def fetch_one(url): resp requests.get(url, timeout5) return len(resp.content) urls [https://example.com] * 50 with concurrent.futures.ThreadPoolExecutor(max_workers10) as executor: results list(executor.map(fetch_one, urls))这段代码里10个线程会同时发起请求每个请求在等待网络响应时都会释放GIL所以即使requests底层是阻塞式IO整个任务集合也能被快速执行完。如果你想控制限速可以通过一个队列或者semaphore限制同时进行的请求数量这是爬虫工程里非常核心的操作。2.3 什么时候多线程反而帮倒忙多线程并不是在所有I/O场景下都稳赚不赔。如果任务本身几乎没有等待全是快速计算和小量数据读取那么线程切换、锁获取的开销就会超过并行收益。比如你需要循环读取1万个小文本文件并拼接字符串文件不大读取耗时很低但拼接操作是CPU计算这时多线程可能比串行还慢。我在测试的时候遇到过耗时反而增加30%的情况。原因是每次线程切换都需要保存和恢复上下文GIL的获取和释放也要损耗一点时间任务太短的时候这些开销会吃掉优势。所以正确的经验是当单个I/O操作耗时较长且总任务数较多时线程的收益很明显当I/O非常快或者任务几乎不阻塞时先用单线程跑一遍再决定是否上多线程。3. 多进程怎么用真正压满CPU的方案3.1 多进程为什么能绕开GIL多进程和线程最大的区别在于每个进程拥有独立的Python解释器也就拥有自己独立的GIL。各个进程在操作系统的调度下可以分别运行在不同CPU核上实现真正的“并行”。这意味着CPU密集型任务只有多进程能吃到多核红利。比如一个需要计算斐波那契数列或者做大量矩阵运算的任务开多线程时GIL会让多个线程竞争锁实际CPU占用率只有单核时间开销和串行差不多。而改用多进程后每个进程各占一个核2核机器跑两个进程耗时几乎减半4核机器跑4个进程收益更明显。3.2 进程池与返回结果的正确姿势multiprocessing模块里最常用的不是裸的Process而是Pool或ProcessPoolExecutor。前者通过进程池复用进程避免频繁fork/start带来的开销后者提供了和ThreadPoolExecutor一样的Future接口写起来更加统一。import concurrent.futures import time def cpu_task(n): # 模拟一段较重的CPU计算 start time.perf_counter() total sum(i * i for i in range(n)) return round(time.perf_counter() - start, 3) if __name__ __main__: nums [1000000 i * 5000 for i in range(8)] with concurrent.futures.ProcessPoolExecutor(max_workers4) as executor: futures [executor.submit(cpu_task, n) for n in nums] for f in concurrent.futures.as_completed(futures): print(任务耗时, f.result(), 秒)注意ProcessPoolExecutor的入口代码必须放在if __name__ __main__里面特别是在Windows系统下。因为Windows没有fork语义新进程会重新导入主模块如果不加这个保护进程会无限递归创建子进程直接抛出运行时错误。这是一个非常经典的新手坑。3.3 多进程的代价没有白吃的午餐多进程虽然能绕开GIL代价也很明显。首先是内存开销每个进程都会加载一个完整的Python解释器即使只跑一个简单函数也要额外占用几十到一两百MB内存进程数量一多内存很容易吃紧。其次是进程间数据传递进程之间无法像线程那样直接共享变量只能通过进程间通信IPC比如管道、共享内存、Queue等。Python中需要把数据序列化成二进制再传过去或者传回来的结果也需要反序列化这两次序列化的开销往往比计算本身还大。如果任务计算量不大用多进程反而会让总耗时上升。举个例子如果你需要处理的数据本身就是几个GB的列表那么想通过ProcessPoolExecutor.submit直接传给子进程系统会尝试把整个列表序列化并复制到子进程内存开销会成倍增长甚至可能直接导致OOM。所以多进程适合计算量大但数据量相对可控的场景或者数据本身可以分片读取、分片计算的场景。4. 如何选择线程、进程还是协程4.1 选型决策表我把这几年反复使用的选型逻辑整理成一张表遇到具体任务的时候几乎可以直接套用任务特征推荐方案核心理由网络请求、文件读写、数据库IO等I/O密集threading或ThreadPoolExecutorGIL在等待I/O时释放线程开销小纯CPU计算密集型multiprocessing或ProcessPoolExecutor每个进程有独立GIL可以真正吃满多核大量并发请求但每个请求内部以逻辑等待为主asyncio协程单线程事件循环内存占用极低混合负载比如先做大量I/O再对结果做CPU计算进程池内嵌套线程池两层并行各司其职任务数极少且耗时长的CPU任务Process直接创建避免进程池管理开销任务数多但单个任务计算量很小串行或协程并发调度的开销可能超过收益这张表最关键的一句话是先看你的任务到底在等什么。如果任务在等网络、等磁盘、等数据库用线程就够如果任务在让CPU长时间忙碌用进程如果任务是高并发I/O且逻辑比较复杂甚至可以考虑协程。很多人的失败经验是拿线程去算CPU密集型任务结果被GIL拖垮然后反过来骂Python并发不行其实是选错了工具。4.2 混合方案一个真实案例我在做一个网页数据处理服务时遇到过典型的混合型任务需要从多个接口拉取数据I/O密集然后对拉取回来的JSON做大量聚合计算CPU密集。单纯用线程聚合计算阶段会被GIL卡住单纯用进程又没法高效处理大量并发的网络等待。当时的最终方案是外层用ProcessPoolExecutor启动4个子进程每个子进程内部再用ThreadPoolExecutor开10个线程去并发拉取接口。这样网络等待分散在多个进程中CPU聚合计算也被分散到不同核上整个服务的吞吐能力比原先纯线程方案提升了将近一倍。这种嵌套方案在工程里完全可行要注意的是设计好任务拆分边界。比如让子进程只负责接收“待处理的URL列表”内部自己并发抓取和计算最后只返回最终结果避免在进程间来回传递大量中间数据。4.3 协程是另一个选择方向提到并发选择不能绕开asyncio。协程在等待IO时主动让出控制权让事件循环调度其他任务在单一线程内可以管理成千上万个连接。它的最大优势是内存占用极低无需为每个连接创建线程或进程。但协程有一个硬性要求整个链路上的IO操作都需要是异步非阻塞的比如使用aiohttp而不是requests使用aiomysql而不是pymysql。如果你的代码里调用了同步阻塞的库协程会退化成串行执行反而更慢。所以协程适合新建项目适合你从头控制代码风格。如果你在维护老代码里面全是同步requests那直接用线程池改造成本更低。5. 常见问题与排查实录5.1 线程共享变量为什么还是会脏在多线程中修改同一个全局变量很多人以为GIL会保护变量操作实际上不是。GIL只保证字节码执行不被并发干扰但不保证单条Python语句是原子的。比如counter 1会被拆成读取、加1、写回三步线程可能在“读取”和“写回”之间被切走导致两个线程同时读到同一个旧值各加1最终只加了一次。这种情况下需要加上threading.Lock自己保护临界区或者使用queue.Queue这类内部已经加锁的数据结构而不是指望GIL帮你搞定线程安全。这个问题的排查方式很简单在线程函数里打印全局变量的当前值多跑几次你会看到明显的乱序和丢失更新。5.2 Windows下启动进程无限递归我在Windows环境里第一次写多进程程序时直接在脚本顶层调用了Process(targetfunc).start()结果程序像疯了一样不断创建新进程最后系统弹了一堆错误窗口。原因就是Windows的spawn方式会重新导入主模块主模块每次被导入都再次执行了创建进程的代码形成无限递归。后来养成了习惯所有多进程入口必须放进if __name__ __main__:块里。这个问题在Linux上用fork方式不太容易出现因为Linux会直接复制当前进程不需要重新导入主模块。但为了跨平台一致性建议所有多进程代码都老老实实加保护。5.3 进程池任务传参报错ProcessPoolExecutor提交任务时参数和函数都必须能被pickle序列化。如果你传入的是lambda函数、嵌套函数或者某个无法序列化的对象会直接抛出AttributeError或者PicklingError。我踩过最多次的坑是在子进程里传入一个线程锁对象这玩意儿没法序列化程序瞬间崩掉。解决办法是尽量传普通数据类型比如整数、字符串、元组或者把复杂对象拆成多个简单参数再传入。宁可传参多一点也不要传一个自定义对象进去。5.4 Queue用错模块的尴尬在threading里用queue.Queue在multiprocessing里要用multiprocessing.Queue。如果混着用最典型的问题是子进程排队等待的数据永远拿不到因为multiprocessing进程之间使用的是另一套进程间通信机制。如果用线程里的Queue给进程用子进程拿到的其实是父进程内存中的对象副本修改无法传递回父进程。这类问题的排查思路很直接先在代码里加日志打印队列的qsize和每个进程拿到的对象id。如果对象id不一致说明根本不是在操作同一块内存大概率就是Queue用错了模块。5.5 GIL导致性能不升反降时的判断技巧如果多线程版本比单线程慢不要急着下结论说Python不行。先用cProfile或者自己埋点打印各段的耗时看CPU时间消耗在哪个函数上。如果大部分时间花在时间戳、字符串拼接、累加这类CPU操作上说明任务本质是CPU密集该换多进程。如果大部分时间花在等待sleep、网络请求、文件IO上那么多线程依然是最优解慢的话就要检查线程数量是否过多、目标服务器的响应是否太慢、是否有不必要的锁竞争。我做性能排查时有一个习惯先用一个极小的样例跑一遍单线程和并发版本直接对比wall time和CPU time。如果wall time下降而CPU time大量上升说明并发调度开销过大如果wall time几乎不变而CPU time也不变说明GIL根本没被让出来。两种现象对应的方案完全不同。6. 最后分享我这几年用下来的选型心得聊了这么多理论和方法最后说点个人经验。我现在拿到一个并发需求第一件事不是选线程还是进程而是先花五分钟做三件事第一判断任务里I/O和CPU各占多少比例第二用小样本数据实测一次单线程耗时第三估算并发量级和机器核心数。根据这三项结果百分之八十的场景可以直接确定方案纯粹等待I/O的线程池需要大量计算的进程池二者混在一起的进程池内部套线程池如果是高并发短请求的场景优先考虑协程。这个流程我用了很久几乎没有误判过。还有一个很多人不知道的小技巧遇到需要同时使用线程和进程的场景不要自己手动创建两条链路直接用concurrent.futures把ThreadPoolExecutor和ProcessPoolExecutor搭配使用接口完全一致后面维护起来会轻松很多。多线程和多进程从来不是对手它们只是Python在不同任务形态下给出的两把钥匙用对场景比争论“谁更强”更有意义。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。