CPython 性能与剖析
"这段代码慢"是最没信息量的判断。慢在哪一行、慢在算法还是慢在数据结构、是 CPU 饱和还是卡在 IO——这些必须靠测量,不能靠感觉。这一章讲清 GIL 到底是什么(以及 3.13 的 free-threading 意味着什么),然后给你三把剖析刀、一把计时钟、三把内存显微镜,最后讲优化该从哪一层下手。
GIL 到底是什么
是什么:GIL(Global Interpreter Lock)是 CPython 解释器里的一把全局互斥锁。同一时刻,一个进程内只有一个线程能执行 Python 字节码。注意三个限定词:CPython(PyPy、Jython 没有)、一个进程内(多进程各自有锁)、执行 Python 字节码(调 C 扩展时通常会释放锁)。
为什么要有它:不是为了"限制你",而是为了让 CPython 的实现简单且安全。最直接的好处是:引用计数的增减不需要每个对象一把细粒度锁。如果没有 GIL,refcount += 1 这种操作要在所有地方加锁,会带来巨大的性能开销和死锁风险,且大量 C 扩展要重写。用一把大锁换来了"整个生态都好写 C 扩展"。
它带来什么后果:多线程在 CPU 密集型任务上完全不能加速——4 个线程算 4 个任务,总时间和串行一样(甚至更慢,因为切换有开销)。但在 IO 密集型任务上,多线程依然有效:线程在 recv()、sleep()、文件读写时会主动释放 GIL,其他线程就能跑。
3.13 的 free-threading 是什么:Python 3.13 提供了一个实验性的"无 GIL 构建"(PEP 703),编译时启用 --disable-gil,运行时可用 PYTHON_GIL=0 或 -X gil=0 关掉 GIL,让多线程真的并行执行字节码。代价比想象中大:① 单线程性能有回退(需要偏向式引用计数、各种细粒度锁与内存屏障);② 大量尚未适配的 C 扩展在无 GIL 下无法工作或性能异常,生态需要时间;③ 它是单独下载或编译的"另一套解释器",不是默认版本。结论:生产环境继续用标准 CPython。想并行就多进程;想高并发 IO 就用 asyncio。等官方宣布正式化、主流科学计算栈都适配之后再迁移。
| 任务类型 | 特征 | 该用什么 | 为什么 |
|---|---|---|---|
| CPU 密集 | 持续计算,几乎不等待 | multiprocessing / ProcessPoolExecutor | 绕开 GIL,每个进程独立解释器 |
| IO 密集(少量并发) | 等网络、等磁盘 | threading / ThreadPoolExecutor | 阻塞时释放 GIL,写法简单 |
| IO 密集(大量并发) | 成千上万连接 | asyncio 加 aiohttp / asyncpg | 单线程事件循环,开销远小于线程 |
| 数值计算 | 数组运算、矩阵 | NumPy / Pandas(底层 C) | C 代码执行时释放 GIL,且向量化更快 |
| 混合 | 既要算又要等 | 多进程,每进程内 asyncio | 进程吃满 CPU,进程内并发 IO |
论进程池不是"免费的加速"
① 启动成本:Linux 上 fork 相对便宜,但 Windows 和 macOS 用 spawn,每个子进程都要重新 import 你的模块——如果模块里有重逻辑(加载模型、建连接),4 个进程就执行 4 遍。② 数据要序列化:参数和返回值都要 pickle 穿过进程边界。传 100 MB 的 DataFrame 给子进程,光序列化就能吃掉所有收益。③ 所以多进程的适配场景是:任务粒度粗(每个任务几百毫秒以上)、输入输出小(传 id 而不是传数据)、任务之间独立。④ 更本质的一条:把任务丢给多进程之前,先问一句"这个计算能不能用 NumPy 向量化"。向量化通常比"开 8 个进程跑 Python 循环"快得多,而且不占内存、不用序列化。
很多人在文章里看到"Python 多线程是假的",于是在一个"批量调 100 个 HTTP 接口"的脚本里老老实实写串行循环,跑 100 秒。这类任务用线程池 10 秒就完了——因为瓶颈在网络等待,GIL 在等的时候是放开的。正确的心智模型:GIL 只惩罚"CPU 时间占比高"的任务。判断方法很简单:time.time() 的差值远大于 time.process_time() 的差值,说明时间花在等待上(IO 密集);两者接近就是真在算(CPU 密集)。
三把剖析刀:cProfile / line_profiler / py-spy
是什么:三种剖析工具,粒度与侵入性不同,适用场景也不同。
| 工具 | 粒度 | 需要改代码 | 能否用于线上 | 最适合 |
|---|---|---|---|---|
cProfile | 函数级(调用次数、累计耗时) | 要(或用 -m cProfile) | 不建议(有开销) | 先定位"哪个函数最慢" |
line_profiler | 行级 | 要(加 @profile) | 不建议 | 已锁定函数后找具体那一行 |
py-spy | 函数级采样 | 不用 | 可以(直接 attach 运行中的进程) | 线上卡住、生产采样、火焰图 |
论火焰图怎么读:横轴是时间,纵轴是调用栈
① 横轴不代表时间顺序,只代表占比:某个方块越宽,说明它(及其子调用)占用的采样越多;左右顺序是无关的。② 找最宽的"叶子":一个函数在最底层(没有更深的子调用)却占很宽,它就是热点本体;如果它很宽但下面还有更宽的子函数,问题在更深处。③ 别被"平台期"骗了:顶部聚集成一大片的 read、recv、select 说明时间花在等 IO 上——这时候优化算法毫无意义,该优化的是"并发方式"或"减少往返次数"。④ 结论式的用法:先用 py-spy top 花 30 秒找出最热的函数,再用 line_profiler 钻进去看具体哪一行,改完再测一遍——这才叫"优化",凭感觉改叫"折腾"。
剖析有"观察者效应",程度不同:cProfile 开销通常在数倍级别,而且它会让调用次数多的小函数显得更贵(每次调用都要记账),可能把包在循环里的工具函数顶到榜首,而真正贵的大函数被摊薄了。line_profiler 的行级计数也会让每行变慢,不适合测绝对耗时,只看相对占比。py-spy 是外部采样,开销最小、最接近真实,但采样分辨率有限(很短的脚本可能采不到几个点)。方法:先用 py-spy 看真实分布,用 cProfile 看调用关系与次数,用 line_profiler 精确定位,最后以"端到端墙钟时间"作为唯一验收标准。
timeit 的正确用法
是什么:timeit 反复执行一小段代码,取多次运行中的最小值,用来自动避开"单次测量被系统抖动污染"的问题。它回答的是"这两种写法的常数级差异",不是"这个程序要跑多久"。
| 做法 | 为什么对或错 |
|---|---|
用 timeit 测"一次请求耗时" | 错,那不是常数级微差异,应该用 time.perf_counter 计时 |
| 把数据准备写在被测语句里 | 错,准备成本会被算进结果,要用 -s 或 setup |
| 看多次重复里的最小值 | 对,最小值最能代表"没有干扰时"的真实性能 |
number=1 就下结论 | 错,噪声远大于差异;至少要上万次量级 |
| 用它比较 O(n) 和 O(n²) 算法 | 不太对,应该换不同规模 n 测增长趋势 |
timeit 说 A 比 B 快 15%,把 B 换成 A 之后接口总耗时没变——因为那 15% 只占整体耗时的 0.3%。微基准的两个使用原则:① 只在"这个函数真的在热点上"之后再比较实现细节(先用剖析工具确认它的占比显著);② 关心的不是"快了 15%",而是"这 15% 乘上调用频率之后值不值得"。反过来说,微基准非常适合另一类场景:验证"我以为的写法"是否真的更快——很多流传的"优化技巧"在不同 Python 版本上结论会变,实测一次比背结论可靠。
内存剖析:tracemalloc / memory_profiler / objgraph
是什么:CPU 剖析回答"哪里慢",内存剖析回答"谁在占内存、谁在泄漏"。Python 的内存问题有两种:峰值过高(一次加载太大)和持续增长(对象没被回收)。
| 症状 | 先用什么 | 再看什么 |
|---|---|---|
| 进程被 OOM Killer 杀掉 | tracemalloc 峰值统计 | 哪一行分配最大,能否改流式处理 |
| 内存随时间持续上涨 | tracemalloc 的 compare_to | objgraph.show_backrefs 找引用链 |
| 不确定某函数吃多少内存 | memory_profiler 逐行 | 把列表改生成器、分批处理 |
| 疑似循环引用无法回收 | gc.get_referrers | 用 weakref 打破环,或检查 __del__ |
| 想知道对象数量趋势 | objgraph.show_growth | 定位到具体类再回看代码 |
Python 用引用计数加分代 GC,正常情况下对象用完就会释放。内存持续增长的常见真实原因:① 全局缓存无上限——模块级的 dict 或 lru_cache(maxsize=None) 一直往里塞;② 闭包或回调持有大对象——注册的回调捕获了整份数据,注册一次就永远留在内存;③ 循环引用叠加 __del__——会拖慢回收,需要显式检查;④ 误以为"内存会还给操作系统"——Python 释放的对象内存常留在自己的 arena 里给后续分配复用,top 上的 RSS 不下降不代表泄漏,要看对象计数。排查顺序永远是:先确认真泄漏(对象数持续增长),再找引用链,最后改代码。
优化的优先级:算法优先于数据结构优先于局部优化
是什么:性能优化有个几乎不该被打破的优先级——先换算法(复杂度降一级),再换数据结构(把 O(n) 查找换成 O(1)),最后才是局部微优化(少几个属性访问、避免重复编译正则)。
| 层级 | 典型改动 | 收益量级 | 风险 |
|---|---|---|---|
| 算法 | O(n²) 改 O(n);嵌套循环改哈希表;排序改堆 | 数量级(10 到 1000 倍) | 逻辑易错,要有测试覆盖 |
| 数据结构 | list 改 set / dict;list 头部操作改 deque | 10 倍级 | 低,但要确认语义(如需要有序) |
| 架构 | 批量接口代替逐条请求;加缓存;预处理 | 数量级,省的是 IO | 中,涉及一致性问题 |
| 局部 | 缓存属性访问、局部变量、避免重复构造 | 几个百分点到几十个百分点 | 低,但可读性变差 |
| 换实现语言 | 热点函数用 C / Rust 扩展重写 | 10 到 100 倍 | 高,构建与维护成本大(见第 16 章) |
论为什么"先测再改"是不可跳过的
① 人的直觉在性能上很不准:程序员普遍低估 IO 和序列化的成本,高估"循环里那点运算"。凭经验猜的热点常常不是真正的瓶颈。② 局部优化容易被更大的问题吃掉:你花半天把一个函数优化快 30%,但它在总耗时里只占 2%,最终端到端提升 0.6%——用户完全感知不到,代码却难读了一截。③ 优化的正确闭环:测量 → 定位热点 → 改一处 → 再测量 → 对比。没有"再测量"这一步,你无法知道改动是否有效,也无法发现"改完之后瓶颈转移到了哪里"。④ 最后一条纪律:优化必须有基准测试守着。没有测试的优化会在下一次重构中悄悄退化,而且没人发现——直到半年后又有人来问"为什么变慢了"。
常见加速手段:四个立刻能用的写法
| 手段 | 适用场景 | 代价与注意 |
|---|---|---|
__slots__ | 几十万个同构小对象 | 不能动态加属性,也不能继承带 __dict__ 的基类 |
| 生成器 | 大文件、大结果集、流式管道 | 只能遍历一次;调试不如列表直观 |
lru_cache | 纯函数、参数空间有限 | 必须设 maxsize;写操作后要 cache_clear |
join | 大量字符串拼接 | 先收集到列表再 join;注意峰值内存 |
| 局部变量缓存 | 超热循环里的属性或全局查找 | 可读性下降,只在剖析后做 |
@lru_cache(maxsize=None) 加上一个参数空间很大的函数(比如 get_user(user_id),参数是几百万个 id),效果就是"把所有用户对象永久钉在内存里"。而且它缓存的是对象引用——一旦被缓存,GC 永远不会回收,用户数据变更后你还返回陈旧对象。规则:① 缓存"从参数到结果的纯映射",别缓存 ORM 对象;② 一定给 maxsize;③ 写操作后显式 cache_clear(),或用 cachetools 的 TTLCache 自动过期;④ 服务里的"缓存"优先用 Redis(可过期、可共享、可观测),进程内缓存只留给真正无状态的高频纯函数。
什么时候该换武器:numpy / 多进程 / 异步
是什么:当纯 Python 优化到极限还达不到要求时,有三条换武器的路:向量化(numpy)、并行(多进程)、异步(asyncio)。它们解决三类不同问题,选错方向再多努力也没用。
| 瓶颈真的在 | 该换什么 | 为什么 | 反例(换了没用) |
|---|---|---|---|
| 大量数值计算(逐元素运算) | NumPy / Pandas 向量化 | C 层循环加连续内存,比 Python 循环快几十到几百倍 | 数据量很小(几百个元素)时,转换开销比收益大 |
| CPU 被算满,单核跑不过 | 多进程 / ProcessPoolExecutor | 绕开 GIL,真正多核并行 | 任务太小、数据太大(序列化吃掉收益) |
| 等网络或磁盘,并发上千 | asyncio 加异步库 | 单线程事件循环,开销远低于线程 | 用的库是同步阻塞的(会卡死事件循环) |
| 重复计算同一结果 | 缓存(Redis / lru_cache) | 不计算就是最快的优化 | 结果有时效性、缓存一致性更贵 |
| 单次调用就要几毫秒以上的热点函数 | C 扩展 / Rust / Cython | 消除解释器开销(见第 16 章) | 边界没划好,跨语言调用比计算还贵 |
论别在同一段代码里同时用三种武器
① asyncio 里调同步阻塞函数是大忌:事件循环是单线程的,一个 time.sleep(1) 或同步 requests.get() 会卡住所有协程。要么换异步库(aiohttp、httpx.AsyncClient、asyncpg),要么用 asyncio.to_thread() 把阻塞调用丢到线程池。② 多进程里再开线程池要小心:每层并行度相乘,容易出现"进程数乘线程数"远大于核数,反而因上下文切换变慢。③ 最优结构通常是分层的:进程数等于 CPU 核数(吃满计算),每个进程内用 asyncio 处理 IO;或者干脆把"IO 密集的采集"和"CPU 密集的计算"拆成两个服务。④ 最后一条最实用的建议:先问"这个计算真的需要做吗"。缓存、增量更新、把工作挪到写入时而不是查询时——省掉计算,永远比加速计算更有效。
① 数据量小:几百个元素的列表转成 ndarray 的构造开销加类型转换,可能比 Python 循环还慢。向量化要到十万级以上的规模才开始明显。② 数据要频繁跨边界:在 Python 循环里一次处理一个元素,每次都要建一个 numpy 标量对象(比 Python 的 int 还慢)——必须"整批进、整批出"。③ 逻辑有复杂分支:向量化要求用 np.where、布尔掩码表达分支;写不出来时用 np.vectorize 假装向量化,实际上它只是把循环藏起来,根本不快。判断方法:改完一定用同一份数据、同一个口径测端到端时间,别相信"用了 numpy 就一定快"。
① GIL 让同一进程内只有一个线程跑字节码:CPU 密集用多进程,IO 密集用线程或 asyncio。3.13 的 free-threading 还是实验特性,生产别上。
② 三把刀:cProfile 看函数级排名与调用次数,line_profiler 定位到行,py-spy 不改代码、能 attach 线上进程并出火焰图。火焰图横轴是时间占比,找最宽的叶子。
③ timeit 只回答常数级差异:准备成本放 setup、上万次量级、看最小值;微基准的结论要乘上调用频率才有意义。
④ 内存:tracemalloc 找分配点与增长差异,memory_profiler 看逐行增量,objgraph 追引用链。先确认真泄漏,再找谁抓着不放。
⑤ 优化优先级:算法优于数据结构,优于架构(缓存或批量),优于局部微优化,最后才是换语言。每改一处都要再测量,并用基准测试守住成果。
⑥ 换武器前先确认瓶颈类型:数值计算用向量化、算满 CPU 用多进程、高并发等待用 asyncio。别为了"用了新技术"而优化。
1.(概念题)GIL 是什么?为什么它让"多线程加速 CPU 密集任务"失效,却不影响 IO 密集任务?
查看答案
答案:GIL 是 CPython 的全局互斥锁,同一时刻一个进程内只有一个线程能执行 Python 字节码。CPU 密集任务全程需要字节码执行权,线程只能排队;IO 密集任务在阻塞调用(网络、磁盘、sleep)时会主动释放 GIL,其他线程能继续跑,所以能真正并行等待。
2.(概念题)cProfile、line_profiler、py-spy 分别适合什么场景?
查看答案
答案:cProfile 做函数级统计(调用次数、自身耗时、累计耗时),用来先找出最慢的函数;line_profiler 做行级统计,用来在已定位的函数里找出具体哪一行;py-spy 是外部采样,不需要改代码、能 attach 到运行中的生产进程并生成火焰图,适合排查线上卡顿。
3.(概念题)用 timeit 比较两种写法时,哪些做法是错的?
查看答案
答案:三类常见错误:① 把数据准备写进被测语句(准备成本被算进去,要用 -s 或 setup);② number 太小,噪声大于差异;③ 用它测"一次请求要多久"——那是宏观耗时,应该用 time.perf_counter 或专门的基准测试。另外应看多次重复的最小值。
4.(思考题)接口 P95 从 200ms 涨到 2s,你用 py-spy 看到最宽的是 recv 和 select。接下来你会怎么排查?
查看答案
答案:火焰图顶部大量阻塞在 recv 或 select,说明时间花在等待上,不是 CPU 计算。所以优化算法没用,要查:① 哪个下游依赖变慢了(数据库慢查询、第三方 API);② 查询次数是否变多(N+1);③ 连接池是否被耗尽导致排队;④ DNS 解析或 TLS 握手是否异常。方向是"减少往返次数、加缓存、调并发",不是"让代码跑得更快"。
5.(代码题)有个函数每次调用要 5 毫秒,被循环调用 100 万次。你会按什么顺序尝试优化?
查看答案
答案:① 先看能不能不调用——批量处理、缓存结果、把计算挪到写入时;② 看是不是算法问题(每轮是否重复做了本可复用的工作);③ 看数据结构(是否存在 in list 这类 O(n) 查找);④ 试着向量化(NumPy);⑤ 如果确实是纯计算热点且必须保留,再考虑用 C 或 Rust 扩展重写这一个函数。顺序不能倒过来——先写扩展是典型的"努力错方向"。