实测过不少号称能优化 Python 性能的方案,也踩过不少坑。很多人一觉得 Python 慢,第一反应就是"换 C++""上多线程",但往往连代码慢在哪儿都没搞清楚。真正的性能优化,第一步不是改代码,而是先测量、找瓶颈。脱离瓶颈谈优化,都是空谈。
这篇文章我会从定位瓶颈讲起,一路聊到数据结构选型、代码细节、并行缓存,再到 NumPy、Numba 这些进阶武器,每一节都附上可以直接抄的代码和实测经验。不管你是刚入门的新手,还是写了不少业务代码的老手,照着下面的思路去排查和改造,都能让代码跑得快一截。我会尽量少讲虚的,多给能落地的操作。
1. 性能问题定位:先搞清楚慢在哪里
1.1 最简单的计时方式:time 和 timeit
很多人优化代码是凭感觉,觉得某段逻辑"看起来复杂",就直奔那里去改。结果经常是改了半天,整体时间一点没降。所以我每次拿到一个慢项目,第一件事就是先给各个模块加计时器。
最简单的方式是直接包一层 time:
import time start = time.perf_counter() # 待测代码 result = sum(range(1000000)) end = time.perf_counter() print(f"耗时: {end - start:.4f} 秒")注意这里推荐perf_counter而不是time.time,因为perf_counter专门用于测量短时间间隔,精度高,不受系统时间调整影响。如果是测试一小段代码的多次运行,用timeit模块更省事:
import timeit # 在命令行里可以直接这样测 # python -m timeit "sum(range(1000000))" # 在代码里这样用 t = timeit.timeit("sum(range(1000000))", number=100) print(t)timeit会自动选择重复次数,减少随机误差,适合对比不同写法的时候用。比如你想知道列表推导式快还是生成器快,同一个环境里跑一遍timeit,结论一目了然。
1.2 cProfile:找到真正的瓶颈函数
粗粒度的计时能看到哪个模块慢,但要精确定位到是哪个函数吃掉了时间,就得用性能剖析器。Python 自带的cProfile就是最趁手的工具,不需要安装任何额外依赖。
在代码里直接调用:
import cProfile import pstats profiler = cProfile.Profile() profiler.enable() # 这里是你要剖析的代码 data = [i * 2 for i in range(1000000)] profiler.disable() stats = pstats.Stats(profiler) stats.sort_stats("cumulative").print_stats(20)也可以在命令行直接剖析整个脚本:
python -m cProfile -s cumulative my_script.py跑完会输出一张表,里面有几个关键列:tottime是函数自身执行时间(不包含子函数调用),cumtime是包含子调用的累计时间。我一般先看tottime高的函数,因为那才是真正在干活的地方;cumtime高但tottime低的,说明它只是调用了其他慢函数。
看到这里你可能想问:输出好多行,哪些才值得关注?我的经验是,优先看排名前五的函数。很多脚本的耗时往往集中在某一个循环或某一次 IO 上,找出那个"大头",优化的目标就明确了。
1.3 line_profiler:把瓶颈定位到行
cProfile能告诉你哪个函数慢,但还不够,因为一个函数里可能有几十行代码。想在函数内部找出具体是哪一行最耗时,我一般用line_profiler,它需要单独安装:
pip install line_profiler用法很特别,只需要在要剖析的函数上加上@profile装饰器:
@profile def process_data(items): result = [] for item in items: temp = item * 2 + 1 result.append(temp) return result process_data(range(10000))然后运行:
kernprof -l -v my_script.py输出的结果会按行显示每行代码执行的次数和耗时。看到Time Per Hit特别高的那一行,就是你要优化的重点。我用这个工具排查过一个数据处理脚本,发现慢的根本不是循环本身,而是循环内部反复调用了一个属性访问链,把那行改成局部变量后,整个循环提速了三倍。
1.4 一个真实的定位案例
举个实际的例子。我之前优化过一个日志解析脚本,处理 500MB 的日志文件要跑四十多分钟。拿到手之后先用cProfile剖析,结果发现一个叫parse_line的函数占了 67% 的累计时间。继续用line_profiler钻进去,发现里面有一个正则表达式匹配操作,执行了上百万次,每次都要编译一次正则。
解决方案很简单:把正则表达式从函数内部提到模块级别,只编译一次。优化后整个脚本跑完只需要七分钟。这个例子说明,慢的往往不是你想象中的算法,而是一些不起眼的重复开销。所以一定养成先剖析再动手的习惯,不要拿优化当猜谜。
2. 算法与数据结构选型:从源头减负
2.1 列表、字典与集合的取舍
定位到瓶颈之后,下一步是看数据结构是不是选对了。这是性价比极高的一步,因为数据结构的“时间复杂度”决定了数据规模变大时程序的性能走向。
直观对比几个常用容器操作的时间复杂度:
| 数据结构 | 查找元素 | 插入/删除 | 适用场景 |
|---|---|---|---|
| 列表 list | O(n) | 尾部 O(1),中间 O(n) | 有序集合、按下标访问 |
| 字典 dict | O(1) | O(1) | 键值映射、快速查询 |
| 集合 set | O(1) | O(1) | 去重、成员判断 |
| 元组 tuple | O(n) | 不可变 | 固定结构数据 |
最经典的坑是用列表来做成员判断。比如你要检查一个元素是否在某个大集合里,用item in list,数据量到十万级就开始明显变慢;换成item in set之后,几乎瞬间返回。我见过太多人写着if x in list_a这样的代码,数据量一大就拖垮整个程序。
还有一类场景是频繁从列表头部删除元素,用pop(0)会触发所有元素移位,时间复杂度 O(n)。这种情况更适合用collections.deque,它的两端操作都是 O(1),性能差距在数据量大时是数量级的。
2.2 生成器与惰性求值
你是否遇到过这样的问题:一次性把所有数据加载到内存,结果内存爆了,程序直接崩溃,或者卡到无法响应。处理很大规模的数据时,这种“一次性加载”的做法非常危险,而生成器就是解决这类问题的利器。
生成器不一次性产生所有值,而是每次只产生一个,用yield关键字实现惰性求值。举个例子:
def read_large_file(file_path): with open(file_path) as f: for line in f: yield line.strip() # 逐行处理,内存中永远只有一行 for line in read_large_file("huge.log"): process(line)对比一下一次性readlines()的做法,整个文件的内容都会塞进内存,文件越大风险越大。使用生成器后,内存占用几乎恒定。
另外,把列表推导式的中括号换成圆括号,得到的不是生成器表达式,但这也是一种惰性迭代的思路。比如sum(x * x for x in range(1000000))不会生成 100 万个元素的列表,而是边算边求和,省内存还更快。
2.3 用缓存避免重复计算
有些函数的输入相同,输出也相同,但每次都要重新计算。遇到这种情况,缓存是最直接的加速手段。Python 的functools.lru_cache用起来非常简单:
from functools import lru_cache @lru_cache(maxsize=128) def fib(n): if n < 2: return n return fib(n - 1) + fib(n - 2)没加缓存之前,这个递归版本的斐波那契函数算到第 30 项就已经明显卡顿,因为重复计算了无数次相同的子问题;加上lru_cache之后,算第 100 项也毫无压力。
我的经验是,在递归、数据清洗、数据库查询结果复用这类场景里,加一个缓存装饰器往往收益特别大。但要注意maxsize别设太大,否则缓存本身会占用大量内存。另外,如果函数的参数是 list 这类不可哈希类型,lru_cache会直接报错。这时可以先把 list 转成 tuple 作为参数,或者自己写个简单字典做缓存。
2.4 复杂度优化实例
选对算法比什么都重要。举个直观的例子:查找两个列表的公共元素。
新手写法通常是双重循环:
common = [] for a in list_a: for b in list_b: if a == b: common.append(a)这个写法的时间复杂度是 O(n*m),两个列表各一万个元素时,就要执行一亿次比较,跑起来能让你怀疑电脑是不是坏了。用集合求交集,一行代码搞定:
common = list(set(list_a) & set(list_b))像这样把复杂度从 O(n*m) 降到 O(n+m),在数据规模稍微上来一点之后,就是几秒和几十秒的差距。所以优化之前,先想想自己的算法是不是有更优解,这比抠每一行的执行速度更有效。
3. 代码细节加速:每一行都快一点
3.1 局部变量与属性访问的差别
很多 Python 性能优化的技巧看起来不起眼,但累加起来效果很可观。最典型的例子就是属性访问。每写一次self.name或者module.func(),Python 都要经历一次属性查找的过程,它需要解析隐藏的上下文关系,比访问一个局部变量慢得多。
如果在一个大循环里反复用到某个属性,最佳实践是先把属性值赋给局部变量。对比一下:
import math def slow(): result = 0.0 for i in range(1000000): result += math.sqrt(i) + math.sin(i) def fast(): sqrt = math.sqrt sin = math.sin result = 0.0 for i in range(1000000): result += sqrt(i) + sin(i)第二种写法每次循环少两次属性查找,在我的机器上测试,大约能快 10% 到 15%。别小看这个数字,如果是高频调用的核心逻辑,收益会被放大。
3.2 字符串拼接的正确姿势
字符串拼接也是一处容易踩坑的地方。很多人习惯用+拼接,但在循环里这样做会反复创建中间字符串对象,开销极大。
s = "" for c in data_list: s += c更高效的做法是先把片段收集到列表里,最后一次性join:
parts = [] for c in data_list: parts.append(c) s = "".join(parts)如果你用的是 Python 3.6 以上版本,f-string 是格式化字符串的首选,它比%格式化和format()方法都更快,而且可读性更好:
name = "Python" version = 3.12 info = f"{name} {version}"所以在处理字符串时,记住一个原则:尽量避免在循环里用+拼接,能用join就用join,能用 f-string 就用 f-string。
3.3 列表推导式与 map 的取舍
列表推导式不仅是语法糖,它本身也有性能优势。与普通的 for 循环加 append 相比,列表推导式的字节码执行更高效,运行速度通常能快 20% 到 30%。
# 常规写法 result = [] for i in range(1000000): result.append(i * 2) # 列表推导式 result = [i * 2 for i in range(1000000)]同样的逻辑,map配合 lambda 也能实现类似效果,但实测下来,大多数场景列表推导式的速度优于map + lambda,因为 lambda 本身有一次函数调用开销。只有当映射函数已经是内置函数时,map才占优势,比如map(str, list_a)。
3.4 循环内的常见坏习惯
搞优化这么久,我发现循环里的坏习惯是最多的,而且都是能直接改掉的:
- 循环里重复计算不变量。例如
for i in range(len(data)):里反复访问len(data),其实数据长度不会变,应该提到循环外。 - 循环里做没必要的外部调用。比如每次输出日志都调用
time.time()获取时间戳,如果精度要求不高,完全可以在循环外取一次。 - 嵌套循环里频繁创建临时对象。比如在双层循环中不断构造新的列表、字典,浪费内存又耗时,能复用就复用。
- 用
range(len(data))按下标访问元素。如果能直接遍历元素,就用for item in data,这样少了一次下标查找。
这些坏习惯单个看都不严重,但放在百万次循环里,就会被放大成几秒甚至几十秒的差量。
4. 并行与缓存:把资源用起来
4.1 关键认知:GIL 不是洪水猛兽
说到 Python 的性能,总是避不开 GIL(全局解释器锁)这个话题。很多人的理解是"Python 有 GIL,所以多线程没用",这个说法过于绝对了。GIL 确实让同一进程内的多个线程无法并行执行 Python 字节码,但对于 IO 密集型任务,多线程依然能大幅提速,因为线程在等待网络请求、磁盘读写时,会释放 GIL 让其他线程运行。
所以选择并发方案时,先判断任务是 CPU 密集还是 IO 密集:
| 任务类型 | 推荐方案 | 原因 |
|---|---|---|
| IO 密集型(网络请求、文件读写) | 多线程 / asyncio | 等待 IO 时切换,不占 CPU |
| CPU 密集型(计算、循环) | 多进程 / NumPy / Numba | 绕过 GIL,真正并行 |
| 混合型 | 进程池 + 协程 | 各取所长,按场景分配 |
4.2 多进程的正确打开方式
CPU 密集型任务想利用多核,就用multiprocessing或concurrent.futures。以concurrent.futures.ProcessPoolExecutor为例:
from concurrent.futures import ProcessPoolExecutor def square(n): return n * n if __name__ == "__main__": numbers = range(1, 10000) with ProcessPoolExecutor(max_workers=4) as executor: results = list(executor.map(square, numbers))这里有个容易踩的坑:在 Windows 上,多进程代码必须放在if __name__ == "__main__":块里,否则会递归启动子进程报错。另外,进程间传数据会经过序列化,如果传递的数据量太大,这个开销可能抵消并行带来的收益。所以,尽量传小参数,把大数据的处理留在子进程内部完成。
4.3 asyncio:IO 密集场景的轻量选择
多线程虽然能在 IO 等待时切换,但线程本身有开销。如果你的任务是高并发网络请求,asyncio是更好的选择。它用事件循环在一个线程内管理大量任务,开销远小于线程。
import asyncio import aiohttp async def fetch(session, url): async with session.get(url) as resp: return await resp.text() async def main(): urls = ["https://example.com" for _ in range(100)] async with aiohttp.ClientSession() as session: tasks = [fetch(session, url) for url in urls] results = await asyncio.gather(*tasks) print(len(results)) asyncio.run(main())需要注意的是,asyncio只对 IO 密集型有效,如果协程里有大量 CPU 计算,照样会阻塞事件循环。配合asyncio.to_thread可以把阻塞型调用丢到线程池里,绕开阻塞问题。
4.4 缓存策略:不止装饰器
lru_cache适合函数级的小缓存,但如果缓存的数据来自数据库或远程接口,我更推荐自己写一层带过期时间的缓存。比如用一个字典存数据,并记录存入时间:
import time cache = {} expire_seconds = 60 def get_data(key): now = time.time() if key in cache: value, timestamp = cache[key] if now - timestamp < expire_seconds: return value # 缓存失效,重新获取(这里替换成实际的数据获取逻辑) value = fetch_from_db(key) cache[key] = (value, now) return value这样实现忽略了一个线程安全问题,如果多线程同时访问,底层字典可能因为并发修改而出错。稳妥的做法是加锁保护,或者直接用第三方库(比如cachetools)提供的TTLCache,它原生支持过期时间,省心很多。
内存也是一种资源,你的内存缓存不可能无限增长。给缓存设置上限,配合过期策略,才能在高并发场景下稳定运行。
5. 第三方高性能模块:站在巨人肩膀上
5.1 NumPy:向量化运算的降维打击
如果你的性能瓶颈是大量数值计算,用纯 Python 循环硬算是最傻的做法。换成 NumPy 的向量化运算,速度提升不是一倍两倍,经常是几十倍甚至上百倍。
举个例子,计算两个长度为 100 万的数组逐元素相加:
import numpy as np import random a = [random.random() for _ in range(1000000)] b = [random.random() for _ in range(1000000)] # 纯 Python 循环 c_py = [x + y for x, y in zip(a, b)] # NumPy 向量化 a_np = np.array(a) b_np = np.array(b) c_np = a_np + b_np在普通机器上,纯 Python 版本可能需要零点几秒,而 NumPy 版本几乎瞬间完成。原理在于 NumPy 的底层用 C 实现,操作的是连续内存块,没有 Python 对象的层层包裹。所以只要能用数组批量操作,就尽量别写 Python 循环。
5.2 Numba:给函数加 JIT 加速
如果不想费劲把逻辑改成 NumPy 风格,numba是个很好用的补充。它通过 JIT(即时编译)技术把 Python 函数编译成机器码,特别适合数值密集的算法。
安装:
pip install numba使用方式很朴素,只需要加一个装饰器:
from numba import jit @jit(nopython=True) def compute_pi(n): pi = 0.0 for i in range(n): pi += (-1) ** i / (2 * i + 1) return pi * 4 print(compute_pi(1000000))注意nopython=True是关键,它要求函数内只用 NumPy 和 Python 基础类型,这样才会走快速路径。我第一次跑numba函数时,第一遍调用会慢,因为要编译,但后面再调用就快了。如果还觉得慢,可以加上cache=True让它把编译结果缓存到磁盘,下次进程启动直接加载编译结果。
5.3 用 Cython 编译瓶颈模块
如果 numba 满足不了你,或者函数的类型太复杂,可以考虑 Cython。它允许你用接近 Python 的语法写代码,然后编译成 C 扩展,性能接近手写 C。
简单示例,保存为speed.pyx:
def f(double x): return x**2 - x然后通过setup.py编译。这个过程有点繁琐,但如果你维护的项目里某个函数确实是核心瓶颈,值得花这个时间。我通常只在 numba 和向量化都解决不了问题时才考虑 Cython,日常工作里前两者已经能覆盖大部分场景。
5.4 PyPy:换个解释器试试
有时候我们不想改代码,只是想换个环境跑,那可以试试 PyPy。PyPy 是 Python 的另一种实现,内置了 JIT 编译器,很多纯 Python 项目直接切换解释器就能获得可观的性能提升。
前提是你的项目没有重度依赖某些 CPython 特有的 C 扩展模块,比如某些科学计算库在 PyPy 下支持不完整。我的建议是,遇到纯 Python 的 CPU 密集任务,跑不快时顺手用 PyPy 试一把。不用改代码,直接把脚本交给pypy3执行,如果依赖没有兼容问题,性能提升是白捡的。
5.5 从设计层面削减性能压力
除了代码层面的优化,架构设计对性能的影响更大。比如同样的功能,同步逐条处理大量数据请求,和用批量接口一次处理,性能差距是数量级的。我优化过好几个服务,都是把逐条调用改成批处理,配合合理的缓存,问题立刻缓解。
另一个思路是延迟计算。把耗时操作挪到数据真正被需要的时候再去算,甚至用后台任务提前异步计算好。这一招看起来不直接"优化代码",但对系统整体响应速度提升非常明显。
6. 常见问题与排查技巧实录
6.1 代码变快了,但结果不对怎么办
这是优化中我最常遇到的问题,尤其是引入缓存、并行和惰性求值之后。"优化"改坏了正确性,比优化前更糟糕。这时候第一件事是自查有没有动了共享可变对象。多线程下,多个线程同时修改同一个列表或字典,就可能出现奇怪的脏数据。排查的方法很简单:先把并发相关代码禁用,用单线程重新跑一遍,对比结果。如果单线程结果正确,再逐步加回并发环节,缩小排查范围。
6.2 内存占用不降反升
有一次我用了lru_cache,结果函数被几十万个不同的参数疯狂调用,缓存把内存吃满了,程序反而比以前更慢。解决办法是把maxsize调小,或者改用其他缓存策略。另外一个高频内存问题出在不经意地构造大列表。比如list(range(100000000)),这行代码会把上亿个整数放进内存,直接撑爆。换成迭代器或者拆成小块处理,内存占用会立刻降下来。
6.3 学会克制:不做无意义的过度优化
优化是有收益递减规律的。很多时候,某个不重要的函数被优化了几毫秒,对整体毫无影响。做性能优化前先问自己三个问题:这个函数被调用的次数多吗?单次耗时的占比大吗?优化后代码的复杂度会增加多少?如果是冷路径、低频调用,那就不值得花力气。我见过很多人把工具函数改得花里胡哨,为省几微秒牺牲了可读性,回头维护的人一脸崩溃。性能要优化,但要在大数据量变慢的时候集中优化真正的热点。
从我个人的经验来看,性能优化最关键的步骤永远是前三步:测量、分析、定位。工具永远放在那里,但真正的提升来自对瓶颈的清醒认识。每次拿到慢代码,我都会告诉自己,真正有价值的不是技巧本身,而是知道在什么时候用哪个技巧。你手里掌握的优化手段会越来越多,但这份"先测量再动手"的定力不要丢掉。