Python 基础体系 · 第 72/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 性能剖析:timeit、cProfile、tracemalloc、采样和证据链
性能问题不是“代码看起来慢”,而是一个需要被测量、解释和复现的问题。
一次请求耗时增加,可能来自:
- Python 字节码执行时间增加;
- 调用了更多函数;
- 某个函数本身变慢;
- 等待网络、磁盘、锁或线程调度;
- 创建了大量临时对象;
- 垃圾回收频率上升;
- 内存增长导致缺页、换页或容器被 OOM;
- C 扩展、数据库驱动或解释器外部的代码占用了时间。
因此,“性能剖析”不是单独运行一个工具,而是建立一条从现象到原因、从原因到修复、从修复到回归判断的证据链:
现象
↓
定义测量对象与指标
↓
基准测试确认差异
↓
确定性剖析定位调用路径
↓
内存追踪定位 Python 分配来源
↓
采样观察真实运行态与等待位置
↓
修改实现
↓
用相同输入和环境复测
↓
判断改进是否真实、稳定、值得
下面的示例以 Python 3.14 为范围。标准库文档当前对应 Python 3.14.7;具体补丁版本、解释器构建方式、操作系统和硬件仍可能影响结果。
一、先区分四个问题:测多快、慢在哪里、分配了什么、运行时在等什么
1. 执行时间不是单一概念
一个操作的墙钟时间可以粗略表示为:
其中:
- :Python 代码自身执行时间;
- :C、C++、Rust 或其他原生扩展执行时间;
- :磁盘、网络、数据库等 I/O 时间;
- :等待锁、条件变量、队列或其他同步对象的时间;
- :操作系统调度、抢占和进程竞争带来的时间。
timeit 主要回答“一个小片段在当前条件下执行多快”;cProfile 主要回答“程序调用了哪些函数、每个函数占用了多少时间”;tracemalloc 主要回答“Python 内存块从哪里分配”;采样剖析则回答“程序运行的大部分时间,调用栈通常停在哪里”。
它们观察的是不同投影,不能互相替代。
例如,一个数据库查询很慢:
timeit可以测一条查询封装函数的端到端时间;cProfile可能显示时间集中在驱动函数;tracemalloc可能只显示结果对象的分配;- 采样工具可能显示线程长期停在 socket 读取或驱动的原生栈中。
如果把“某行分配了很多内存”直接解释成“某行耗时最多”,或者把“某函数累计耗时最高”直接解释成“该函数本身最慢”,都是证据越界。
二、timeit:为小片段建立可比较的时间测量
2.1 timeit 测量的对象
timeit 是一个小型基准测试工具,用于测量短代码片段的执行时间。它既提供 Python API,也提供命令行接口。默认计时器是 time.perf_counter(),也就是墙钟高分辨率计时器。命令行的 -p 选项可以改用 time.process_time() 测量进程 CPU 时间。(docs.python.org)
最简单的例子是:
python -m timeit "'-'.join(map(str, range(100)))"
输出形如:
10000 loops, best of 5: 23.2 usec per loop
这里不能把 23.2 usec 理解为程序只执行了一次。命令行会先选择一个循环次数 N,让一次重复运行的总时间达到足够长的量级,然后重复若干次,报告最快重复中的平均单次耗时。未指定 -n 时,命令行会尝试 1、2、5、10、20、50 ...,直到总耗时至少达到约 0.2 秒。(docs.python.org)
设第 次重复运行的总时间为 ,循环次数为 ,命令行报告的近似值是:
这不是一般意义上的均值,而是最快重复的平均每次耗时。
2.2 为什么默认关闭垃圾回收
timeit 在测量期间默认临时关闭自动垃圾回收。这样做的目的是让多个候选实现更容易比较,避免某一次恰好触发 GC 而产生额外延迟;代价是,如果被测函数的真实性能依赖垃圾回收,那么关闭 GC 就改变了问题本身。官方文档明确建议:如果 GC 是被测行为的一部分,应在 setup 中重新启用它。(docs.python.org)
import gc
import timeit
def create_cycles():
a = []
a.append(a)
return a
without_gc = timeit.Timer(create_cycles).repeat(
repeat=5,
number=10_000,
)
with_gc = timeit.Timer(
create_cycles,
setup="gc.enable()",
).repeat(
repeat=5,
number=10_000,
)
print("GC disabled:", without_gc)
print("GC enabled :", with_gc)
这个实验有两个重要前提:
create_cycles()返回的列表如果没有外部引用,会形成不可由引用计数立即回收的循环;- 当 GC 被禁用时,循环对象可能积累,后续测量的内存状态也会变化。
因此,两个数组不仅反映“函数执行速度”,还反映了“测量期间是否允许周期性垃圾回收”。不能在一个 GC 关闭的结果上推断生产环境中的端到端延迟。
Python 使用引用计数处理大量对象的即时释放,同时由循环 GC 补充处理引用循环。gc.collect() 不带参数时执行一次完整回收;在已经进行 GC 时再次调用 gc.collect() 的行为是未定义的,因此不要在回调或复杂诊断钩子中无条件嵌套调用。(docs.python.org)
2.3 setup、stmt 和输入状态
setup 只执行一次,其耗时不计入被测部分;stmt 会执行多次。这个边界决定了测试是否测到了正确的对象。
错误示例:
import timeit
bad = timeit.timeit(
"data.sort()",
setup="data = [3, 1, 2]",
number=100_000,
)
第一次执行后,data 已经有序,后续执行测量的是“对已经排序的数据再次排序”,而不是“排序一个无序列表”。
正确做法是在每次迭代中生成独立输入,或者复制基准数据:
import timeit
setup = """
data = [3, 1, 2]
sort = list.sort
"""
result = timeit.repeat(
"candidate = data[:]; sort(candidate)",
setup=setup,
number=100_000,
repeat=5,
)
print(result)
print("best seconds:", min(result))
这里的 data[:] 属于测量范围。如果目标是比较“复制加排序”的端到端成本,这样是正确的;如果目标只想比较排序本身,则需要设计另一个实验,并保证每次传入的列表都处于相同的无序状态。测试边界必须和问题边界一致。
2.4 callable 比字符串更适合复杂代码
timeit 接受无参数 callable:
import timeit
def squares_loop():
result = []
for value in range(1_000):
result.append(value * value)
return result
def squares_comp():
return [value * value for value in range(1_000)]
for function in (squares_loop, squares_comp):
samples = timeit.repeat(
function,
repeat=7,
number=1_000,
)
print(function.__name__, min(samples) / 1_000)
callable 形式避免了字符串拼接和 globals 管理,但会引入额外的函数调用开销。官方文档明确指出,使用 callable 时计时开销会略高。(docs.python.org)
当单次操作极短时,应该增加 number,使总测量时间远大于计时器和循环本身的开销。可以用 Timer.autorange() 自动寻找循环次数:
import timeit
timer = timeit.Timer("1 + 1")
number, total = timer.autorange()
print("number =", number)
print("total =", total)
print("per loop =", total / number)
autorange() 会不断尝试不同循环次数,直到一次测量至少达到约 0.2 秒。(docs.python.org)
2.5 repeat() 的结果如何解释
import timeit
samples = timeit.repeat(
"sum(range(1_000))",
repeat=9,
number=10_000,
)
print("all samples:", samples)
print("best:", min(samples))
print("worst:", max(samples))
repeat() 返回的是多个总耗时样本。官方文档不建议把这些样本简单地汇总成均值和标准差,然后把它当作唯一结论;在典型本机微基准中,较大的值通常来自其他进程、调度或系统噪声,最小值更接近机器在低干扰状态下执行该片段的下界。文档同时要求查看完整样本并结合常识判断。(docs.python.org)
这条建议有明确边界:
- 对“单机微基准中候选 A 是否比 B 快”,最小值常常有用;
- 对“线上请求 P99 是否改善”,不能只看最小值;
- 对“包含 I/O、锁竞争、GC 抖动”的行为,尾部延迟本身就是问题,不能被最小值隐藏;
- 对统计回归判断,应使用固定输入、重复运行、分布和效应大小,而不是只比较某两个偶然数字。
可以把每个候选实现的结果写成:
至少保留:
min(R):低干扰下界;median(R):典型重复结果;max(R)或高分位数:噪声和抖动线索;- 每次运行的解释器版本、平台、输入规模和提交版本。
timeit 适合回答“候选实现之间是否存在稳定的局部差异”,不适合单独回答“完整服务为什么变慢”。
三、cProfile:把执行时间映射到调用图
3.1 确定性剖析是什么
cProfile 和 profile 都提供确定性剖析。它们在函数调用、返回等事件发生时记录调用统计,因此可以知道某个函数被调用多少次、在自身内部耗时多少、连同子函数累计耗时多少。cProfile 是 C 扩展,通常开销较低,适合大多数用户;profile 是纯 Python 实现,开销明显更高,主要用于需要扩展剖析器行为的场景。(docs.python.org)
确定性剖析的基本数据可以抽象为调用边:
以及函数节点的两个时间:
- :函数 A 自身执行时间,不包括调用的子函数;
- :函数 A 从进入到返回的累计时间,包括子函数。
例如:
def parse(values):
normalized = [value.strip().lower() for value in values]
return sorted(normalized)
def workload():
values = [" Python ", "Cpython", " profiling "] * 20_000
return parse(values)
if __name__ == "__main__":
workload()
运行:
python -m cProfile -o profile.dat demo.py
python -c "
import pstats
from pstats import SortKey
stats = pstats.Stats('profile.dat')
stats.strip_dirs().sort_stats(SortKey.CUMULATIVE).print_stats(15)
"
-o 把统计结果写入文件,pstats.Stats 再读取并排序。官方文档也提供了 SortKey.CUMULATIVE 和 SortKey.TIME 两种典型视角:前者适合寻找调用链上的总耗时,后者适合寻找函数自身内部耗时较多的代码。(docs.python.org)
常见输出列的含义是:
| 列 | 含义 |
|---|---|
ncalls |
调用次数 |
tottime |
函数自身耗时,不含子调用 |
percall |
tottime / ncalls |
cumtime |
函数及其所有子调用的累计耗时 |
第二个 percall |
cumtime / primitive calls |
filename:lineno(function) |
函数来源位置 |
如果函数递归,ncalls 可能显示成 总调用数/原始调用数,例如 3/1 表示总共进入三次,但只有一次是由外部调用直接触发的。(docs.python.org)
3.2 tottime 和 cumtime 的推导
假设调用关系如下:
request()
└── parse()
├── normalize()
└── sort()
假设一次运行得到:
normalize: tottime=0.20 ms, cumtime=0.20 ms
sort : tottime=0.50 ms, cumtime=0.50 ms
parse : tottime=0.10 ms, cumtime=1.00 ms
request : tottime=0.05 ms, cumtime=1.05 ms
则:
代入就是:
因此,parse 的 cumtime 很高,并不说明 parse 函数体自身很慢;它可能只是调用了真正耗时的下游函数。
反过来,如果一个函数 tottime 很高而 cumtime 与之接近,说明时间主要消耗在函数自身。优化方向可能是减少循环、改变数据结构、避免重复转换,而不是继续分析它的子函数。
3.3 为什么 cProfile 不能代替基准测试
官方文档特别提醒:剖析器用于生成执行剖析,不是用于基准测试。剖析会给 Python 函数增加事件记录开销,但对 C 级函数的影响不同,因此直接比较 Python 实现和 C 实现时,C 代码可能因为受到的剖析干扰较少而显得异常快。(docs.python.org)
错误流程是:
运行 cProfile
→ 看到函数 A 占 60%
→ 修改 A
→ 再运行 cProfile
→ 认为占比下降就是性能提升
正确流程是:
cProfile:定位可能的热点
→ 修改 A
→ 不启用剖析器,用 timeit 或端到端测试测量
→ 在相同 workload 下确认总时间是否下降
剖析结果中的百分比是“在剖析器观测模型下的时间分布”,不是脱离工具后的绝对真实性能。
3.4 一个完整的函数级剖析示例
from __future__ import annotations
import cProfile
import io
import pstats
from pstats import SortKey
def transform(rows: list[str]) -> list[str]:
return sorted(
row.strip().casefold()
for row in rows
if row.strip()
)
def workload() -> list[str]:
rows = [" Python ", " CPython ", " profiling "] * 100_000
return transform(rows)
def main() -> None:
profiler = cProfile.Profile()
try:
profiler.enable()
result = workload()
assert len(result) == 300_000
finally:
profiler.disable()
stream = io.StringIO()
stats = (
pstats.Stats(profiler, stream=stream)
.strip_dirs()
.sort_stats(SortKey.CUMULATIVE)
)
stats.print_stats(15)
print(stream.getvalue())
if __name__ == "__main__":
main()
这里使用 try/finally 保证即使 workload 抛出异常,剖析器也会被关闭。cProfile.Profile 支持显式 enable()、disable(),也支持上下文管理器;显式写法适合只包围目标阶段,避免把导入、配置和日志初始化混入结果。(docs.python.org)
四、tracemalloc:追踪 Python 内存块,而不是读取进程 RSS
4.1 tracemalloc 记录什么
tracemalloc 是 Python 内存分配追踪工具。它可以记录:
- Python 内存块的分配回溯;
- 按文件、行号统计分配总大小、数量和平均大小;
- 两个快照之间的差异;
- 当前追踪内存和峰值追踪内存。
它应尽可能早地启动:可以通过 PYTHONTRACEMALLOC 环境变量、-X tracemalloc 命令行选项,或运行时调用 tracemalloc.start()。默认每个分配只保存最近一帧,也可以保存更多回溯帧。(docs.python.org)
最小示例:
import tracemalloc
tracemalloc.start(10)
before = tracemalloc.take_snapshot()
items = [
{"id": index, "name": f"user-{index}"}
for index in range(100_000)
]
after = tracemalloc.take_snapshot()
for stat in after.compare_to(before, "lineno")[:10]:
print(stat)
compare_to() 输出的是后一个快照相对于前一个快照的变化。例如:
example.py:8: size=14.2 MiB (+14.2 MiB), count=299742 (+299742), average=49 B
它表示该位置当前比快照前多出约 14.2 MiB 的被追踪内存块,而不是说这一行在整个进程生命周期中只分配了 14.2 MiB。
4.2 当前值和峰值是两个不同状态
下面的示例制造一个大的临时列表:
import tracemalloc
tracemalloc.start()
large_sum = sum(list(range(100_000)))
current_1, peak_1 = tracemalloc.get_traced_memory()
tracemalloc.reset_peak()
small_sum = sum(list(range(1_000)))
current_2, peak_2 = tracemalloc.get_traced_memory()
print(f"{current_1=}, {peak_1=}")
print(f"{current_2=}, {peak_2=}")
get_traced_memory() 返回:
(current, peak)
其中:
current:当前仍被追踪的内存块大小;peak:从开始追踪或上次reset_peak()后观察到的峰值。
大列表在 sum() 完成后可能已经释放,所以最终 current 很小,但执行期间的 peak 很大。reset_peak() 只重置峰值记录,不清除已有追踪,因此可以分别观察两个阶段的峰值。(docs.python.org)
这对应两个不同的性能问题:
M_live持续增加,常见于引用仍然存在、缓存无界增长或对象泄漏;M_peak很高但最终下降,常见于临时列表、重复拷贝或批处理粒度过大;- 两者都不高但 RSS 很高,可能是原生扩展、内存分配器保留、内存映射或其他进程级资源。
4.3 快照比较比单次 top 列表更接近泄漏诊断
单次快照只能说明“当前哪些位置持有较多被追踪分配”。要诊断疑似泄漏,应把测试分成阶段:
import gc
import tracemalloc
def handle_batch() -> None:
cache = []
for index in range(20_000):
cache.append({"index": index, "payload": "x" * 100})
tracemalloc.start(10)
gc.collect()
baseline = tracemalloc.take_snapshot()
for _ in range(5):
handle_batch()
gc.collect()
current = tracemalloc.take_snapshot()
for stat in current.compare_to(baseline, "traceback")[:10]:
print(stat)
这里的 gc.collect() 是为了在两个阶段之间尽量减少尚未回收的循环对象干扰,但它不能证明不存在泄漏。它只能改变 GC 状态,使比较更容易解释。
真正的泄漏证据需要满足更强条件:
- 每轮 workload 相同;
- 阶段结束后应该释放的对象确实没有业务引用;
- 多轮运行后,某些分配位置持续正增长;
- 增长与输入规模或轮数具有稳定关系;
- 修复后增长趋势消失;
- 端到端 RSS 或容器内存也符合预期。
4.4 tracemalloc 的边界
tracemalloc 追踪的是 Python 分配器管理的内存块,不是所有进程内存。
因此下面的推断是不成立的:
tracemalloc 只增加 2 MiB
→ 进程一定只增加 2 MiB
进程 RSS 还可能受到以下因素影响:
- NumPy、PyTorch、数据库驱动等原生扩展的分配;
- C 库自己的 allocator;
- 内存映射文件;
- 线程栈;
- Python 内存分配器保留但暂时未归还操作系统的页面;
- 动态加载库和共享库;
- 子进程。
tracemalloc 的准确表述应是:“Python 代码分配来源和被追踪内存变化”。若问题是容器 RSS、系统换页或 OOM,必须把 tracemalloc 与操作系统层面的 RSS、容器指标或原生内存工具结合起来。
五、采样剖析:不记录每次事件,而是周期性观察调用栈
5.1 采样的基本模型
采样剖析器每隔一段时间观察线程当前的调用栈,而不是记录每次函数调用和返回。
假设总运行时间为 ,每隔 采样一次,样本数约为:
如果某段代码实际占用 CPU 时间 ,它被采到的概率近似为:
于是样本数 可以近似看作:
样本比例:
用 估计时间占比。样本越多,估计越稳定;某函数只运行几微秒,即使很重要,也可能在低频采样中完全没有样本。
采样的优点是开销通常较低、适合长时间运行的真实进程,并且不需要在每个函数调用处插入事件记录。缺点是它是统计估计,不保证捕获短暂事件,也不能天然告诉你每次函数调用的精确次数。
5.2 使用 py-spy 观察运行中的进程
py-spy 是一个独立于目标进程的 Python 采样剖析器,提供 top、record 和 dump 子命令,并支持 Python 3.14。它可以对正在运行的进程采样,也可以直接启动目标程序。(github.com)
启动一个持续运行的程序:
py-spy top -- python app.py
对已有进程采样:
sudo py-spy top --pid 12345
生成火焰图:
py-spy record -o profile.svg -- python app.py
这里的火焰图不是“时间轴上的完整调用日志”。横向宽度通常代表样本数量或估计时间占比,纵向表示调用栈深度。一个宽而高的栈表示程序经常处于该调用路径,但不能据此推断每次调用都耗时相同。
采样已有进程还涉及权限边界。py-spy 通过读取目标进程内存获取 Python 线程和栈信息,在 Linux、macOS、Windows 等系统上可能受到 ptrace、容器能力或安全策略限制;容器中常见的错误是 Permission denied,此时需要检查进程可读权限和 SYS_PTRACE 等运行时配置。(github.com)
5.3 采样结果中的“宽”不等于“函数本身慢”
假设采样结果显示:
90% socket.recv
85% database_driver.execute
80% application.handle_request
这表示采样时线程经常停在这些调用路径附近,但可能有多种原因:
- 真正在执行 CPU 密集型原生代码;
- 等待数据库返回;
- 阻塞在 socket;
- 线程持有 GIL 或正在释放 GIL 的扩展中运行;
- 采样工具把等待状态和运行状态按特定规则聚合。
因此,采样必须结合:
- 请求耗时和超时指标;
- CPU 使用率;
- 数据库服务端耗时;
- 网络连接状态;
- 线程状态;
cProfile对同一 workload 的函数级结果。
采样的价值是告诉你“真实运行态的主要栈是什么”,而不是替代所有其他测量。
5.4 Python 3.14 的 sys.monitoring 与采样不是一回事
Python 3.14 提供了 sys.monitoring 命名空间,用于接收执行事件回调,例如函数开始、返回、调用、行事件和 C 函数返回等。它是事件监控接口,不是采样剖析器;调用方需要注册工具 ID、事件和回调。(docs.python.org)
两者的区别是:
sys.monitoring:
每次发生指定事件 → 调用回调
采样:
每隔一段时间 → 读取当前状态
如果需要覆盖率、调试器、细粒度执行事件,事件监控更合适;如果需要低干扰地观察生产进程长时间处于哪些栈,采样通常更合适。启用大量 LINE 或 INSTRUCTION 事件会改变运行开销,因此不能把启用监控后的时间直接当作无监控性能。
六、建立证据链:从一个 CPU 与内存混合问题开始
下面构造一个同时包含 CPU 工作和临时内存峰值的 workload:
from __future__ import annotations
def transform(rows: list[str]) -> list[str]:
return sorted(
row.strip().casefold()
for row in rows
if row.strip()
)
def workload(size: int = 100_000) -> list[str]:
rows = [f" User-{index % 10_000} " for index in range(size)]
return transform(rows)
if __name__ == "__main__":
result = workload()
print(len(result))
第一步:先用端到端计时确认问题存在
python -m timeit -s "from demo import workload" "workload()"
如果结果从旧版本的 80 ms 变成新版本的 110 ms,说明差异存在,但还不知道原因。
此时必须记录:
- Python 版本;
- 操作系统和 CPU;
- workload 参数;
- 输入是否固定;
- 是否在虚拟环境中;
- 依赖版本;
- 是否启用了调试器、覆盖率或 profiler;
- 是墙钟时间还是进程 CPU 时间。
第二步:用 cProfile 判断时间集中在哪里
python -m cProfile -o profile.dat demo.py
查看累计时间:
python -c "
import pstats
from pstats import SortKey
pstats.Stats('profile.dat').strip_dirs().sort_stats(
SortKey.CUMULATIVE
).print_stats(20)
"
可能得到这样的结论:
transform() 占据大部分 cumulative time
sorted() 占据大部分 tottime
这说明优化方向可能是排序、字符串规范化或输入数据结构,而不是盲目修改 workload() 的外层代码。
如果 cProfile 显示大量时间在驱动函数、文件读取或数据库调用上,则局部 Python 循环优化可能无法改善总耗时。
第三步:用 tracemalloc 区分存活内存和峰值内存
import tracemalloc
tracemalloc.start(10)
current_before, peak_before = tracemalloc.get_traced_memory()
result = workload()
current_after, peak_after = tracemalloc.get_traced_memory()
print("before:", current_before, peak_before)
print("after :", current_after, peak_after)
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:10]:
print(stat)
如果 peak_after 很高,但 current_after 在释放 result 后明显下降,问题更像是临时峰值;如果每轮处理后 current 持续增加,则需要继续查找引用链、缓存或全局容器。
第四步:对长时间服务进行采样
py-spy record -o profile.svg --pid 12345
如果火焰图显示线程大部分时间在:
handle_request
└── database.execute
└── socket.recv
那么当前证据更支持“等待外部服务”而不是“Python 排序算法变慢”。
如果显示:
handle_request
└── transform
└── sorted
并且 CPU 利用率接近一个核心饱和,则可以把 cProfile 的函数热点与采样结果相互印证。
第五步:修改后必须重新验证同一个问题
修复不能只看某个函数的局部数字。至少需要重新运行:
- 同一份端到端 workload;
- 同一组
timeit微基准; - 同一套
cProfile路径; - 同一套
tracemalloc阶段比较; - 对服务问题,重新采样真实流量或等价压力。
证据链闭合的形式应当类似:
端到端耗时下降
+ cProfile 中热点路径耗时下降
+ timeit 中局部候选实现稳定变快
+ tracemalloc 中峰值或持续增长改善
+ 采样结果中的主栈比例发生符合预期的变化
如果只满足其中一项,结论仍然可能不完整。例如:
timeit变快,但端到端时间不变:优化的代码不是瓶颈;cProfile占比下降,但总时间不变:可能只是其他瓶颈占比上升;tracemalloc当前值下降,但 RSS 不降:问题可能在原生分配器或内存保留;- 采样火焰图变窄,但请求 P99 变差:平均 CPU 路径改善,尾部等待可能恶化。
七、常见误解与失败表现
误解一:cProfile 排名第一的函数就是应该修改的函数
排名第一可能是:
- 入口函数累计包含了所有下游时间;
- 一个被大量调用的廉价函数;
- 一个等待外部资源的包装函数;
- profiler 注入开销后的结果。
先看 tottime 与 cumtime 的关系,再看调用者和被调用者。累计时间适合找调用树上的路径,自身时间适合找函数体内部热点。
误解二:tracemalloc 的总量就是进程内存
它只覆盖被追踪的 Python 内存块,不等于 RSS,也不等于所有原生扩展分配。需要把“Python 分配来源”和“进程实际驻留内存”分别记录。
误解三:峰值内存高就是内存泄漏
泄漏的关键不是“某一时刻高”,而是“本应释放的对象在重复 workload 后仍然持续增长”。临时列表可能造成高峰值,却不是泄漏;无界缓存可能每轮只增加一点,却最终耗尽内存。
误解四:微基准快 10% 就代表服务快 10%
如果该片段只占完整请求的 2%,即使它缩短 10%,端到端理论上也最多缩短:
这就是局部优化的边界。应先确认局部代码在总耗时中的实际占比,再决定是否值得优化。
误解五:平均值能代表线上性能
平均值适合描述总体资源消耗,但用户体验往往由高分位延迟决定。包含锁、I/O、GC、网络和调度的系统,应同时观察中位数、P95、P99 和最大值。timeit 的最小值适合观察低干扰微基准下界,但不能代替线上延迟分布。
误解六:每次测试都手动调用 gc.collect() 就更准确
手动 GC 可以帮助构造可解释的实验阶段,但也会引入一次完整回收和空闲列表处理成本。它适合诊断,不代表生产运行策略。Python 3.14 的 GC 代际相关行为还有补丁版本差异,依赖具体 generation 编号进行跨版本推理时尤其要谨慎;需要完整回收时,优先明确使用 gc.collect() 的无参数语义。(docs.python.org)
八、工具选择的实际边界
可以用下面的因果关系选择工具:
| 问题 | 首选工具 | 原因 |
|---|---|---|
| 两段短代码谁更快 | timeit |
控制 setup、重复次数和计时器 |
| 完整脚本的函数调用热点 | cProfile |
给出调用次数、self time、cumulative time |
| 哪几行 Python 代码分配了内存 | tracemalloc |
提供分配位置和回溯 |
| 线上进程当前卡在哪里 | 采样剖析器 | 低侵入地观察真实调用栈 |
| 是否等待数据库、网络或锁 | 采样 + 端到端指标 | 需要区分执行与等待 |
| RSS 持续增长但 tracemalloc 不明显 | 操作系统/原生内存工具 | 问题可能不在 Python 分配器 |
| 优化后是否真的回归 | 固定 workload + 重复基准 | 需要可复现的比较证据 |
timeit 是测量工具,不是解释工具;cProfile 是调用路径解释工具,不是低扰动基准工具;tracemalloc 是 Python 分配诊断工具,不是完整进程内存工具;采样是运行态统计观察工具,不是精确调用日志。
真正可靠的性能结论必须回答四个问题:
- 测量对象是什么?
- 观测到的数字由哪个机制产生?
- 该机制是否覆盖了实际故障路径?
- 修改后是否在同一条件下得到相反且稳定的证据?
当这四个问题都能回答时,性能剖析才从“看报告”变成了工程诊断。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 调试:pdb、断点、异常现场、日志和远程诊断
- 下一篇:Python 基准测试:预热、噪声、统计、pyperf 和回归判断
- 延伸:Python 内存与垃圾回收:引用计数、循环 GC、分代与诊断
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论