
说个上周加班的真实经历。有个定时任务要处理几十万条日志数据全是正则匹配和字符串解析属于典型CPU密集计算。我寻思着Python明明有threading模块于是吭哧吭哧加了线程池四个worker并发跑心想这次肯定能把四核CPU吃满任务时间至少砍一半。结果一跑直接傻眼单线程脚本跑完只要32秒加了线程池之后反而要41秒CPU占用率只有120%左右死活上不去。那一刻我脑子里飘过无数种可能线程锁竞争服务器被限流内存带宽瓶颈甚至怀疑是不是自己线程池的用法写得有问题。折腾到半夜两点排查日志、改代码、调参数毫无进展。最后逼自己静下心重新翻了翻CPython解释器实现相关的资料才猛地想起那个被念叨了无数遍的词——GIL全局解释器锁。想明白的那一瞬间我整个人都释然了。如果再给我一次机会我一定先搞懂GIL再动手写代码那三天夜真的不用熬。这篇文章不打算讲那些教科书里干巴巴的定义就结合我这次实战踩坑的经历把Python多线程和GIL这回事彻底掰开揉碎聊清楚它到底是什么、为什么会存在、它对我们的并发代码有什么实质影响以及面对不同任务到底该怎么选并发方案。1. 并发编程的灵异事件多线程怎么越跑越慢我那天遇到的问题相信很多Python开发者都碰到过只是网上讨论的时候经常各说各话搞得新手一头雾水。1.1 我那天的任务一段加了并发反而更慢的代码先还原一下当时让崩溃的任务。我需要处理一批积累了很久的Nginx日志文件每一行都需要做正则匹配提取字段、字符串拼接、加上一些统计计数然后输出结构化结果。代码逻辑不复杂纯粹是量大几百万行单进程跑要四五十分钟。因为之前写过不少Java代码惯性思维让我第一反应就是上线程池。使用concurrent.futures.ThreadPoolExecutormax_workers设成4把日志文件按行分片丢给worker去处理。代码长这样import concurrent.futures import re LOG_PATTERN re.compile( r(\d\.\d\.\d\.\d)\s-\s\[(.*?)\]\s(\w)\s(\S)\sHTTP/(\d\.\d)\s(\d)\s(\d) ) def parse_line(line: str) - tuple: match LOG_PATTERN.search(line) if not match: return None ip, time_str, method, url, http_ver, status, size match.groups() # 这里还有一堆字符串拼接和统计逻辑 return (ip, time_str, method, status, size) def process_file(file_path: str): with open(file_path, r, encodingutf-8) as f: for line in f: parse_line(line) # 四个文件四个线程并行 files [log1.txt, log2.txt, log3.txt, log4.txt] with concurrent.futures.ThreadPoolExecutor(max_workers4) as executor: executor.map(process_file, files)逻辑上没有任何问题线程之间完全不共享数据也就不用加锁。但跑出来的结果就是这么打脸——比单线程还要慢。这种反直觉的现象如果不知道GIL的存在确实会让人怀疑人生。1.2 初步排查锁解释器还是服务器本身有问题当时我按常规思路排查了一遍先怀疑是不是磁盘IO瓶颈。但用iostat看磁盘使用率连50%都没到。再怀疑正则表达式是不是有灾难性回溯问题。单独跑一条测试样例性能正常。又怀疑是不是线程切换开销太大。但4个线程而已量级不该有这么大影响。最后甚至怀疑是不是Python版本有bug还换了个版本测结果一样。直到我翻到CPython源码里关于ceval.c和gil_drop相关的实现才彻底明白这个解释器在设计上根本不允许Python字节码在多核CPU上并行执行。不管你的机器是4核还是64核同一时刻只能有一个线程在执行Python代码。这锅不怨线程、不怨代码、不怨服务器完全是CPython解释器自己的设计约束。2. GIL是什么CPython里的一个红绿灯GIL全称Global Interpreter Lock翻译过来就是全局解释器锁。它就是一个互斥锁锁住的是整个解释器进程。任何一个线程想要执行Python字节码必须先拿到这把全局锁执行一小段时间后把锁交出来让其他线程去抢如此反复。从操作系统角度看确实有4个线程在跑但从解释器层面看任意时刻只有1个线程在真正干活。2.1 为什么CPython需要这把全局锁这里必须先讲清楚一个背景CPython的内存管理机制依赖引用计数。每个Python对象内部会维护一个计数器记录有多少个引用指向它。当归零时对象的内存会被立即回收。当你写a obj、del a、或者函数传参时这个计数器都在飞快地变化。问题就出在这。如果多个线程同时操作同一个对象的引用计数在高并发场景下可能会出现竞态条件两个线程同时读到计数是2同时减1结果实际值应该是0但被写回成了1。这个对象永远释放不掉就内存泄漏了更糟糕的情况是对象已经被回收的瞬间另一个线程还持有悬空指针直接用就崩溃。那怎么解决业界常见的做法是给每个对象都加一把细粒度的小锁或者用原子操作实现引用计数的增减。CPython早期开发团队没有走这条路而是选择了最简单粗暴的方式全局只放一把大锁所有线程共享谁拿到锁谁才能执行字节码。保证同一时刻只有一个线程在修改对象状态引用计数的操作天然就不会冲突。代价就是多核并行彻底没了换来的是垃圾回收和内存管理的绝对安全。这本质上是一种取舍在Python诞生的90年代单核CPU是主流这个选择并没有太大问题但放到今天多核普及的时代就有点尴尬了。2.2 GIL的切换真相时间片和字节码的博弈很多人以为GIL是执行完一个完整的函数之后才切换其实不是。CPython在解释执行字节码时会维护一个计数器每执行一定数量的字节码指令就会去检查当前线程是否超过了允许持有的时间片。默认间隔是5毫秒你可以通过sys.setswitchinterval()查看和修改python -c import sys; print(sys.getswitchinterval()) # 输出 0.005单位是秒每过5毫秒当前线程就会被强制释放GIL然后操作系统调度其他线程来争抢。这时候问题就来了频繁的获取、释放锁加上操作系统线程上下文的切换本身就是不小的开销。我那个任务里4个线程频繁抢锁、切换、抢锁、切换实际花在调度上的时间比直接顺序执行还多所以总耗时反而更长。这就像四个人轮流用一台打印机每个人都要排队、交接最后四个人一共打的页数可能还不如一个人闷头打来得快。另外还有一个细节Python 3.2之前是每执行固定数量字节码指令就切换3.2之后改成了基于时间的切换机制算是对高频率线程切换做了优化。2.3 GIL锁不死所有并发IO操作会主动放手这里有个非常重要的转折点。我前面说的同一时刻只能有一个线程执行Python字节码有一个前提——线程里运行的代码必须真的是Python字节码。如果你的线程在执行阻塞型IO操作比如time.sleep()、socket.recv()、requests.get()、文件读写、数据库查询那情况就完全不一样了。因为这些操作在执行过程中会主动释放GIL。为什么因为等IO返回期间线程啥也干不了如果还霸占着GIL别的线程也干不了活那就真的锁死成串行了。CPython在调用底层IO接口之前会先释放GIL然后让线程进入阻塞等待等IO事件完成、数据准备好的时候线程被唤醒再重新申请获取GIL。所以对于IO密集型任务Python多线程并不是假把式而是能实打实提升吞吐量的。一个线程在等网络响应另一个线程可以同时在CPU上做数据处理效率自然就上去了。我后来用爬虫场景验证过多线程并发请求API接口速度提升接近线性效果非常明显。3. 上实验CPU密集和IO密集的实测数据光讲原理有点空我重新写了两个对照实验用同一台4核8线程的Linux服务器跑Python 3.10版本大家可以自己复现。3.1 CPU密集型对比单线程、4线程、4进程先定义一个纯计算的函数模拟真正的CPU密集工作负载。我们让它循环做大量浮点乘法运算import threading import time from multiprocessing import Pool def cpu_bound_task(n): total 0 while n 0: total n * 1.0001 n - 1 return total # 单线程基准 start time.time() cpu_bound_task(20000000) print(单线程耗时: {:.4f}s.format(time.time() - start)) # 4线程每个线程负责四分之一工作量 def thread_worker(): cpu_bound_task(5000000) start time.time() threads [threading.Thread(targetthread_worker) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() print(4线程耗时: {:.4f}s.format(time.time() - start)) # 4进程通过multiprocessing.Pool实现 if __name__ __main__: start time.time() with Pool(processes4) as pool: pool.map(cpu_bound_task, [5000000]*4) print(4进程耗时: {:.4f}s.format(time.time() - start))我跑出来的结果如下执行方式总工作量耗时相对单线程加速比单线程2000万次循环4.28s1x4线程2000万次循环4.31s0.99x4进程2000万次循环1.11s3.85x数据非常直观。4线程几乎没有加速甚至还略慢一点点就是GIL切换开销导致的4进程则几乎线性扩展加速比接近3.85因为每个进程都有自己独立的解释器和GIL互不干扰。3.2 IO密集型对比为什么多线程在这里效果明显再来看IO密集型的实验。这里用time.sleep()来模拟网络请求之类的阻塞等待每个任务睡眠0.5秒import threading import time def io_bound_task(): time.sleep(0.5) # 单线程依次执行8次 start time.time() for _ in range(8): io_bound_task() print(单线程耗时: {:.4f}s.format(time.time() - start)) # 8个线程并行执行 start time.time() threads [threading.Thread(targetio_bound_task) for _ in range(8)] for t in threads: t.start() for t in threads: t.join() print(8线程耗时: {:.4f}s.format(time.time() - start))结果执行方式任务数耗时说明单线程8个0.5s睡眠4.01s顺序执行完全累加8线程8个0.5s睡眠0.52s几乎完全并行原因就是前面说的sleep()会主动释放GIL。8个线程各自阻塞在睡眠状态GIL锁被让出来给其他线程用等0.5秒之后大家陆续醒来各自继续执行。这种情况下线程不仅有用而且很高效。3.3 数据背后的几个反直觉结论这两个实验结合在一起能得出几个很重要的结论直接改变了我对Python并发的认知GIL影响的是CPU密集型任务不是IO密集型任务。如果你的代码大部分时间花在网络请求、文件读写、数据库操作上多线程完全没问题该用就用。多线程没有把4个核全用上的能力这是CPython的设计决定的。想用满多核去做计算请转战multiprocessing或者三方的分布式框架。线程数量不是越多越好。即便是在IO密集场景下线程数也要结合上下文切换开销来定。比如一个服务同时发起10000个请求线程创建、切换的开销也会成为瓶颈这种场景就该考虑asyncio或者消息队列了。4. 那些让我少熬三天夜的坑理解了GIL之后回头看以前的调试经历发现好多夜班加得特别冤。这里把我在实战中踩过的几个坑梳理一下每一条背后都是血泪教训。4.1 坑一以为GIL保护了一切从而忘了加LockGIL能保证多线程不会同时执行字节码于是有个新手特别容易踩的坑觉得自己写的多线程代码完全不需要加锁反正同一时刻只有一个线程在跑。这是大错特错。GIL保护的只是单个字节码指令级别的操作不是你的整段业务逻辑。比如下面这段代码import threading counter 0 def increase(): global counter for _ in range(1000000): current counter current 1 counter currentcounter current这一行在Python里看起来是一行但底层其实是好几条字节码指令读全局变量、把新值写回全局变量。在线程切换的时间窗口里可能前一个线程读完了counter还没写回去GIL就被切走了换另一个线程进来也读了同一个旧值最后两个线程各加了一次结果却只加了1。这就是典型的竞态条件。实测跑完之后counter的值大概率不是2000000而是比它小。所以该用threading.Lock的地方必须用别指望GIL帮你兜底。GIL是一个开发便利性的妥协不是并发安全的全能护盾。4.2 坑二看到多线程就否定了整个并发方案以前我看过不少技术文章上来就是一句Python多线程是个假把式底下附一段CPU密集型的对比实验搞得很多人直接对Python线程产生了偏见一遇到并发场景就想着用多进程、甚至直接嫌弃Python。但我那次事故之后做了更系统的测试发现这种观点属于以偏概全。在爬虫、Web接口调用、消息消费者、文件批处理这些IO密集型场景里Python的多线程完全够用配合ThreadPoolExecutor写起来还很顺手。而且进程的内存开销远大于线程如果业务本身要共享大量状态和缓存多进程的通信会让人非常痛苦多线程反而更合适。关键是先判断你的任务是哪一类再谈用什么工具。不能一竿子把所有多线程方案打死。4.3 坑三忽视C扩展和协程这两个变量GIL并不是在所有情况下都锁死计算。相当多高性能Python库比如numpy、pandas、openpyxl的某些底层实现它们内部的计算逻辑是用C/C写的并且在C扩展入口主动释放了GIL。这意味着当你调用numpy矩阵乘法的时候解释器会把GIL放开让多个线程真正并行跑在多个核上。这带来一个实战技巧如果你有一段CPU密集型的纯Python循环但可以改写成numpy向量运算那即便你用多线程也能享受到多核的加速因为耗时大头在C层执行且已经释放了GIL。我自己用这个方式把一个文本处理任务提速了将近8倍。另外就是协程asyncio。协程本质上跑在同一个线程里通过事件循环在任务之间手动切换完全绕开了GIL线程切换的开销。对于超大量的IO并发协程比多线程更轻量内存占用小得多。但要注意协程里一旦出现CPU密集的同步计算事件循环就会被卡住所有其他协程都会停止响应所以协程适合IO写法的代码计算密集的逻辑还是要抽出去。5. 对症下药我的并发选型清单现在不管遇到什么并发需求我基本有一套固定的判断流程。这套流程帮我省下了大量本可能浪费在验证和试错上的时间这大概就是少熬三天夜的底层逻辑。5.1 任务画像先分清CPU密集还是IO密集拿到一个优化需求第一步不是写代码而是先问自己三个问题这段代码大多数时间花在哪里瓶颈是计算资源不足还是等待外部IO如果去掉等待时间程序的耗时能减少多少最简单的方式是做个粗测把任务里的IO调用注释掉或者替换成pass看耗时变化。如果耗时基本没变说明是CPU密集如果耗时急剧下降说明是IO密集如果两边都不小那就是混合型任务。我做了一个整理好的速查表基本可以作为选型参考任务类型推荐方案原因CPU密集纯Python循环/计算multiprocessing / ProcessPoolExecutor绕开GIL每个进程独立解释器并行利用多核IO密集网络/磁盘/数据库threading / ThreadPoolExecutor阻塞期间释放GIL线程切换开销远小于阻塞等待时间超大规模网络并发asyncio单线程事件循环极低内存开销支持数万并发连接底层计算量非常大的科学计算numpy/scipy/C扩展 多线程C扩展释放GIL多线程可以并行执行混合型多进程协程/线程池组合进程承担计算量大的部分协程/线程管IO等待5.2 并发实现怎么选threading、multiprocessing、asyncio的取舍选型的时候除了看任务类型还要考虑开发复杂度、内存开销和调试难度。threading最大的优点是共享内存方便。多个线程可以自由读写同一个字典、列表、缓存对象只要加好锁就行省去了进程间通信的序列化和拷贝成本。但调试起来也最头疼尤其是死锁和竞态问题不好复现、不好定位。multiprocessing最大的优点是能用满多核CPU但它有几个绕不开的坑每个子进程都是独立内存空间想往主进程回传结果必须走进程间通信机制比如Queue、Pipe或者multiprocessing.Manager大型数据要序列化之后在进程之间传递那个开销在某些场景可能比计算本身还大。asyncio的代码写法和同步代码差别很大整个程序思维要变成事件驱动。但它带来的吞吐量提升确实惊人。我写过一个爬虫ThreadPoolExecutor开200个线程抓5000个URL耗时约35秒改成asyncio配合aiohttp之后同样抓5000个URL耗时降到约11秒而且运行期间CPU占用还更低。5.3 两个实用工具与一个避坑技巧最后分享两个我这个过程中觉得特别有用的工具和一个很实用的排查思路。py-spy一个采样分析器不用改代码就能看到当前进程里所有线程正在执行的Python函数。当脚本卡住或者出现性能异常时用它sudo py-spy dump --pid 进程号来看当前线程栈能迅速定位到是不是哪个线程长期霸占GIL或者哪个线程卡在IO调用上。这个工具帮我解决过好几个线上疑难杂症。concurrent.futures标准库它帮我们屏蔽了直接管理threading.Thread和multiprocessing.Process的细节。同一个接口写多线程和多进程切换几乎零成本。我强烈建议把ThreadPoolExecutor和ProcessPoolExecutor当成默认选择别自己裸写一堆线程管理代码。避坑技巧永远不要在生产代码里用time.sleep()强制等待某个线程完成而应该用Event或者Future的结果回调。同样地测试GIL相关问题时别用time.sleep()模拟任务它会主动释放GIL导致你误以为多线程很好用无法真实反映CPU密集场景。当初我如果能早点把这些关系理顺可能真的不用熬那三个通宵。现在不管是给别人Code Review还是自己写代码第一反应永远是先看一眼任务类型再选并发方案GIL这个问题再也没骗过我。这里再补充一句个人心得网上关于GIL的讨论容易走极端要么说Python多线程没用要么说GIL其实影响不大。真实的答案不是非黑即白它取决于你写的代码到底花时间在哪里。把任务类型这个基本盘搞清楚就等于把90%的并发问题都解决了一半。