文章目录
- [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 多线程没用"的复读机,而是能根据具体场景选工具的工程师。
思考 && 总结
三道门、三道锁,总结一句话:不要用什么方案,先确定什么瓶颈。
- 多进程解决 CPU 密集型------独立解释器,无 GIL 竞争。代价是数据共享要序列化。
- C 扩展 绕过 GIL------在计算前释放这把锁让其他线程干活。Numba 一行
nogil=True就能用。 - 协程 asyncio 解决高并发 IO------单线程、无锁、低开销。全链路异步是关键约束。
记住决策树上那条"不确定 → 先跑 profile"------我踩过的坑里,80% 的性能优化方向一开始都是错的。
结尾
GIL 上下篇到此完结。感谢耐心看到这里的你!
源码骑士 --- 源码级拆解,从底层看透技术
👀 关注:跟博主一起从源码视角深耕底层原理
❤️ 点赞:让优质内容被更多人看见
⭐ 收藏:核心知识点存好,随用随查
💬 评论:分享你的经验或疑问,一起交流
🔄 一键四连:别忘了给博主一键四连!今日源码拆解达成!
🗡️ 寄语:先测后改是优化这个行当的第一条定律。
结语:GIL 是一个话题,三种绕过去的方案,一个决策树。下篇我们深入列表------lst.append(1) 在 C 源码里到底做了什么。一键四连!