ARTICLE DETAIL

资讯详情

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

Python GIL深度解析:多线程性能陷阱与三种绕开方案

Python GIL深度解析:多线程性能陷阱与三种绕开方案 刚学 Python 的时候我一直把多线程当成性能解药。写爬虫用threading写下载工具用threading一切看起来都很完美。直到有一次写一个批量计算脚本开了 8 个线程去处理一批数值任务结果时间比单线程还慢了一倍多CPU 占用率始终卡在一核附近任务管理器里一片死寂。后来查完资料才知道这个坑的根源就是 GIL——CPython 的全局解释器锁。这篇文章我打算把自己踩过的坑、做过的对比实验以及后来在真实项目里怎么绕开 GIL 的完整经验整理出来。适合刚入门的 Python 开发者也适合正在做并发性能调优、准备多线程相关面试的人保证你能搞懂 GIL 到底锁住了什么、什么时候该用线程、什么时候必须换方案。1. 先看现象GIL 在真实程序里到底锁住了什么1.1 CPU 密集型任务的诡异结果多线程让程序更慢先别急着背原理我们直接跑一个最典型的实验。我用斐波那契数列循环来模拟纯 CPU 计算任务这个任务里没有网络请求、没有文件读写、没有sleep就是纯粹地在 CPU 上做整数运算。实验环境是普通的 4 核 Windows 笔记本Python 3.11。import threading import time from multiprocessing import Pool def cpu_bound(n300_000): 纯 CPU 计算斐波那契数列没有任何 IO 操作 a, b 0, 1 for _ in range(n): a, b b, a b return a def run_single(): start time.perf_counter() for _ in range(4): cpu_bound() return time.perf_counter() - start def run_threads(thread_count4): threads [] start time.perf_counter() for _ in range(thread_count): t threading.Thread(targetcpu_bound) threads.append(t) t.start() for t in threads: t.join() return time.perf_counter() - start def run_processes(): start time.perf_counter() with Pool(4) as pool: pool.map(cpu_bound, [300_000] * 4) return time.perf_counter() - start if __name__ __main__: print(f单线程: {run_single():.3f}s) print(f4线程: {run_threads(4):.3f}s) print(f4进程: {run_processes():.3f}s)这是我当时跑出来的实测数据方案耗时结论单线程1.62 秒基线4 个线程4.35 秒比单线程还慢 2.6 倍4 个进程0.84 秒接近 4 倍加速看到这个结果的第一反应肯定是多线程怎么越搞越慢原因就是我标题里写的那个词——GIL全局解释器锁。CPython 解释器在任意时刻只允许一个线程执行 Python 字节码你的 4 个线程看起来在“并发”实际上是在抢同一把锁抢到锁的线程跑一小段其他线程全部阻塞等待。线程数量越多锁的竞争和上下文切换越频繁多出来的那部分开销反而把性能拖垮了。1.2 IO 密集型任务多线程为什么又能起飞了如果把任务换成 IO 密集型比如频繁读写文件、访问外部接口、执行数据库查询同样是threading结果会完全不一样。我用time.sleep来模拟网络请求的等待过程因为真实场景里网络 IO 的耗时主要就是等待服务端响应。import threading import time def io_bound(wait0.05): 模拟 IO 操作这里用 sleep 代替网络/磁盘等待 time.sleep(wait) def run_single_io(total40): start time.perf_counter() for _ in range(total): io_bound() return time.perf_counter() - start def run_threads_io(total40, thread_count4): per_thread total // thread_count threads [] start time.perf_counter() for _ in range(thread_count): t threading.Thread(targetlambda: [io_bound() for _ in range(per_thread)]) threads.append(t) t.start() for t in threads: t.join() return time.perf_counter() - start我的运行结果是单线程跑完 40 次模拟 IO 需要约 2.0 秒而 4 个线程跑完只需要约 0.52 秒基本接近 4 倍加速。原因在于IO 操作执行时线程会进入内核等待状态这个等待期间根本不需要持有 GIL。CPython 在检测到线程即将执行阻塞操作时会主动释放 GIL让其他线程去执行 Python 代码。等 IO 完成之后原来的线程再回来重新争取 GIL。所以 IO 密集场景里GIL 的占用率很低多线程可以真正利用并行等待的窗口。1.3 一句总结线程适合等不适合算从上面两组实验可以总结出一个朴素但重要的结论线程适合“等”不适合“算”。等网络响应、等磁盘数据、等数据库返回这些场景多线程可以显著提升吞吐但如果任务是纯计算线程没有并行能力反而会因为锁竞争让性能雪上加霜。很多新手把线程当成“并行加速器”写出线程池套 CPU 算法的代码结果 CPU 占用率一直上不去往往就是被 GIL 卡住了。2. 深入原理GIL 为什么存在它到底怎么工作2.1 从引用计数说起GIL 不是设计缺陷而是历史选择要理解 GIL得先知道 CPython 怎么管理内存。Python 里每个对象都是一个PyObject结构体的头部有一个字段叫ob_refcnt专门记录当前有多少个变量、容器、函数参数正在引用这个对象。这个数字归零就说明没人再用它了内存管理器会立刻把内存回收。这整套机制叫引用计数。问题在于ob_refcnt的加减操作并不是原子操作。假设两个线程同时引用了同一个对象一个线程在对象被加入列表时执行ob_refcnt另一个线程在对象被移出列表时执行ob_refcnt--这两个操作如果同时发生会产生数据竞争——计数结果错误可能导致对象被提前释放程序直接崩溃也可能导致计数永远不为零内存泄漏。解决办法有两种一个是给每个对象都单独加锁但 Python 对象的创建销毁非常频繁每次字典查询、函数调用都会触发引用计数增减给每个对象加锁会让所有操作慢上好几倍另一个方案就是给整个解释器加一把大锁同一时刻只让一个线程执行字节码从根源上避免引用计数的竞争。CPython 选择了后者这就是 GIL。可以这样类比一个仓库里有几千个货架每件货物都要登记数量。如果你给每个货架都装一把锁操作起来会耗费大量时间和精力但如果直接规定“整个仓库同一时间只允许一个人进来操作”虽然同时只能干一件事但保证永远不会登记错。GIL 就是那把“仓库总门锁”牺牲了并行计算能力换来了内存安全和简化实现。2.2 GIL 的调度机制不是无限持有而是时间片轮转GIL 并不是说一个线程拿到锁之后就永远不放手了。CPython 内部有一套时间片机制持有 GIL 的线程执行一段时间后会主动释放锁让其他线程有机会执行。在 Python 2 时代这个机制是按“字节码指令数量”来控制的每执行一定数量的字节码就检查一次是否需要切换。Python 3.2 之后改成了时间间隔控制默认值是 5 毫秒可以通过sys.setswitchinterval()修改。import sys # 查看当前的线程切换间隔单位是秒默认 0.005 print(sys.getswitchinterval()) # 改成 20 毫秒想减少线程切换次数时可以试试 sys.setswitchinterval(0.02)这里有一个很容易被误解的地方GIL 的切换不是由操作系统做的抢占式调度而是 CPython 解释器自己的“主动礼让”。每执行一小段字节码解释器都会检查当前是否该让出 GIL如果到了切换时间点当前线程就会释放 GIL同时标记自己“我还没执行完”随后参与重新竞争。也就是说Python 线程的并发调度是先到先得的锁竞争操作系统层面的线程调度只是底层基础。2.3 什么时候 GIL 会被真正释放三类关键场景搞懂 GIL 的释放时机基本就能预测你的程序能不能靠多线程加速场景是否释放 GIL说明CPU 密集计算纯 Python 循环不释放一直占着锁直到切换时间点IO 阻塞操作文件、网络、sleep、锁等待主动释放等待期间其他线程可以执行调用 C 扩展库numpy、ctypes、Cython部分主动释放取决于底层库是否手动释放IO 操作释放 GIL 是非常明确的。比如socket.read()、file.read()、time.sleep()执行之前解释器就会释放 GIL进入阻塞等待等事件返回之后线程重新竞争 GIL 并继续执行。这也是为什么我前面那个sleep模拟实验里4 个线程能接近 4 倍加速每个线程持有 GIL 的时间极短大部分时间都在等其他线程完全有机会同时推进。更微妙的是 C 扩展场景。很多第三方高性能库并不是纯 Python比如 numpy、pandas、OpenCV底层是 C 语言实现的。C 扩展在编写时可以使用Py_BEGIN_ALLOW_THREADS和Py_END_ALLOW_THREADS两个宏主动释放 GIL把计算任务交给底层 C/C 之后直接放弃锁等计算结束再重新拿锁。这就是为什么你用 numpy 做矩阵乘法时同一进程里多开几个线程去算不同矩阵性能还能涨——因为底层计算段是真的在并行执行。很多教程说“numpy 操作也会受 GIL 影响”其实不完全准确要看具体的操作类型和库的实现。2.4 版本演进与新动态free-threaded 实验模式离我们多远这些年 Python 社区一直在尝试去掉 GIL。最有名的是 PEP 703目标是实现 free-threaded 的 CPython 构建版本。从 Python 3.13 开始官方提供了--disable-gil编译选项可以用一个不带 GIL 的解释器运行代码。但要注意普通用户从官网下载的 Python 3.13 默认仍然带 GILfree-threaded 只是实验性选项需要自己编译或者下载专门的构建包。即使将来 GIL 被移除也不代表现有并发代码可以直接跑满多核。GIL 拿掉之后很多 C 扩展会自动变成多线程不安全的需要底层库先做适配Python 里大量对象操作也依赖“引用计数原子性”这个隐含前提数据结构的线程安全实现需要重新设计。所以我个人的看法是至少在近几年内普通 Python 开发者还是得按“GIL 存在”来思考问题写代码的时候默认多线程无法并行跑纯 Python 计算这样踩坑的概率会小很多。3. 实战改造三种绕开 GIL 的可靠方案3.1 方案一multiprocessing 多进程最直接但要注意开销多进程的思路非常朴素既然 GIL 是把单个解释器锁住那我干脆启动多个解释器进程每个进程有自己的 GIL互相之间不存在锁竞争。每个进程都能独立占满一个 CPU 核从原理上彻底绕开 GIL。Python 的标准库提供了multiprocessing和concurrent.futures.ProcessPoolExecutor两套接口。from concurrent.futures import ProcessPoolExecutor def heavy_calc(x): total 0 for i in range(1_000_000): total i * x return total if __name__ __main__: data [1, 2, 3, 4, 5, 6, 7, 8] with ProcessPoolExecutor(max_workers8) as executor: results list(executor.map(heavy_calc, data)) print(results)用多进程有三个必须注意的点。第一个是启动开销每次创建进程都要初始化一套新的解释器环境Windows 上还会重新导入__main__模块所以代码必须放在if __name__ __main__:保护之下。第二个是数据通信开销进程之间内存隔离传入参数和传出结果都需要 pickle 序列化再复制一份数据量大的场景序列化时间本身会成为瓶颈。第三个是任务粒度如果单个任务执行时间只有几十毫秒进程创建和通信的时间可能比计算时间还长此时多进程反而不划算。我一般习惯按“单任务执行时间超过 100 毫秒”作为使用多进程的参考门槛低于这个门槛先考虑优化单线程逻辑。对于大数据量交换的场景可以用multiprocessing.Array、Value或shared_memory共享内存方案避免反复序列化。比如两个进程同时处理一个很大的 numpy 数组可以先在父进程里创建 shared memory 数组子进程直接用内存映射读数据吞吐量往往能提升几个数量级。3.2 方案二让计算发生在 C 扩展里把 GIL 锁在外面多进程虽然能并行但进程通信的代价不小。另一个思路是把热点的计算代码从 Python 移到 C/C 层让计算过程中释放 GIL这样多线程就能真正并行执行而且线程间通信比进程间通信便宜得多。典型的做法是用 Cython 写扩展模块。下面是一个 Cython 版本的斐波那契计算关键在于with nogil:块它明确告诉解释器这段 C 代码执行期间允许释放 GIL。# fib_cython.pyx def fib_cython(int n): cdef int i cdef unsigned long long a 0, b 1 with nogil: for i in range(n): a, b b, a b return a编译这个扩展后你用前面threading的代码多线程调用fib_cython会发现性能出现了本质变化——多个线程在同时执行nogil段时不再受 GIL 约束可以真正做到并行。Cython 还提供了prange指令用来做并行循环也就是把多线程的调度交给 OpenMP代码写起来比手工 split 任务简单很多。再往下说ctypes 调用外部 C 库函数时也是类似效果。比如你在 Python 里通过ctypes.CDLL加载系统的数学库调用 C 函数时解释器同样会释放 GIL。这意味着只要计算主体在 C 库里Python 层的多线程调用实际上是“伪并行”变成“真并行”了。很多人对 numpy 多线程加速的疑惑本质也是这个原理numpy 底层调用了 BLAS/LAPACK 等优化过的 C 库执行矩阵乘法和向量运算时释放了 GIL所以多线程场景下用 numpy 计算确实能吃到并行红利。3.3 方案三asyncio 协程不解决并行但能大幅提升吞吐除了线程和进程Python 还有一套基于协程的异步模型 asyncio。它和 GIL 的关系比较微妙asyncio 本来就是单线程的不存在线程竞争所以 GIL 在 asyncio 模型里几乎不构成问题。asyncio 的思路是单线程内维护一个事件循环只要遇到 IO 等待就把当前协程挂起切换到其他可以继续执行的协程。import asyncio import aiohttp async def fetch_one(session, url): async with session.get(url) as resp: return await resp.text() async def main(): conn aiohttp.TCPConnector(limit50) async with aiohttp.ClientSession(connectorconn) as session: urls [fhttp://example.com/api/item/{i} for i in range(200)] results await asyncio.gather(*[fetch_one(session, u) for u in urls]) print(len(results)) asyncio.run(main())这个写法可以轻松在单线程里挂起几百个并发请求内存占用远小于起几百个线程的方案。但它解决不了纯 CPU 计算并行的问题因为整个事件循环只有一个线程在跑计算密集操作会阻塞事件循环导致其他协程全部排队。如果遇到 CPU 密集和 IO 密集混合的场景可以用asyncio.to_thread或loop.run_in_executor(ProcessPoolExecutor())把 CPU 部分丢给其他进程。3.4 选型速查不同场景到底该怎么选我把这些方案整理成一个速查表方便开新项目时直接对着选场景推荐方案核心原因大批量文件读写、网络请求、数据库查询threading或asyncioIO 等待时 GIL 被释放线程/协程能并发等待纯 Python 算法、CPU 密集计算multiprocessing或ProcessPoolExecutor独立进程并行绕开 GIL数值计算、图像处理、矩阵运算numpy / scipy 多线程调用C 扩展释放 GIL在线程内也能并行计算量大且要频繁交换数据Cython 扩展 nogil或共享内存并行执行的同时避免进程通信开销混合场景既有计算又有 IOasyncio 进程池协程管 IO进程管计算优势互补说实话选型这事没有银弹。我在实际项目中见过有人为了“绕开 GIL”硬上 multiprocessing结果每个进程都要加载 2GB 模型文件六个进程一启动内存直接爆掉。遇到这种场景更合理的方案是让 Python 线程只负责调度把真正的计算推给底层库或者干脆考虑任务队列加多机分布式。工具永远服务于场景先把任务特征是“等”还是“算”搞清楚再谈选型。4. 真实项目案例从日志采集到图像批处理4.1 案例一多服务日志采集程序之前我写过一个日志采集工具要从 30 个不同服务节点上拉取最新日志文件合并之后做关键字告警。每个节点的日志文件在远程磁盘上读取一次需要大约 2 到 5 秒这期间绝大多数时间都在等待网络 IO。最初版本是单线程串行拉取全部跑完要 90 秒明显太慢。我改成线程池后代码几乎没动只加了一个ThreadPoolExecutorfrom concurrent.futures import ThreadPoolExecutor def fetch_and_analyze(host, path): data download_file(host, path) # 网络 IO 为主 return analyze_log(data) with ThreadPoolExecutor(max_workers30) as pool: futures [pool.submit(fetch_and_analyze, h, p) for h, p in hosts] for future in as_completed(futures): results.append(future.result())结果总耗时降到 8 秒左右加速比接近 10 倍。这个场景里 GIL 几乎不构成瓶颈因为所有线程的大头时间都在等待网络响应GIL 一直被释放其他线程能顺利执行下载和解析逻辑。这其实就是前面 IO 密集实验在真实项目上的体现。4.2 案例二批量图像特征计算的教训同样是在日志采集项目之后的另一个需求把 2000 张商品图片批量提取颜色直方图和纹理特征每张图大约需要 0.5 秒 CPU 时间。我的第一反应是照搬线程池方案——毕竟上一项目刚靠它拿到了十倍加速。结果跑起来后CPU 占用率只有 25% 左右进程时间比单线程还长。用py-spy一看所有线程都在反复等待 GIL实质执行时间分配极其低效。后来我把ThreadPoolExecutor换成ProcessPoolExecutor每张图通过executor.map提交给独立进程。因为单张图处理时间是 0.5 秒左右远大于进程启动和序列化开销所以多进程的优势完全发挥出来。最终整个批处理从单线程的 1000 秒压到 280 秒加速比约 3.6 倍接近 4 核机器的理论极限。这个项目真正让我记住了线程池能解决 IO 等待问题但不能解决 CPU 竞争问题两者不能照搬混用。4.3 案例三Web 服务到底该不该用多线程还有一个常见困惑Flask、FastAPI 这类 Web 框架里多线程有没有意义答案是如果接口逻辑主要是查数据库、调外部 REST API、读写 Redis那么多线程仍然有效因为瓶颈在 IO不在 Python 解释器。你可以在 FastAPI 的接口里用def普通函数而不是async def框架会用线程池去并发处理多个请求也可以用async def配合异步驱动比如数据库用 asyncpg、HTTP 用 httpx这样单线程就能扛很高并发。我自己的经验是先看接口的耗时构成。如果一个接口要 300 毫秒其中 280 毫秒在等数据库查询那不管 GIL 怎么折腾多线程都能带来明显吞吐提升。反过来说如果接口里有大段 CPU 计算逻辑比如实时特征计算、复杂的协议解析那就得单独处理不能指望普通线程池解决至少要把这段逻辑拆出去用多进程或 C 扩展。5. 常见问题与排查技巧实录5.1 为什么开了 8 个线程CPU 却只有一核在忙这个问题几乎每个写 Python 并发的人都遇到过。排查顺序建议先确认任务类型任务里有没有长时间的纯 Python 计算如果是那么多线程无论如何也跑不满多核因为 GIL 同一时刻只允许一个线程执行字节码别的线程全在等锁。这时最简单有效的验证方式是换ProcessPoolExecutor试试如果换成多进程后 CPU 占用明显分散到多核基本就能断定是 GIL 卡住了。有一种例外情况任务用了 numpy 等底层库但 CPU 占用还是上不去。这时要找一下是不是数据量太小计算还没有并行起来就结束了。可以加大单批数据量或者检查库是否用了多线程版本比如 OpenBLAS 线程数设置。在 Linux 上可以用perf topWindows 上用任务管理器观察线程状态先把瓶颈定位准不要盲目改并发模型。5.2 为什么多线程跑得比单线程还慢如果你跑的是纯 CPU 计算这个问题在理论上就是必然的线程切换本身有开销加上各线程抢 GIL 时会出现“伪唤醒”和重新排队执行效率自然不及单线程。核数越多、线程数越多切换越频繁性能退化越明显。我用 4 核机器开 4 个线程跑斐波那契耗时大约是单线程的 2.7 倍如果把线程数开到 8这个比例会进一步恶化。但如果你的任务是 IO 密集而多线程仍然比单线程慢那要反思一下并发度是不是过低了。比如只开 2 个线程去跑 40 次sleep加速比确实只有 1.7 倍左右因为线程少、等待窗口没有充分利用。理想的线程数一般要超过 IO 等待的并发量比如同时等 30 个网络请求就至少准备 30 个线程否则高延迟请求会拖慢整体速度。5.3 修改了 switchinterval 为什么没有效果sys.setswitchinterval()只影响“主动切换”的频率也就是纯 Python 字节码执行时到达切换点后强制让出 GIL 的间隔。对于网络 IO、文件读写这类阻塞操作解释器会在进入等待前主动释放 GIL这跟 switchinterval 没有任何关系。所以你在 IO 密集场景里调整switchinterval是感知不到什么变化的——线程大部分时间压根不持有 GIL你改的是“抢夺频率”而不是“释放时机”。还有一点switchinterval设置得太小会导致线程反复切换性能下降设置得太大又会让某个线程长时间霸占 GIL其他线程响应明显变慢。我一般只在混合计算场景下尝试微调比如从默认的 0.005 改到 0.02而不是彻底关掉切换。记住这是调优手段不是解决 GIL 并发问题的根本方法。5.4 多进程的数据交换陷阱pickle 和启动开销多进程方案也有它的暗坑最典型的就是数据交换。用ProcessPoolExecutor.submit传参时参数和返回值都会经过 pickle 序列化。如果你传递的是一个很大的 pandas DataFrame 或嵌套很深的字典序列化时间可能占到整个任务耗时的一半。我遇到过一个场景单个计算任务只要 80 毫秒但传一份 100MB 的 DataFrame 进去要花 500 毫秒整个流程变成“序列化一小时计算一分钟”。解决办法是按数据共享程度来分低频但小体积的数据走 pickle 参数传递没问题高频访问的大块数据用multiprocessing.shared_memory做内存映射或者用Manager维护公共对象如果计算之间有严格的先后依赖宁可把数据切分好之后每个进程单独加载一次也不要反复通过队列传大对象。在 Windows 上还要注意进程启动方式默认是 spawn每个子进程都会重新执行模块代码尽量把重量级初始化放在子进程内部而不是在全局区。5.5 我常用的 GIL 排查工具和技巧排查 GIL 问题光靠猜效率太低了我的工具清单位其实很简单第一是py-spy用py-spy dump --pid 进程id可以查看当前每个线程的调用栈如果一个 CPU 密集任务里大量线程停在pthread_cond_wait或类似等待状态基本就说明它们在等 GIL。第二是py-spy top --pid 进程id可以看到每个 Python 函数的实时采样 CPU 占比非常直观。py-spy dump --pid 12345 py-spy top --pid 12345临时加日志也是一种简单粗暴但有效的方式在任务的开始和结束处记录线程名和时间看多个线程的时间线是否交错从而判断并发是否真实发生。还有一个更间接的方法测算代码执行期的系统上下文切换次数Linux 上可以用time -v或perf stat如果 context switches 数量暴涨大概率就是 GIL 切换在作祟。最后再分享一点个人经验折腾了这么多项目和实验我个人最深的体会是GIL 不是洪水猛兽它只是一个明确的“特性约束”。你只要在写并发代码之前先回答一个问题——这段代码是在等 IO 还是在做计算——就能避开绝大多数 GIL 的坑。等 IO 就放心用线程或协程做计算就老实走多进程或 C 扩展两者都不要硬混。碰到性能问题也别急着四处抄方案先用几行 benchmark 观察任务特征再对着我上面整理的选型表做决策。想彻底搞懂 GIL 的话建议自己动手跑一遍文章开头那组对比实验看一眼 CPU 占用率和耗时比背十遍原理都管用。踩过几次坑之后你会发现所谓“Python 多线程性能之谜”其实答案早就写在任务的模型里了。
返回列表