12-GIL不是性能杀手(下)-绕过GIL的三种方案与决策树

文章目录

  • [GIL 不是性能杀手(下):绕过 GIL 的三种方案------多进程、C扩展、asyncio 到底怎么选](#GIL 不是性能杀手(下):绕过 GIL 的三种方案——多进程、C扩展、asyncio 到底怎么选)
    • 导入语
    • [1 ~> 方案一:`multiprocessing` 多进程池------CPU密集场景的首选](#1 ~> 方案一:multiprocessing 多进程池——CPU密集场景的首选)
      • [1.1 原理](#1.1 原理)
      • [1.2 基础用法](#1.2 基础用法)
      • [1.3 数据共享------多进程的代价](#1.3 数据共享——多进程的代价)
      • [1.4 我的经验](#1.4 我的经验)
    • [2 ~> 方案二:C 扩展------把瓶颈代码移出 GIL 的管辖区](#2 ~> 方案二:C 扩展——把瓶颈代码移出 GIL 的管辖区)
      • [2.1 原理](#2.1 原理)
      • [2.2 三种降低门槛的方式](#2.2 三种降低门槛的方式)
    • [3 ~> 方案三:`asyncio` 协程------单线程高并发](#3 ~> 方案三:asyncio 协程——单线程高并发)
      • [3.1 原理](#3.1 原理)
      • [3.2 示例:并发请求](#3.2 示例:并发请求)
      • [3.3 asyncio 的局限](#3.3 asyncio 的局限)
    • [4 ~> 终极决策树------你的场景该用哪个](#4 ~> 终极决策树——你的场景该用哪个)
      • [4.1 为什么这三种方案能并存](#4.1 为什么这三种方案能并存)
    • [思考 && 总结](#思考 && 总结)
    • 结尾

GIL 不是性能杀手(下):绕过 GIL 的三种方案------多进程、C扩展、asyncio 到底怎么选

📖 文章简介: 上篇用实测数据验证了 GIL 只在 CPU 密集型场景中成为瓶颈,本篇聚焦解决方案。逐一拆解三种绕过 GIL 的实战方案:multiprocessing 进程池------适合 CPU 密集的纯 Python 计算;C 扩展(Cython/Numba)------在 C 层面运行计算密集代码,自动释放 GIL;asyncio 协程------单线程高并发处理 IO 密集任务。每种方案配有完整的可跑代码和适用场景分析,结尾给一个决策树------拿到需求先看瓶颈类型,再看数据共享需求,最后选方案。穿插真实经历:将数据处理服务从多线程切到多进程池,性能提升 3 倍。


🎬 个人主页: 源码骑士

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

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


🎬 源码骑士的简介:

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

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

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


导入语

上篇讲清楚了 GIL 在什么场景下是瓶颈。这篇上干货------绕过 GIL 的三种方案。

先声明一个前提:没有"最好"的方案,只有"最适合你当前场景"的方案。 我见过很多人因为看了某篇推荐 asyncio 的文章,就把所有东西写成协程------结果 CPU 密集计算部分不仅没变快,还因为协程切换开销反而更慢了。三把刀各有用途------菜刀切菜、砍刀劈骨、水果刀削皮------你不可能只用一把刀做所有事。


1 ~> 方案一:multiprocessing 多进程池------CPU密集场景的首选

1.1 原理

多进程不受 GIL 限制------因为每个进程有自己的独立 Python 解释器实例,各带一把 GIL。四进程就是四个独立的 CPython,各自运行各自的字节码。

1.2 基础用法

python 复制代码
from multiprocessing import Pool
import time

def fib(n):
    if n <= 1:
        return n
    return fib(n-1) + fib(n-2)

if __name__ == "__main__":
    start = time.perf_counter()
    with Pool(processes=4) as pool:
        results = pool.map(fib, [35, 35, 35, 35])
    print(f"四进程池耗时:{time.perf_counter() - start:.2f}秒")

上篇四线程跑斐波那契额 13 秒。换成四进程------同样的计算,耗时约 3.3 秒。几乎线性加速。

1.3 数据共享------多进程的代价

多进程的数据共享不免费。每个进程有自己的内存空间,数据传递需要序列化(pickle)+ 跨进程 IPC 传输。如果输入输出数据量大(几百 MB 的 DataFrame),序列化开销可能比计算本身还大。共享内存或 multiprocessing.Manager 能缓解一部分,但它们增加复杂度。

适用判断: 计算开销远大于数据传输开销时用多进程。否则还不如单进程。

1.4 我的经验

之前那个数据处理服务------数据包很小(每批 200KB raw 日志文本),但计算极其重(正则解析 + 聚合)。切到 4-process Pool 之后,跑量时间从 12 分钟降到 4 分钟。序列化开销约 1%,可忽略。但如果数据包改成了 50MB 一张图片的压缩处理------单进程可能更快。


2 ~> 方案二:C 扩展------把瓶颈代码移出 GIL 的管辖区

2.1 原理

GIL 只锁 Python 字节码。如果你的代码跑在 C 层面,可以在执行前调用 Py_BEGIN_ALLOW_THREADS 释放 GIL,其他 Python 线程可以继续执行。NumPy、pandas 的大量核心运算是这样做的------这也是为什么你能在多线程中高效使用 NumPy。

2.2 三种降低门槛的方式

方式一:Numba JIT 编译器------一行装饰器搞定

python 复制代码
from numba import jit
import math, time, threading

@jit(nopython=True, nogil=True)   # nopython=True: 启 JIT 编译; nogil=True: 释放 GIL
def heavyloop(n):
    result = 0
    for i in range(n):
        result += math.sqrt(i)
    return result

nogil=True 告诉 Numba:编译成原生代码后别忘了释放 GIL,让其他 Python 线程也可以执行。多线程调用 heavyloop 时就能获得真正的并行。

方式二:Cython------写 Python 风格的 C 代码

python 复制代码
# heavy.pyx(Cython 文件)
cdef double heavyloop(int n) nogil:   # nogil 声明这个函数不持有 GIL
    cdef double result = 0
    cdef int i
    for i in range(n):
        result += math.sqrt(i)
    return result

nogil 关键字声明这个 Cython 函数执行时不持有 GIL。

方式三:直接用 C/Python API------不适合日常开发,只适合写底层库

这是 NumPy/Pandas 团队用的方法。一般你在 Python Web 应用里很少需要写 CPython C API,知道这种做法存在就行。


3 ~> 方案三:asyncio 协程------单线程高并发

3.1 原理

协程不是多线程------协程是单线程内的协作式多任务。一个协程在 await 时把控制权主动还给事件循环,事件循环调度另一个协程执行。没有上下文切换开销,没有 GIL 竞争,可以轻松创建上万任务。

只适用于 IO 密集------因为协程本身不利用多核。CPU 密集代码跑在协程里依然是单核。

3.2 示例:并发请求

python 复制代码
import asyncio, aiohttp, time

async def fetch(session, url):
    async with session.get(url) as resp:
        return await resp.text()

async def main():
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, f"https://httpbin.org/delay/0.5") for _ in range(20)]
        results = await asyncio.gather(*tasks)
        print(f"并发 20 个请求完成")

start = time.perf_counter()
asyncio.run(main())
print(f"耗时:{time.perf_counter() - start:.2f}秒")
# 在 20 个并发请求的情况下,耗时接近 0.5 秒(而非串行的 10 秒)

3.3 asyncio 的局限

  • 整个调用链必须是异步的------一个同步库的调用会卡死事件循环。
  • CPU 密集任务仍然会阻塞------可以用 loop.run_in_executor() 把重计算扔到线程池。
  • 调试比多线程还痛苦------没有堆栈帧,Exception 信息少。

4 ~> 终极决策树------你的场景该用哪个

bash 复制代码
拿到一个性能优化任务
│
├─ 瓶颈是 IO 流(网络 / 磁盘 / 数据库查询)
│  ├─ 并发量 < 100,需要快速上线 → 多线程 ✅
│  └─ 并发量 > 1000,全链路异步可用 → asyncio ✅
│
├─ 瓶颈是 CPU 计算(纯 Python 逻辑)
│  ├─ 计算量不大,代码简单 → 多进程池 ✅
│  ├─ 计算量中等,能引入 C 扩展 → Numba / Cython ✅
│  └─ 库已经是 NumPy/Pandas → 多线程 ✅(C 层面释放 GIL)
│
└─ 瓶颈不确定
   └─ 先写最简版本 + 用 cProfile 跑一遍 → 从数据出发选方案,别凭感觉

4.1 为什么这三种方案能并存

整个 Python 生态的设计是"分层处理 GIL":

  • 简单任务 → 多进程 ------ 不需要改代码,开箱即用
  • 性能敏感路径 → C 扩展 ------ 把瓶颈移出 Python 字节码层
  • 高并发服务 → asyncio ------ 单线程、低开销、十万级同时连接

了解这三种方案之后,你不再是"因为 GIL 所以 Python 多线程没用"的复读机,而是能根据具体场景选工具的工程师。


思考 && 总结

三道门、三道锁,总结一句话:不要用什么方案,先确定什么瓶颈。

  1. 多进程解决 CPU 密集型------独立解释器,无 GIL 竞争。代价是数据共享要序列化。
  2. C 扩展 绕过 GIL------在计算前释放这把锁让其他线程干活。Numba 一行 nogil=True 就能用。
  3. 协程 asyncio 解决高并发 IO------单线程、无锁、低开销。全链路异步是关键约束。

记住决策树上那条"不确定 → 先跑 profile"------我踩过的坑里,80% 的性能优化方向一开始都是错的。


结尾

GIL 上下篇到此完结。感谢耐心看到这里的你!

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

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

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

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

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

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

🗡️ 寄语:先测后改是优化这个行当的第一条定律。

结语:GIL 是一个话题,三种绕过去的方案,一个决策树。下篇我们深入列表------lst.append(1) 在 C 源码里到底做了什么。一键四连!

相关推荐
一只齐刘海的猫1 小时前
【Leetcode】无重复字符的最长子串
算法·leetcode·职场和发展
行智科技1 小时前
FAST-LIVO2 源码精读(二):环境搭建与编译避坑
算法·ubuntu·自动驾驶·slam
Hello数据集1 小时前
医疗AI实战:如何利用免疫与内分泌系统疾病数据集训练高精度预测模型?
人工智能·机器学习·数据挖掘·医疗ai
插件开发1 小时前
vs2015 cuda c++ cdpSimplePrint范例,递归功能实现演示
linux·c++·算法
Tisfy1 小时前
LeetCode 2130.链表最大孪生和:转数组 / 快慢指针+链表翻转(O(1))
算法·leetcode·链表·题解
来自于狂人2 小时前
第5章 记忆管理——让Agent记住事情
人工智能·算法·语言模型·自然语言处理
CHHH_HHH2 小时前
【C++】哈希表原理与实战:从冲突解决到性能优化
开发语言·数据结构·c++·学习·算法·哈希算法·散列表
sali-tec2 小时前
C# 基于OpenCv的视觉工作流-章84-包胶有无检测
图像处理·人工智能·opencv·算法·计算机视觉
Irissgwe2 小时前
数据结构-排序
数据结构·算法·排序算法