
小冯那天被叫去优化一个数据导出接口。
这个接口要从数据库读一批记录,做一些格式转换,然后生成 Excel 文件返回给前端。用户反馈说“导出太慢了,点一下要等十几秒”。小冯打开代码,凭直觉觉得是数据库查询慢,于是花了一个下午加了索引、改了查询语句、上了分页。改完一测,还是十几秒。
他又怀疑是 Excel 生成库太慢,换了一个库重写了一遍导出逻辑。还是十几秒。
两天过去了,他改了三四个地方,每次都是“我觉得这里慢”,改完测一下,快了一点点,但离“快”还差得远。他始终不知道那十几秒到底花在了哪里。
同事看他又在对着代码发呆,说:“你别猜了,用 cProfile 跑一下,它会告诉你每个函数花了多少时间。”
小冯半信半疑地加了兩行:
import cProfile
cProfile.run("export_data()")输出了一屏函数调用统计。他一眼看到,排在第一位的不是数据库查询,也不是 Excel 生成,而是一个他从来没注意过的函数——format_phone_number,被调用了八万多次,累计耗时七秒多。这个函数里用了一个正则表达式,每次调用都重新编译一遍。
他之前所有的“我觉得”,全都猜错了。真正的瓶颈,藏在他根本没想过去看的地方。
“我花了两天猜哪里慢,结果工具十秒钟就告诉我了。”
这个教训的核心,是很多人对性能分析工具的理解有偏差:**timeit 和 cProfile 不是“两种测时间的方式”,它们回答的是两个完全不同的问题。**
import timeit
import cProfile
# timeit:测量一小段代码执行多少次需要多久
t = timeit.timeit("sum(range(100))", number=10000)
print(f"sum(range(100)) 执行 10000 次耗时: {t:.4f} 秒")
# cProfile:分析整个程序的函数调用耗时分布
def my_program():
total = 0
for i in range(10000):
total += sum(range(100))
return total
cProfile.run("my_program()")timeit 的输出是一个数字:这段代码跑 N 次总共花了多少秒。它不关心代码内部发生了什么,只给你一个总时间。
cProfile 的输出是一张表:每个函数被调用了多少次,自身花了多少时间,累计花了多少时间。它告诉你时间分布在哪里。
用一个生活场景来理解。
timeit 像你用秒表给一个人测百米跑。你只知道他跑了多少秒,不知道他起跑慢、中途加速不够、还是冲刺没力。
cProfile 像你给这个人做全身运动分析。每一块肌肉在什么阶段发力、发力持续多久、哪块肌肉拖了后腿,全都列出来。
区别一目了然:一个给你总成绩,一个给你分解报告。
timeit 的设计目标是精确测量一小段代码的执行时间。它解决的问题是:“这个操作到底有多快?”
它的核心机制是多次重复、取最优。你写一段代码,timeit 把它跑很多遍,记录每一遍的耗时,最后返回总时间或平均时间。默认重复次数是 100 万次(可以用 number 参数调整),它会自动选择重复次数,让测量结果足够稳定。
import timeit
# 测量列表推导式和 map 的速度差异
t1 = timeit.timeit("[x * 2 for x in range(100)]", number=100000)
t2 = timeit.timeit("list(map(lambda x: x * 2, range(100)))", number=100000)
print(f"列表推导式: {t1:.4f}")
print(f"map: {t2:.4f}")timeit 有几个关键特性:
第一,它会在独立的命名空间里执行代码。 你传给它的字符串,默认看不到你当前脚本里的变量。要用外部变量,得通过 globals 参数传进去:
data = list(range(1000))
# 错误:timeit 看不到 data
t = timeit.timeit("sum(data)", number=1000) # NameError
# 正确:把 data 传进去
t = timeit.timeit("sum(data)", globals={"data": data}, number=1000)第二,它会临时关闭垃圾回收。 这是 timeit 的一个重要设计:测量期间禁用 GC,避免 GC 的随机触发干扰计时。如果你的代码依赖 GC 行为(比如大量创建和销毁对象),timeit 的结果可能和真实场景有偏差。
第三,它适合比较“微小的实现差异”。 两个函数写法不同,哪个更快?两种数据结构,哪个查找更高效?这类问题 timeit 能给出可靠的答案。
但 timeit 有一个根本局限:它只适合测量小段、孤立、可重复的代码。 它不适合分析一个完整程序的性能分布,因为一个真实程序里有 I/O、有分支、有外部依赖,这些在 timeit 的循环里无法真实模拟。
cProfile 的设计目标是分析整个程序的函数调用耗时分布。它解决的问题是:“我的程序时间都花在哪些函数上了?”
它的核心机制是函数调用钩子。cProfile 在程序运行时,记录每一次函数调用的进入和退出,统计每个函数的调用次数、自身耗时、累计耗时。
import cProfile
def slow_function():
total = 0
for i in range(1000000):
total += i
return total
def fast_function():
return sum(range(1000000))
def main():
slow_function()
fast_function()
cProfile.run("main()")输出大概是这样:
4 function calls in 0.085 seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.085 0.085 <string>:1(<module>)
1 0.052 0.052 0.052 0.052 test.py:3(slow_function)
1 0.033 0.033 0.033 0.033 test.py:8(fast_function)
1 0.000 0.000 0.085 0.085 test.py:11(main)每一列的含义:
ncalls:函数被调用了多少次。tottime:函数自身执行的时间,不包含它调用的子函数。percall:tottime 除以 ncalls,平均每次调用的自身耗时。cumtime:函数累计执行的时间,包含它调用的所有子函数。percall(第二列):cumtime 除以 ncalls。cProfile 的关键价值在于:它按函数拆分了时间,让你看到时间的分布,而不是一个总数。 你不需要猜“哪里慢”,它直接告诉你“哪个函数花了多少时间”。
timeit 的粒度是代码片段。你给它一段代码,它告诉你这段代码跑 N 次的总时间。它不关心这段代码里调用了哪些函数,每个函数花了多少时间。
cProfile 的粒度是函数。它记录每一次函数调用的耗时,汇总成一张表。你可以看到每个函数的调用次数、自身耗时、累计耗时。
这个差异决定了它们的适用场景:
timeit。cProfile。timeit 的开销很小。它只是在循环里反复执行代码,记录开始和结束时间。除了禁用 GC 之外,它几乎不改变代码的行为。
cProfile 的开销相对较大。它在每次函数调用时都要记录时间戳、更新统计表。对于函数调用极其频繁的程序,cProfile 会让程序变慢几倍甚至十几倍。它给出的时间数字是“在 profiler 运行下的相对分布”,不是“真实运行时的绝对时间”。
所以 cProfile 的正确用法是看相对比例,不看绝对数值。它告诉你“slow_function 占了总时间的 60%”,这个比例是可靠的;但它说“slow_function 花了 0.052 秒”,这个绝对值可能比真实运行时长。
timeit 的数字更接近真实耗时,因为它只在循环层面计时,不侵入每次函数调用。
timeit 通常用在开发阶段,比较两种实现的性能差异。你写两个版本的函数,用 timeit 各跑一遍,选快的那个。
# 比较两种字符串拼接方式
t1 = timeit.timeit("'-'.join(str(i) for i in range(100))", number=10000)
t2 = timeit.timeit(
"s = ''\nfor i in range(100):\n s += str(i) + '-'",
number=10000
)
print(f"join: {t1:.4f}, 循环拼接: {t2:.4f}")cProfile 通常用在排查阶段,定位一个已知慢的程序到底慢在哪里。你有一个跑得慢的脚本或接口,用 cProfile 跑一遍,看统计表,找到耗时最多的函数,然后针对性地优化。
# 分析一个完整脚本
cProfile.run("main()", sort="cumtime")sort="cumtime" 让结果按累计耗时排序,最慢的函数排在最前面。
# 错误用法
t = timeit.timeit("main()", globals=globals(), number=1)main() 可能包含数据库查询、文件读写、网络请求。timeit 跑一次,测出来的时间是“这一次运行的总时间”,包含所有 I/O 等待。这个数字没有太大意义,因为 I/O 时间波动很大,而且你无法知道时间花在了哪个环节。
timeit 适合测纯计算、无 I/O、可重复的代码。整程序的性能分析,应该用 cProfile。
# 错误用法
cProfile.run("sum(range(100))")sum(range(100)) 执行时间太短,cProfile 的开销(函数调用钩子)比代码本身还大。测出来的数字主要是 profiler 的开销,不是代码的真实耗时。
微小操作的性能比较,应该用 timeit。cProfile 适合分析有一定规模的程序,函数调用次数足够多,统计才有意义。
def outer():
inner() # inner 花了 5 秒
def inner():
time.sleep(5)cProfile 里 outer 的 cumtime 是 5 秒多,tottime 接近 0。因为 outer 自身几乎没花时间,时间都花在它调用的 inner 上。
看 cProfile 结果时,先看 tottime 找到自身耗时最多的函数,再看 cumtime 理解调用链。只看 cumtime 会被“外层函数”误导,以为外层函数慢,实际上是它调用的某个子函数慢。
cProfile 记录的是墙钟时间,包含 I/O 等待。如果一个函数在等数据库响应,cProfile 会把等待时间算在这个函数头上。
这本身不是错,但容易误导:你看到某个函数 tottime 很高,以为它计算量大,实际上它只是在等网络。要区分“计算时间”和“等待时间”,需要结合代码逻辑判断,或者用更细粒度的工具(比如在函数内部手动打点)。
t = timeit.timeit(
"sorted(data)",
setup="data = list(range(1000))",
number=10000
)setup 里的代码在每次重复之前执行。如果 setup 本身很重,它会计入总时间,污染测量结果。
timeit 的 setup 适合放轻量的初始化(比如创建一个小列表)。如果初始化很重,应该用 globals 把已经创建好的对象传进去,而不是在 setup 里重复创建。
timeit 默认禁用垃圾回收。如果你的代码大量创建临时对象,真实运行时 GC 会介入,耗时可能比 timeit 测出来的高。
要模拟真实 GC 行为,可以手动开启:
import gc
gc.enable()
t = timeit.timeit("...", number=1000)但大多数情况下,timeit 的默认行为(禁用 GC)是合理的,因为它测的是“代码本身的执行效率”,排除 GC 的随机干扰。比较两种实现时,两者都在无 GC 环境下跑,相对差异仍然可靠。
cProfile 的粒度是函数。如果一个函数内部有多个步骤,你想知道哪一行最慢,cProfile 帮不了你。
line_profiler 是另一个工具,它按行统计执行时间:
# pip install line_profiler
@profile
def slow_function():
a = [i for i in range(100000)] # 这一行花了多久
b = sum(a) # 这一行花了多久
c = [x * 2 for x in b] # 这一行花了多久
return c用 kernprof -l -v script.py 运行,它会输出每一行的执行次数和耗时。
line_profiler 适合“已经用 cProfile 定位到某个函数慢,想知道函数内部哪一行慢”的场景。它是 cProfile 的补充,不是替代。
判断标准很清晰:
比较两种小段代码的实现效率:用 timeit。比如列表推导式 vs map、join vs +、字典 vs 列表查找。
定位一个完整程序的性能瓶颈:用 cProfile。比如接口慢、脚本慢、批处理慢,先看时间分布在哪里。
想知道“这个操作有多快”:用 timeit。
想知道“时间花在哪些函数上”:用 cProfile。
已经定位到慢函数,想知道函数内部哪一行慢:用 line_profiler。
生产环境监控:两者都不适合。生产环境应该用 APM 工具、日志打点、或者 time.perf_counter() 在关键路径上手动埋点。
小冯后来的流程是这样的:
第一步,用 cProfile 定位。 跑一遍完整流程,按 tottime 排序,找到自身耗时最多的函数。
第二步,看代码,理解为什么慢。 是算法复杂度高?是重复计算?是 I/O 等待?是锁竞争?
第三步,改代码。
第四步,用 timeit 验证改动。 把改前和改后的关键函数各跑一遍,确认确实变快了。
第五步,再跑 cProfile,确认瓶颈转移了。 优化完一个瓶颈,原来的第二名可能变成新的第一名,继续下一轮。
这个流程的核心是:先测量,再优化,不要猜。 人的直觉在性能问题上非常不可靠。你觉得慢的地方,往往不是真正的瓶颈;你从没注意过的函数,可能是耗时大户。
小冯最终的优化是这样的:
他用 cProfile 发现 format_phone_number 占了七秒多。这个函数每次调用都执行 re.compile(pattern),而它被调用了八万多次。把正则编译提到模块级别,只编译一次,七秒变成了不到一秒。
# 改前:每次调用都编译
def format_phone_number(phone):
pattern = re.compile(r"(\d{3})(\d{4})(\d{4})")
return pattern.sub(r"\1-\2-\3", phone)
# 改后:模块级别编译一次
PHONE_PATTERN = re.compile(r"(\d{3})(\d{4})(\d{4})")
def format_phone_number(phone):
return PHONE_PATTERN.sub(r"\1-\2-\3", phone)总耗时从十几秒降到了三秒多。他之前猜的数据库、Excel 库,都不是主要瓶颈。
他后来在团队文档里写了一条:“性能优化不要猜。先用 cProfile 看时间分布,找到 tottime 最高的函数,再针对性优化。比较两种小实现用 timeit。cProfile 看相对比例,不看绝对值。cumtime 包含子函数,tottime 是自身耗时。优化完再跑一遍,确认瓶颈转移了。”
timeit 和 cProfile 的区别,本质上是“微基准测试”和“性能分析”的区别。一个回答“这个操作多快”,一个回答“时间花在哪里”。用错了工具,就会像小冯一样,花两天猜一个工具十秒钟就能告诉你答案的问题。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。