楼层: 首页/ 软件技术/ Python 基础/ CPython 性能与剖析
15

CPython 性能与剖析

Performance & Profiling

"这段代码慢"是最没信息量的判断。慢在哪一行、慢在算法还是慢在数据结构、是 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,其他线程就能跑。

# 验证一:CPU 密集型,多线程几乎无收益 import threading, time def burn(n): total = 0 for i in range(n): total += i * i return total t = time.perf_counter() burn(50_000_000) print(f"串行: {time.perf_counter() - t:.2f}s") t = time.perf_counter() threads = [threading.Thread(target=burn, args=(50_000_000,)) for _ in range(2)] [t.start() for t in threads] [t.join() for t in threads] print(f"2 线程: {time.perf_counter() - t:.2f}s") # 实测两行数字差不多——2 个线程没有任何加速,这就是 GIL
# 验证二:IO 密集型,多线程能真并行"等" import time from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor def fake_io(seconds): time.sleep(seconds) # sleep 会释放 GIL return seconds # 4 个任务各"等" 1 秒 → 并发只需约 1 秒 with ThreadPoolExecutor(max_workers=4) as pool: list(pool.map(fake_io, [1, 1, 1, 1])) # CPU 密集想真并行:只能多进程,每个进程一把自己的 GIL with ProcessPoolExecutor(max_workers=4) as pool: list(pool.map(lambda n: sum(i * i for i in range(n)), [10**7] * 4))

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 运行中的进程)线上卡住、生产采样、火焰图
# 第一把:cProfile —— 先看函数级排名 $ python -m cProfile -s cumtime slow_script.py | head -25 # 输出列:ncalls 调用次数 / tottime 函数自身耗时 / cumtime 含子调用耗时 # 代码里手动用 import cProfile, pstats profiler = cProfile.Profile() profiler.enable() do_the_work() profiler.disable() pstats.Stats(profiler).sort_stats("cumtime").print_stats(10)
# 第二把:line_profiler —— 定位到行 $ pip install line_profiler from line_profiler import LineProfiler lp = LineProfiler() lp.add_function(do_the_work) lp.runcall(do_the_work) lp.print_stats() # 输出:每一行执行了多少次、每行总耗时与占比 # 常见发现:一个 for 循环里某行占 80% 时间——那行多半在重复查库或重复编译正则
# 第三把:py-spy —— 不用改代码、能对着生产进程采样 $ pip install py-spy $ py-spy top --pid 12345 # 类 top 的实时视图,看哪个函数在烧 CPU $ py-spy record -o profile.svg --pid 12345 --duration 30 $ py-spy record -o profile.svg -- python slow_script.py # 直接跑并生成火焰图 # 打开 profile.svg,横轴宽度 = 占用时间比例(火焰图) # 注意:多数系统需要相应权限;容器里需要 --cap-add=SYS_PTRACE 或用同用户运行

论火焰图怎么读:横轴是时间,纵轴是调用栈

① 横轴不代表时间顺序,只代表占比:某个方块越宽,说明它(及其子调用)占用的采样越多;左右顺序是无关的。② 找最宽的"叶子":一个函数在最底层(没有更深的子调用)却占很宽,它就是热点本体;如果它很宽但下面还有更宽的子函数,问题在更深处。③ 别被"平台期"骗了:顶部聚集成一大片的 read、recv、select 说明时间花在等 IO 上——这时候优化算法毫无意义,该优化的是"并发方式"或"减少往返次数"。④ 结论式的用法:先用 py-spy top 花 30 秒找出最热的函数,再用 line_profiler 钻进去看具体哪一行,改完再测一遍——这才叫"优化",凭感觉改叫"折腾"。

剖析器本身会改变被测对象

剖析有"观察者效应",程度不同:cProfile 开销通常在数倍级别,而且它会让调用次数多的小函数显得更贵(每次调用都要记账),可能把包在循环里的工具函数顶到榜首,而真正贵的大函数被摊薄了。line_profiler 的行级计数也会让每行变慢,不适合测绝对耗时,只看相对占比。py-spy 是外部采样,开销最小、最接近真实,但采样分辨率有限(很短的脚本可能采不到几个点)。方法:先用 py-spy 看真实分布,用 cProfile 看调用关系与次数,用 line_profiler 精确定位,最后以"端到端墙钟时间"作为唯一验收标准。

timeit 的正确用法

是什么:timeit 反复执行一小段代码,取多次运行中的最小值,用来自动避开"单次测量被系统抖动污染"的问题。它回答的是"这两种写法的常数级差异",不是"这个程序要跑多久"。

# 命令行:直接对比两种写法 $ python -m timeit -s "import re; pat = re.compile(r'\d+')" "pat.search('abc123')" $ python -m timeit "''.join(str(i) for i in range(100))" $ python -m timeit "''.join(map(str, range(100)))"
# 代码里用 import timeit print(timeit.timeit( "''.join(map(str, range(100)))", number=100_000, )) # 输出:10 万次执行的总秒数
# 关键:把"准备成本"放在 setup 里,别混进被测代码 import timeit setup = "import random; data = [random.random() for _ in range(1000)]" a = timeit.timeit("sum([x for x in data if x > 0.5])", setup=setup, number=1000) b = timeit.timeit("sum(x for x in data if x > 0.5)", setup=setup, number=1000) print(f"list: {a:.4f}s gen: {b:.4f}s") # 万次量级下差异才稳定可见
做法为什么对或错
用 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 的内存问题有两种:峰值过高(一次加载太大)和持续增长(对象没被回收)。

# tracemalloc:标准库自带,找"哪一行分配了最多内存" import tracemalloc tracemalloc.start() run_pipeline() snapshot = tracemalloc.take_snapshot() for stat in snapshot.statistics("lineno")[:10]: print(f"{stat.size / 1024 / 1024:8.2f} MB {stat.traceback.format()[-1]}") # 对比两个时间点的差异,专治"跑着跑着内存一直涨" snap1 = tracemalloc.take_snapshot() do_work() snap2 = tracemalloc.take_snapshot() for stat in snap2.compare_to(snap1, "lineno")[:5]: print(stat)
# memory_profiler:逐行看内存增量 $ pip install memory_profiler $ python -m memory_profiler pipeline.py from memory_profiler import profile @profile def load_all(): rows = [parse(line) for line in open("big.csv")] # ← MiB 增量最大的一行 return sum(r["amount"] for r in rows) # 输出每行的 Mem usage 与 Increment,一眼看出哪一行吃内存
# objgraph:看"谁引用着这些对象",查泄漏必备 $ pip install objgraph xdot import gc import objgraph objgraph.show_most_common_types(limit=10) # 跑一段业务后再看一次:某类型数量持续暴涨,就是泄漏嫌疑 objgraph.show_growth() # 对比两次快照,显示增长最多的类型 leaked = next(o for o in gc.get_objects() if isinstance(o, MyBigObj)) objgraph.show_backrefs(leaked, max_depth=3) # 画出引用链,找出"谁抓着不放" # 常见元凶:全局缓存字典、模块级列表、闭包捕获、lru_cache 无上限
症状先用什么再看什么
进程被 OOM Killer 杀掉tracemalloc 峰值统计哪一行分配最大,能否改流式处理
内存随时间持续上涨tracemalloc 的 compare_toobjgraph.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) 是数量级的变化 # 反例:1 万条数据 = 上亿次比较 def find_dups_slow(items): dups = [] for i, x in enumerate(items): if x in items[i + 1:]: # 每轮都切片 + 线性查找 dups.append(x) return dups # 正解:用 set 一次遍历,O(n) def find_dups_fast(items): seen, dups = set(), set() for x in items: if x in seen: # set 的 in 是 O(1) dups.add(x) seen.add(x) return list(dups)
# 第二优先:数据结构。选错容器,代价是常数级的量级差 from collections import deque, Counter, defaultdict # list 头部插入是 O(n)(要搬移所有元素),deque 是 O(1) queue = deque() queue.appendleft(item) # 换成 list.insert(0, ...) 就是 O(n) # 频繁计数用 Counter,别手写 if x in d: d[x] += 1 counts = Counter(words) counts.most_common(10) # 分组用 defaultdict(list),别写两层 try/except KeyError groups = defaultdict(list) for k, v in rows: groups[k].append(v)
# 第三优先:局部优化。只在剖析确认是热点后才做 import re # 循环外编译正则,别在循环里 re.search(模式, s) ID_RE = re.compile(r"^[A-Z]{2}\d{6}$") def parse_rows(rows): match = ID_RE.match # 把方法绑到局部变量,省掉属性查找 out = [] for r in rows: if match(r["id"]): out.append(r) return out
层级典型改动收益量级风险
算法O(n²) 改 O(n);嵌套循环改哈希表;排序改堆数量级(10 到 1000 倍)逻辑易错,要有测试覆盖
数据结构list 改 set / dict;list 头部操作改 deque10 倍级低,但要确认语义(如需要有序)
架构批量接口代替逐条请求;加缓存;预处理数量级,省的是 IO中,涉及一致性问题
局部缓存属性访问、局部变量、避免重复构造几个百分点到几十个百分点低,但可读性变差
换实现语言热点函数用 C / Rust 扩展重写10 到 100 倍高,构建与维护成本大(见第 16 章)

论为什么"先测再改"是不可跳过的

① 人的直觉在性能上很不准:程序员普遍低估 IO 和序列化的成本,高估"循环里那点运算"。凭经验猜的热点常常不是真正的瓶颈。② 局部优化容易被更大的问题吃掉:你花半天把一个函数优化快 30%,但它在总耗时里只占 2%,最终端到端提升 0.6%——用户完全感知不到,代码却难读了一截。③ 优化的正确闭环:测量 → 定位热点 → 改一处 → 再测量 → 对比。没有"再测量"这一步,你无法知道改动是否有效,也无法发现"改完之后瓶颈转移到了哪里"。④ 最后一条纪律:优化必须有基准测试守着。没有测试的优化会在下一次重构中悄悄退化,而且没人发现——直到半年后又有人来问"为什么变慢了"。

常见加速手段:四个立刻能用的写法

# ① __slots__:海量小对象时省下每个实例的 __dict__ class Point: __slots__ = ("x", "y") # 不能再动态加属性,但每个实例省一个 dict def __init__(self, x, y): self.x, self.y = x, y # 100 万个 Point:普通类约占 150 MB 以上,加 __slots__ 常能压到一半以下
# ② 生成器替代列表:内存恒定,且不必等全部算完 def iter_errors(path): with open(path) as f: for line in f: if "ERROR" in line: yield line.rstrip() # 5 GB 日志也只占一行内存;下面这句用 sum(1 for ...) 而不是 len([...]) error_count = sum(1 for _ in iter_errors("app.log"))
# ③ functools.lru_cache:纯函数结果缓存,但要注意两点 from functools import lru_cache @lru_cache(maxsize=4096) # 一定要给 maxsize,None 会无限增长 def exchange_rate(currency: str, day: str) -> float: # 参数必须可哈希;函数必须无副作用 return query_rate(currency, day) # 缓存"有副作用或依赖外部状态"的函数是 bug 温床: # 缓存 get_user(id) 之后,用户改了名字你还返回老对象 # 记得在写操作后 exchange_rate.cache_clear()
# ④ 字符串拼接:join 而不是循环里 += parts = [] for row in rows: parts.append(f"{row['id']},{row['amount']}\n") text = "".join(parts) # 一行版本:map + join,迭代在 C 层完成 text = "\n".join(map(str, numbers))
手段适用场景代价与注意
__slots__几十万个同构小对象不能动态加属性,也不能继承带 __dict__ 的基类
生成器大文件、大结果集、流式管道只能遍历一次;调试不如列表直观
lru_cache纯函数、参数空间有限必须设 maxsize;写操作后要 cache_clear
join大量字符串拼接先收集到列表再 join;注意峰值内存
局部变量缓存超热循环里的属性或全局查找可读性下降,只在剖析后做
lru_cache 在长时间运行的服务里就是内存泄漏

@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 章)边界没划好,跨语言调用比计算还贵
# 向量化:把"循环 + 逐元素"换成数组运算 import numpy as np # 反例:100 万个元素的 Python 循环,约 0.3 秒量级 scores = [compute(x) for x in range(1_000_000)] # 正解:向量化,同样的语义,毫秒级 arr = np.arange(1_000_000, dtype=np.float64) result = np.sqrt(arr) * 2 + 1 mask = result > 100 selected = result[mask] # 布尔索引,替代 for 加 if # 注意:一旦写成 for 遍历 ndarray,性能就退回 Python 循环 for v in arr[:100]: # ← 这是反模式 print(v)
# 异步:一个事件循环挂几千个连接 import asyncio import aiohttp async def fetch(session, url, sem): async with sem: # 限流,别一次打爆对方 async with session.get(url) as resp: return url, resp.status async def main(urls): sem = asyncio.Semaphore(50) async with aiohttp.ClientSession() as session: tasks = [fetch(session, u, sem) for u in urls] return await asyncio.gather(*tasks) print(asyncio.run(main([f"https://example.com/{i}" for i in range(1000)]))

论别在同一段代码里同时用三种武器

① asyncio 里调同步阻塞函数是大忌:事件循环是单线程的,一个 time.sleep(1) 或同步 requests.get() 会卡住所有协程。要么换异步库(aiohttp、httpx.AsyncClient、asyncpg),要么用 asyncio.to_thread() 把阻塞调用丢到线程池。② 多进程里再开线程池要小心:每层并行度相乘,容易出现"进程数乘线程数"远大于核数,反而因上下文切换变慢。③ 最优结构通常是分层的:进程数等于 CPU 核数(吃满计算),每个进程内用 asyncio 处理 IO;或者干脆把"IO 密集的采集"和"CPU 密集的计算"拆成两个服务。④ 最后一条最实用的建议:先问"这个计算真的需要做吗"。缓存、增量更新、把工作挪到写入时而不是查询时——省掉计算,永远比加速计算更有效。

换了 numpy 反而更慢的三个场景

① 数据量小:几百个元素的列表转成 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。别为了"用了新技术"而优化。

自测 · CPython 性能与剖析

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 扩展重写这一个函数。顺序不能倒过来——先写扩展是典型的"努力错方向"。