「Python 进阶之路」系列 Day24
写在前面
写代码的人对"哪里慢"通常都有一种直觉,习惯性地怀疑那些看起来循环次数多、计算量大的代码。但今天用一个实测例子说明:这种直觉经常是错的 ,真正的瓶颈可能藏在一行毫不起眼的代码里。找瓶颈不该靠猜,该靠 cProfile。
一、是什么:确定性性能分析器
cProfile 是 Python 内置的确定性性能分析器(deterministic profiler)------它会精确追踪程序运行过程中每一次函数调用,统计每个函数被调用了多少次、自身代码耗时多少、连同它调用的其他函数一共耗时多少。
最简单的用法:
python
import cProfile
cProfile.run("main_work()")
或者命令行直接跑:
bash
python -m cProfile -s cumtime script.py
二、为什么不能凭直觉猜瓶颈
写代码的人对"哪里慢"经常有一种直觉------通常会盯着那些"看起来计算量大"的循环,但真实的瓶颈经常藏在意想不到的地方:一次不起眼的阻塞调用、一个被反复调用的小函数、一段隐藏的 I/O 等待。凭直觉去优化,很可能把力气花在了根本不是瓶颈的地方,这正是需要 cProfile 这类工具的原因------一次运行,自动统计整个调用图里每一个函数的真实耗时,不需要在代码里到处手动插入 time.perf_counter() 计时,也不会遗漏。
三、怎么用
1. 实测:用cProfile揪出被忽略的真凶
python
import cProfile, time
def slow_string_concat(n):
s = ""
for i in range(n):
s += str(i)
return s
def looks_slow_but_isnt(n):
total = 0
for i in range(n):
total += i * i
time.sleep(0.05) # 看起来只是顺手睡一下,很容易被忽略
return total
def fast_join(n):
return "".join(str(i) for i in range(n))
def main_work():
slow_string_concat(200_000)
looks_slow_but_isnt(200_000)
fast_join(200_000)
cProfile.run("main_work()")
跑出来的原始结果(按函数名排序):
sql
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.023 0.023 0.023 0.023 slow_string_concat
1 0.006 0.006 0.057 0.057 looks_slow_but_isnt
1 0.051 0.051 0.051 0.051 {built-in method time.sleep}
1 0.008 0.008 0.029 0.029 {method 'join' of 'str' objects}
很多人凭直觉会觉得 slow_string_concat 才是"耗时大头"(看起来是一个 20 万次循环拼字符串),但实测发现 time.sleep(0.05) 这一行,单独拿出来的耗时(0.051s)比整个字符串拼接函数自身的代码耗时(0.023s)还要多。looks_slow_but_isnt 函数自己的循环代码只花了 0.006s,但因为它内部调用了 time.sleep,算上这次调用后总耗时飙到了 0.057s------如果只看函数名字、不用工具实测,很容易完全漏掉这个"藏"在一个看起来只是普通计算函数里的阻塞调用。
另一个值得注意的发现:这次实测里,朴素的字符串 += 拼接(0.023s)和 "".join()(0.029s,含 join 调用与生成器耗时)并没有出现网上常说的"+= 拼接是灾难性 O(n²)、必须用 join"那种悬殊差距------现代 CPython 对"变量自身引用计数为 1 时的原地 += 拼接"做了专门优化,实际表现比很多教程描述的要好得多。这类"流传很广但不一定在当前版本成立"的说法,同样应该靠实测验证,而不是直接当作教条。Day25 会更系统地讲字符串拼接这类常见性能陷阱。
2. 用pstats排序、过滤输出
cProfile.run() 默认按函数名排序,不方便一眼看出谁最耗时,配合 pstats 模块可以按需要的指标排序、只看前几名:
python
import pstats
profiler = cProfile.Profile()
profiler.enable()
main_work()
profiler.disable()
stats = pstats.Stats(profiler)
stats.sort_stats("tottime") # 按函数自身耗时排序
stats.print_stats(6) # 只看前6名
按 tottime 排序后,time.sleep 直接排在了第一位------这一步排序把"谁的自身代码最耗时"这个问题一眼摆在了面前,不用再逐行去读一堆没排序的数据。
3. tottime vs cumtime怎么读
tottime(total time):这个函数自身代码的执行耗时,不包括它调用的其他函数花的时间cumtime(cumulative time):这个函数从进入到返回的总耗时,包括它调用的所有子函数耗时(也包括递归调用)
looks_slow_but_isnt 是最典型的例子:它自身的 tottime 只有 0.006s(就是那个 for 循环算平方和的代码),但因为它内部调用了 time.sleep(0.05),cumtime 变成了 0.057s------看 cumtime 才能知道"调用这个函数总共要等多久",看 tottime 才能知道"问题到底出在这个函数自己的代码里,还是它调用的别的函数里",两个指标要配合着看,只看一个容易得出错误结论。
四、面试追问
Q1:cProfile 是怎么工作的?
它是一个确定性性能分析器,通过钩子机制精确追踪程序运行过程中每一次函数调用,统计每个函数的调用次数、自身耗时(tottime)、累计耗时(cumtime),一次运行就能拿到整个调用图的耗时分布,不需要手动到处插桩计时。
Q2:tottime 和 cumtime 的区别是什么?
tottime 是函数自身代码的执行耗时,不包含它调用的子函数花的时间;cumtime 是包含所有子函数调用(含递归)在内的总耗时。一个函数自身逻辑很简单但调用了一个耗时的子函数时,会表现为 tottime 很小但 cumtime 很大,这种情况说明真正的瓶颈在被调用的那个子函数身上。
Q3:为什么不能靠直觉判断性能瓶颈?
直觉容易被"看起来计算量大"的代码误导------真实的瓶颈经常藏在不起眼的地方,比如一次容易被忽略的阻塞调用、一个被反复调用的小函数。实测中一个看似普通的计算函数因为内部藏了 time.sleep,耗时反而超过了"看起来更耗时"的循环,这就是不实测就下结论会踩的坑。
Q4:cProfile 和采样型 profiler 有什么区别?
cProfile 是确定性分析,精确记录每一次函数调用,数据准确,但因为要追踪所有调用会带来一定的性能开销;采样型 profiler(如 py-spy)是按固定时间间隔抽样记录当前的调用栈,开销更小、更适合在生产环境里使用,但得到的是统计估计的结果,不是精确值。
Q5:定位到瓶颈之后,常见的优化思路是什么?
先看瓶颈的性质:如果是 tottime 高,说明问题在这个函数自身的代码逻辑里,考虑优化算法或数据结构;如果是 cumtime 高但自身 tottime 低,说明问题在它调用的某个子函数身上,需要顺着调用链往下找,常见的原因是不必要的阻塞调用、可以缓存起来的重复计算,或者可以异步化处理的 I/O 操作。
下一篇预告
Day25 是模块五的收官篇------常见性能陷阱:字符串拼接、全局变量查找、列表 vs 生成器,把这些容易被误传或者被过度简化的性能结论逐一实测验证清楚。