11-GIL不是性能杀手(上)-CPU密集vsIO密集的实测对比

文章目录

  • [GIL 不是性能杀手(上):CPU密集 vs IO密集------你以为的瓶颈其实是错觉](#GIL 不是性能杀手(上):CPU密集 vs IO密集——你以为的瓶颈其实是错觉)
    • 导入语
    • [1 ~> GIL 到底是什么------一句话能解释清楚](#1 ~> GIL 到底是什么——一句话能解释清楚)
      • [1.1 为什么 CPython 要加这把锁](#1.1 为什么 CPython 要加这把锁)
    • [2 ~> GIL 什么时候释放------临界点在三类操作之间](#2 ~> GIL 什么时候释放——临界点在三类操作之间)
      • [2.1 GIL 的持有与释放](#2.1 GIL 的持有与释放)
      • [2.2 什么时候 GIL 会被释放](#2.2 什么时候 GIL 会被释放)
    • [3 ~> CPU 密集型实测------GIL 确实把多线程打回原形](#3 ~> CPU 密集型实测——GIL 确实把多线程打回原形)
      • [3.1 测试代码:暴力计算斐波那契额](#3.1 测试代码:暴力计算斐波那契额)
      • [3.2 实测数据(i7-12700,8核16线程)](#3.2 实测数据(i7-12700,8核16线程))
      • [3.3 图解:CPU 密集型场景中 GIL 的行为](#3.3 图解:CPU 密集型场景中 GIL 的行为)
    • [4 ~> IO 密集型实测------线程数量和速度基本成正比](#4 ~> IO 密集型实测——线程数量和速度基本成正比)
      • [4.1 测试代码:并发网络请求](#4.1 测试代码:并发网络请求)
      • [4.2 实测数据](#4.2 实测数据)
      • [4.3 图解:IO 密集型场景中 GIL 的行为](#4.3 图解:IO 密集型场景中 GIL 的行为)
    • [5 ~> 什么时候该用多线程、什么时候该切多进程](#5 ~> 什么时候该用多线程、什么时候该切多进程)
      • [5.1 决策表](#5.1 决策表)
      • [5.2 一个真实的踩坑经历](#5.2 一个真实的踩坑经历)
    • [思考 && 总结](#思考 && 总结)
    • 结尾

GIL 不是性能杀手(上):CPU密集 vs IO密集------你以为的瓶颈其实是错觉

📖 文章简介: GIL(全局解释器锁)是Python面试的必考题,也是论坛上被骂得最多的Python"缺陷"。但大多数人骂GIL的原因都不对------他们以为多线程慢是因为GIL,实际上大多数场景里慢的是别的东西。上篇聚焦一个核心问题:GIL在什么场景下真的是瓶颈?通过CPU密集(纯计算)和IO密集(网络/文件读写)两组实测数据对比,讲清楚GIL影响多线程性能的边界条件。文章从CPython源码角度解释GIL的获取/释放时机,配有可复现的压力测试代码和性能曲线对比,读完你会知道什么时候该用多线程、什么时候该果断切多进程。


🎬 个人主页: 源码骑士

专栏传送门: 《Android开发基础》《python基础课程》

⭐️热衷从源码视角拆解技术底层原理,将复杂架构讲得通俗易懂


🎬 源码骑士的简介:

5年Android Framework系统开发经验,曾主导多项系统级性能优化专项

技术栈覆盖Android系统全链路(Binder/Handler/AMS/WMS/启动流程)及Java后端全家桶(Spring + MyBatis + Redis + Oracle)

累计产出原创技术文章100+篇,文章以源码拆解为特色,被读者评价为"看一篇胜过啃一周文档"


导入语

"Python 多线程是假的,因为 GIL,所以多线程没有用。"

这话我至少在十个评论区里看到过。问题是------说这话的人,十个里有九个没做过实测。他们只是背了一句话,然后就对所有 Python 多线程持否定态度。

2022年我在一个日志处理服务上做性能优化。当时的架构是单进程串行------一个线程读文件,读完再做分析。我提出用多线程并行读多个日志文件,一位同事当场说"Python 多线程没用,GIL 会让它变成串行"。我没多解释,写了一组基准测试跑给他看------四线程并行读文件比单线程快了 3.2 倍。

GIL 的真实影响不是"多线程没用了",而是"多线程在 CPU 密集型任务上没用"。上篇把这个边界讲清楚,配实测数据。


1 ~> GIL 到底是什么------一句话能解释清楚

GIL(Global Interpreter Lock,全局解释器锁)是 CPython 解释器中的一把互斥锁。它的核心规则只有八个字:同一时刻,只有一个线程能执行 Python 字节码。

注意,这里的关键词不是"线程不能并行",而是**"Python 字节码不能并行"。**

bash 复制代码
如果你有一个 Python 进程:
  线程A → 获得 GIL → 执行 Python 代码 → 释放 GIL
  线程B → 等待 GIL → 获得 GIL → 执行 Python 代码 → 释放 GIL
  线程C → 等待 GIL → 获得 GIL → 执行 Python 代码 → 释放 GIL
  
  不管你有 4 核还是 64 核,同时刻只有一个线程在跑 Python 代码

1.1 为什么 CPython 要加这把锁

CPython 的内存管理基于引用计数。如果没有 GIL,两个线程同时修改一个对象的引用计数就会产生竞态条件------引用计数可能被算错,导致对象被提前释放或永不释放。加一把全局锁是最简单粗暴的线程安全方案------代价就是多核并行能力受限。

其他 Python 实现(Jython、IronPython)没有 GIL------因为它们分别用了 JVM 和 .NET 的垃圾回收器,不依赖引用计数。


2 ~> GIL 什么时候释放------临界点在三类操作之间

2.1 GIL 的持有与释放

CPython 3.2 之前的 GIL 行为很简单:执行 100 条字节码指令后释放。但 3.2 之后换成了固定时间片机制------每个线程持有 GIL 约 5 毫秒后被迫切换。

bash 复制代码
线程持有 GIL 约 5ms → 释放给下一个线程 → 这个线程也持有约 5ms → 循环往复

更关键的是:如果你执行的是 IO 操作(网络读写、文件读写、sleep),GIL 会在 IO 阻塞发生前主动释放。 这就是多线程在 IO 密集场景下依然高效的原因------线程在等网路响应的时候不锁着解释器。

2.2 什么时候 GIL 会被释放

操作类型 GIL 是否释放 说明
纯 CPU 计算(for i in range(10^9) ❌ 不释放(只释放时间片) 只有 5ms 切换,没有主动放弃
time.sleep(1) ✅ 释放 阻塞出主动释放
socket.recv() ✅ 释放 IO 操作,底层 C 库释放 GIL
file.read() ✅ 释放 同 IO 操作
numpy.dot(A, B) ✅ 释放 大量 NumPy 运算在 C 层面完成,C 代码释放 GIL

一把锁如果能在任务阻塞时释放,那它在阻塞密集的场景中就不再是瓶颈。IO 密集型应用的多线程收益就来自这里。


3 ~> CPU 密集型实测------GIL 确实把多线程打回原形

3.1 测试代码:暴力计算斐波那契额

python 复制代码
import time, threading

def fib(n):
    """纯计算,没有任何 IO 操作"""
    if n <= 1:
        return n
    return fib(n-1) + fib(n-2)

# 单线程基准
start = time.perf_counter()
for _ in range(4):
    fib(35)
print(f"单线程串行耗时:{time.perf_counter() - start:.2f}秒")

# 四个线程并发
start = time.perf_counter()
threads = [threading.Thread(target=fib, args=(35,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f"四线程并发耗时:{time.perf_counter() - start:.2f}秒")

3.2 实测数据(i7-12700,8核16线程)

复制代码
单线程串行耗时:12.84秒
四线程并发耗时:13.21秒(≈ 单线程,甚至还更慢!)

四个线程跑纯计算的斐波那契额,耗时和单线程串行基本一样------甚至因为上下文切换的开销稍慢一点。GIL 把四个线程压成了串行执行。

3.3 图解:CPU 密集型场景中 GIL 的行为

bash 复制代码
时间轴(每条横线代表一个线程占用GIL):
线程1:[████ 5ms ████] [████ 5ms ████] [████ 5ms ████]
线程2:                 [████ 5ms ████]                 [████ 5ms ████]
线程3:                                  [████ 5ms ████]
线程4:                                                   [████ 5ms ████]
         ↑                                           ↑
       不是并行,是快速切换------依然是每时刻一个线程。

4 ~> IO 密集型实测------线程数量和速度基本成正比

4.1 测试代码:并发网络请求

python 复制代码
import time, threading, requests

URL = "https://httpbin.org/delay/0.5"    # 每个请求固定延迟 0.5 秒

# 单线程
start = time.perf_counter()
for _ in range(8):
    requests.get(URL, timeout=5)
print(f"单线程串行耗时:{time.perf_counter() - start:.2f}秒")

# 八线程并发
start = time.perf_counter()
threads = [threading.Thread(target=lambda: requests.get(URL, timeout=5)) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()
print(f"八线程并发耗时:{time.perf_counter() - start:.2f}秒")

4.2 实测数据

复制代码
单线程串行耗时:4.12秒  (8 × 0.5秒 + 一点开销)
八线程并发耗时:1.24秒  (快了约 3.3 倍!)

八个线程同时发出请求,IO 阻塞时 GIL 被释放,其他线程能立刻获得解释器访问权继续发请求------在 IO 密度足够高的场景下,线程几乎可以同时并行工作。

4.3 图解:IO 密集型场景中 GIL 的行为

bash 复制代码
时间轴(线程在等 IO 时,GIL 释放给其他线程):
线程1:[GIL▕ 发请求 ▏       等待响应         ▏收数据 ▕ 释放]
线程2:       [GIL▕  等待           ]
线程3:             [GIL▕ 等待      ]
线程4:                   [GIL▕ 等待]
八个请求几乎同时发出 → 也几乎同时返回 → 总耗时接近 0.5 秒

GIL 只串行化字节码执行,不串行化 IO 操作。 请求通过网络发出去之后,等待数据返回的过程中解释器什么都不做------这时候 GIL 释放给其他线程,让它们继续发出请求。


5 ~> 什么时候该用多线程、什么时候该切多进程

5.1 决策表

场景 最佳方案 原因
大量网络请求(爬虫、API 调用) 多线程 IO 阻塞时释放 GIL,多线程收益显著
大量磁盘读写(日志处理) 多线程 同 IO 密集,但注意文件系统性能瓶颈
纯 Python 计算(数论、字符串处理) 多进程 GIL 限制,多线程≈单线程
NumPy/Pandas 数据处理 多线程 底层 C 代码释放 GIL
数据库查询 多线程 等待数据库响应时 GIL 已释放
GUI 事件 + 后台计算 多线程 主线程处理 UI,后台线程做计算

5.2 一个真实的踩坑经历

2019 年给团队写了一个数据清洗脚本------从 Redis 批量读到 Pandas DataFrame 里做清洗,再写到 MySQL。一开始写的多线程版本,8 个线程并发处理,结果单机处理速度和单线程相差无几。

排查发现瓶颈不在 IO,而在 Pandas 的 DataFrame 合并操作------这部分被 GIL 锁住,八个线程几乎串行。把任务拷进一个进程池(4 个 worker),每个 worker 里全跑 Pandas 操作------处理时间从 12 分钟降到了 4 分钟。

教训:先测瓶颈在哪再选方案。不要听说"GIL 限制多线程"就否定一切多线程设计,也不要迷信"多线程一定比单线程快"。


思考 && 总结

GIL 不是洪水猛兽------它是一把只在特定场景中成为瓶颈的锁。

  1. CPU 密集型任务 (纯 Python 计算):GIL 确实限制多线程并行。改成 multiprocessing,或者切到 C 扩展(Cython/Numba)绕过 GIL。
  2. IO 密集型任务(网络、磁盘、数据库):多线程收益显著。IO 等待期间 GIL 主动释放,其他线程获得执行机会,吞吐量接近线性增长。
  3. NumPy/Pandas 场景:底层 C 实现大量释放 GIL,多线程可跑满多核。

下篇我们深入到绕过 GIL 的三种实战方案------多进程池、C 扩展、异步编程(asyncio),并给一个完整的决策树。


结尾

各位小伙伴,上篇到这里就结束了。源码骑士再次感谢您的阅读!

源码骑士 --- 源码级拆解,从底层看透技术

👀 关注:跟博主一起从源码视角深耕底层原理

❤️ 点赞:让优质内容被更多人看见

收藏:核心知识点存好,随用随查

💬 评论:分享你的经历或疑问,一起交流

🔄 一键四连:别忘了给博主一键四连!今日源码拆解达成!

🗡️ 寄语:先测再改,才不当改量的大冤种。

结语:GIL 是 Python 的一道坎,也是面试的一道试金石。上篇告诉你"什么时候它真的是瓶颈",下篇讲"怎么绕过它"。一键四连别忘了!

相关推荐
johnny2331 小时前
Python生态模版引擎:Django、Jinja2、Liquid、Mustache、Mako、Chameleon
python
Suxing91 小时前
C语言基础分享——内存里的“左右手互搏”术:大小端
c语言·开发语言·学习
Shadow(⊙o⊙)2 小时前
C++进阶知识3.0
linux·服务器·开发语言·c++
喵叔哟2 小时前
Week 3 --Day 5:性能优化与监控
人工智能·python·性能优化·langchain
Kingairy2 小时前
python3装饰器
开发语言·python
多彩电脑2 小时前
SwiftUI的导航界面的嵌套问题
开发语言·swift·设计语言
.千余2 小时前
【C++】C++ map 与 multimap 完全指南:键值对容器详解
开发语言·c++·笔记·学习·其他
牢姐与蒯2 小时前
c++数据结构之c++11(三)
开发语言·c++
暗黑小白2 小时前
第八篇:人在回路与内容安全 —— 当 AI 说“让我请示一下“
python·安全·架构·ai agent