多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Python性能优化实战:从定位瓶颈到多进程提速

Python性能优化实战:从定位瓶颈到多进程提速 你写Python的时候是不是也遇到过这种情况同样的逻辑别人跑完只要1秒你的脚本要卡20秒数据量一上来程序直接变成PPT。性能问题在Python里特别常见因为这门语言本身好用但不够快而绝大多数人都把时间花在了写功能上很少有人认真想过代码为什么慢。这篇文章不是让你从零学什么底层原理而是把我这些年实际调试、优化过的Python项目经验整理出来告诉你怎么快速定位瓶颈、用什么手段有效提速以及哪些优化手段是白费力气。适合写过一段时间Python、想让代码跑得更快的开发者也适合那些即将面对大数据量、高并发场景的读者。我不打算从头到尾讲一堆理论而是从一个很现实的场景切入假设你已经写出了一个能跑的程序接下来该怎么让它飞起来。核心思路只有一条——先测量、再优化不要靠猜。1. 为什么你的代码慢先搞清楚瓶颈在哪而不是凭感觉改代码我经常看到有人拿到一段慢代码第一反应就是这里用循环太慢了换列表推导式这里函数调用太多把全部代码堆到main里。这些做法不是不对但都属于瞎猫撞死耗子。真正专业的做法是先用工具量化每一段代码的耗时再决定改哪里。1.1 用cProfile找出最耗时的那一行Python自带的cProfile是性能优化的第一板斧它能把每个函数的调用次数、总耗时、单次耗时都统计出来。用法也简单不需要改业务代码python -m cProfile -s cumulative your_script.py输出结果里最重要的两个指标是tottime函数自身耗时不含子调用和cumtime包含子调用的总耗时。排查的时候先看cumtime排在前面的函数再看tottime基本就能锁定热点函数。举个例子我之前优化过一个批量处理Excel数据的脚本跑一次要4分多钟。用cProfile一测发现90%的时间都花在一个叫cell_value的底层函数上而它是被一个嵌套循环里的ws.cell(rowrow_index, columncol_index).value调用触发的。问题一下就清楚了不是Excel解析慢而是我在循环里逐格取值导致的。1.2 用line_profiler定位到具体行号cProfile把范围缩小到函数但如果函数本身有几十行你还得继续往下挖。这时候就轮到line_profiler出场了。它能把每一行代码的执行时间和调用次数列出来精确到行。安装和用法pip install line_profiler# 在需要分析的函数上加装饰器 profile def process_data(data): result [] for item in data: temp transform(item) result.append(temp) return result然后执行kernprof -l -v your_script.py你就会看到类似这样的输出告诉你每一行耗时多少、调用多少次、百分比多少。哪个是瓶颈行一目了然。1.3 测量内存和I/O容易被忽视的隐形成本很多时候CPU不是问题内存或者I/O才是。尤其处理大文件、大量小文件、网络请求这类场景。内存方面推荐memory_profiler用法和line_profiler很像也是装饰器from memory_profiler import profile profile def load_large_file(path): content open(path).read() return contentI/O方面如果程序涉及文件读写和网络请求建议只用一行代码就能测出耗时import time start time.perf_counter() # 你的I/O操作 data read_huge_file() elapsed time.perf_counter() - start我见过太多Python慢的案例最后定位下来其实是网络延迟在等响应、磁盘反复读写而不是Python本身的锅。所以第一步一定先把CPU、内存、I/O三类指标分开看否则后续优化方向可能完全跑偏。2. 数据和容器选对了吗从列表到字典、集合换一种结构速度翻倍很多人写代码的时候很少想这个数据到底用什么结构存随手就是list套dict。但在Python里数据结构的选择对性能的影响经常是数量级上的。2.1 list、dict、set的查找性能差异有多大这是Python性能优化里最经典的一个知识点。list的成员查找是O(n)的遍历dict和set的查找是O(1)的哈希查找。数据量小的时候感觉不出来数据量一旦上千、上万差距就是几十倍甚至上百倍。举个例子我见过有同事写代码判断某个ID是否存在于一个字典列表里用了这种写法users [{id: 1001, name: 张三}, {id: 1002, name: 李四}] target_ids [1001, 1003] for tid in target_ids: for user in users: if user[id] tid: print(user[name])这段代码在两层循环下性能是O(n*m)。如果users有1万条、target_ids有1千条就是1000万次遍历。后来改成用字典把ID映射到用户user_map {user[id]: user for user in users} for tid in target_ids: user user_map.get(tid) if user: print(user[name])一百万次操作瞬间变成一千次哈希查找耗时从秒级降到毫秒级。注意我用的是dict.get()用in先判断再取值的写法虽然也快但get一次搞定更干净。2.2 元组 vs 列表不该只是不可变版本元组和列表的差别不只是可变性。元组在创建和访问上确实比列表稍快因为Python对元组有专门的优化——元组的内存布局更紧凑解释器不需要处理多余的容量扩容。但实际优化时不要刻意把列表改成元组收益太小。真正要注意的是如果你有大量固定结构的小数据比如坐标点、RGB颜色值用元组不仅语义清晰还能让GC压力更小。2.3 用__slots__控制类实例内存如果你需要创建大量实例比如几十万个对象Python默认的类会给每个实例一个__dict__来存属性这个字典非常占内存访问也慢。解决办法是在类里声明__slots__class Point: __slots__ (x, y) def __init__(self, x, y): self.x x self.y y加了__slots__之后实例不再创建__dict__内存占用大幅下降。我实际测试过创建一个包含100万个Point实例的集合时普通类占用大约80MB用了__slots__之后降到35MB左右访问属性约能快10%-15%。2.4 排序和去重巧用key参数别写自定义比较函数排序场景里有个常见误区为了按某个字段排序写一个cmp函数结果每次比较都调用一遍。Python 3里PRython 已经移除了cmp参数正确做法是使用key参数让排序算法只计算一次键值# 慢每次都调用len() words_sorted sorted(words, keylambda w: len(w))其实这个写法已经算快了但更快的是预计算键word_keys [(len(w), w) for w in words] word_keys.sort()去重也一样。保留顺序去重时有人在for循环里用if item not in result这是O(n²)的操作。用set辅助就行seen set() result [x for x in data if not (x in seen or seen.add(x))]这行代码虽然有点技巧性但确实是线性的数据量越大收益越明显。3. 循环和函数调用的隐形开销把每毫秒都榨出来Python性能优化里最值钱的经验都在循环和函数调用上。解释型语言的执行模型决定了这两块有大量隐形开销而这些开销完全可以通过写码习惯来避免。3.1 局部变量比全局变量快得多真的这是Python里一个已经被无数次验证的事实函数内访问局部变量比访问全局变量快20%-30%。原因很简单局部变量存在栈帧里用下标索引就能取到全局变量存在字典里需要通过哈希查找。哈希查找再怎么快也比下标慢。所以如果你在一个循环里反复用某个全局变量不如先把它赋值给局部变量# 慢 for item in big_list: something len(global_list) # 快 l len(global_list) for item in big_list: something l别小看这点优化在循环体里每减少一次全局查找循环100万次就差出几百万次字典哈希操作。3.2 列表推导式 vs for循环不是替换就完事很多教程说列表推导式比for循环快这话方向对但不全对。列表推导式的优势在于它把Python字节码简化了减少了循环里的append方法查找和调用开销。在简单场景下列表推导式确实比等价for循环快10%-30%。但有个反直觉的坑如果循环体里逻辑复杂或者需要条件判断硬塞进列表推导式反而更难读而且性能优势会缩小甚至消失。我的建议是简单变换用推导式复杂逻辑保留for循环可读性优先。# 快且清晰 squares [x * x for x in range(1_000_000)] # 复杂逻辑就用for循环别硬写 result [] for item in data: if item.valid: temp complex_transform(item) result.append(temp)3.3 内建函数优先别自己折腾底层Python之所以慢很多时候是因为新手用Python写了本该用C语言实现的逻辑。Python内建函数和方法大多是用C语言写的执行效率远超手写Python循环。举几个经典例子字符串拼接不要用在循环里拼用.join(parts)累加求和sum(numbers)比你手写for循环快注意sum()对大量浮点数可能引入误差这时候才考虑math.fsum别用map(str, data)再join先列表推导式转成字符串再join也行但map配合join更快。另外一个特别有用的内建模块是itertools。很多看起来需要多循环嵌套的场景用itertools.product、itertools.chain、itertools.groupby能让代码不仅更快还更优雅。3.4 递归的噩梦能迭代就不要递归Python默认递归深度只有约1000层而且递归调用开销极大。碰到需要深度遍历的场景比如树形结构处理我会优先考虑用栈来模拟递归# 递归版慢且有深度限制 def visit(node): for child in node.children: visit(child) # 迭代版快且无深度限制 stack [root] while stack: node stack.pop() stack.extend(node.children)这个改写看着简单实际执行效率能提升好几倍还顺便解决了递归溢出的隐患。4. 从单核到多核multiprocessing的正确打开方式Python有个GIL全局解释器锁导致多线程在CPU密集型任务里基本没救I/O密集型任务用多线程依然有效。所以面向CPU密集计算多进程是绕不开的路。但很多人直接用multiprocessing把代码一包就完事性能反而更差——因为这些任务可能太小进程创建和通信的开销大于并行收益。4.1 什么时候该用多进程什么时候不该用我会这么判断如果任务是CPU密集型的且单次任务耗时超过0.1秒任务数量够多才值得上多进程。如果任务本身只有几毫秒进程池切换的开销会吃掉收益不如串行。举个例子处理1万张图片每张图片要做滤镜效果耗时0.5秒。这种场景用进程池8个进程能轻松跑到接近8倍速度。而如果我只是计算1万个数字的平方就完全没必要。4.2 用concurrent.futures的ProcessPoolExecutor而不是手搓multiprocessing.Pool功能强大但API有点老派。我推荐用concurrent.futures.ProcessPoolExecutor接口更简洁而且支持上下文管理器异常处理也更友好from concurrent.futures import ProcessPoolExecutor def process_one(item): # 你的CPU密集型计算 return expensive_calc(item) with ProcessPoolExecutor(max_workers8) as executor: results list(executor.map(process_one, big_list))这里有个隐藏细节executor.map返回结果的顺序和输入一致方便后续处理。如果不需要保序可以改用executor.submit配合as_completed哪个先完成就先处理哪个在一些场景下能进一步减少阻塞等待。4.3 进程间通信要克制别把大数据塞进队列多进程优化里最容易被忽略的是数据传输。每次把数据传给子进程、拿回结果都需要序列化和反序列化pickle。如果你传给每个worker一个巨大的DataFrame光序列化就可能比计算本身还慢。我踩过的坑是在处理几百MB的文本分析任务时每个worker拿到一整份文本的长度导致半小时跑完的任务活活跑了2小时。解决办法是把大文件切分成多个小块只传文件路径或索引给worker让每个进程自己读自己该处理的那一段。数据共享方面如果是只读数据可以用multiprocessing.Manager或直接让子进程在初始化时加载一次避免重复传输。4.4 什么时候该用asyncio而不是多进程如果你的瓶颈是网络I/O比如大量HTTP请求、数据库连接池等待那么多进程帮不了你真正该用是asyncio。协程能用极低的开销管理成千上万个并发连接。我写爬虫的时候多线程版跑起来CPU占用高还要维护线程池换成aiohttp加asyncio之后代码量少一半速度还快好几倍。import asyncio import aiohttp async def fetch(session, url): async with session.get(url) as resp: return await resp.text() async def main(): async with aiohttp.ClientSession() as session: tasks [fetch(session, url) for url in urls] results await asyncio.gather(*tasks)一句话总结这一章CPU密集用多进程、I/O密集用多线程或asyncio别搞反。5. 终极提速武器NumPy、Numba、Cython与内置库的二三事如果上面的优化手段都用过了代码还慢那就要考虑从优化Python代码升级到用C级别的库替Python干活了。这一章讲的是立竿见影的大杀器。5.1 NumPy向量化是Python性能优化的第一步NumPy由C语言编写底层使用了优化过的BLAS库。用NumPy的数组操作替代纯Python循环很多场景能提升10倍以上。最典型的例子计算两个大数组中每个对应元素的平方和并对结果开根号。用纯Python需要两层循环几百万数据就能卡几秒钟。NumPy写法import numpy as np a np.random.rand(1_000_000) b np.random.rand(1_000_000) result np.sqrt(a**2 b**2)这段代码执行速度接近纯Python的50到100倍。关键理念是向量化不要对元素进行循环而是对整个数组做运算。所有的数学运算都要考虑能不能量化为数组操作。5.2 Numba给Python代码装上JIT引擎Numba是一款JIT编译器它能把jit装饰过的Python函数编译成机器码。对于数值计算密集的函数提速常常是几十倍。安装pip install numba基本用法from numba import jit jit(nopythonTrue) def compute_pi(n): s 0.0 for i in range(n): s 1.0 / (1 i * i) return s注意nopythonTrue这个参数表示Numba必须用纯机器码模式编译。如果函数里用了不支持的类型比如字典、某些对象方法会报错。第一次调用时会编译比较慢之后走缓存的机器码就飞快了。我们团队之前用纯Python模拟蒙特卡洛算π几千万次迭代跑几十秒用Numba后突破到0.3秒这是我实际见过最夸张的提速之一。需要注意Numba对Python版本的兼容性。新版本Python或Numba更新后旧缓存经常出现莫名其妙的编译报错。清理缓存的办法是把函数加个cacheTrue首次编译后会把机器码存到磁盘后续复用jit(nopythonTrue, cacheTrue) def compute_pi(n): ...5.3 Cython当Numba解决不了时再上别一上来就用Cython把Python代码编译成C扩展能精细控制类型性能可逼近原生C。但它有一个很大的代价语法和类型标注的复杂度直线上升调试也困难。除非你需要兼容现有C/C库或者Numba覆盖不了的场景否则我不建议一上来就用Cython。如果要尝试最简单的快速上手是写一个.pyx文件# example.pyx def square_sum(double[:] arr): cdef double s 0.0 cdef int n arr.shape[0] for i in range(n): s arr[i] * arr[i] return s再用setup.py构建即可。对大多数数据分析任务NumPyNumba已经足够Cython更适合做底层库开发。5.4 字符串和处理正则内置re之外的选择处理海量正则匹配时Python的re模块性能一般。如果模式是固定字符串匹配时用str.find或str in比正则快得多。如果需要复杂的正则且单条文本超过几百字节可以考虑regex库它对某些复杂回溯场景的处理更优化。还有一点写正则的时候尽量用非贪婪匹配和字符类来减少回溯别把整个字符串拖进回溯地狱。6. 内存管理和大数据场景生成器、缓存与文件读写的性能细节性能优化不只是CPU内存问题会让程序变慢、卡顿甚至被杀。尤其在处理大数据时知道怎么节省内存比让单个计算变快更关键。6.1 用生成器代替列表别把数据全拉进内存当你只需要逐行处理大文件时不要read()或readlines()把整个文件读进来而是用文件对象逐行迭代。处理超大规模循环时把一次性加载改成生成器能极大地节省内存。# 一次性加载全部内存爆炸 data [process(line) for line in open(bigfile.txt)] # 生成器惰性求值按需处理 data (process(line) for line in open(bigfile.txt))生成器不仅能省内存某些场景还能省时间——因为少了巨量内存分配和GC开销。用yield写自定义生成器也一样遇到大数据处理优先考虑惰性求值。6.2 字符串拼接和格式化join不是唯一答案但也不要用在日志生成、文本模板渲染这类场景字符串拼接的性能差异很明显。最容易踩的坑# 慢在循环里用不断创建新字符串 s for x in items: s str(x) # 快一次性join s .join(str(x) for x in items)原因很简单字符串是不可变对象每次都会创建新字符串并复制旧内容形成O(n²)的耗时。join则一次性分配内存一次性拼接完成。如果你用的是Python 3.6以上版本f-string在大部分场景下比%格式化和str.format()都要略快且代码更可读name Alice age 30 msg f{name} is {age} years old.6.3 缓存不重复计算functools.lru_cache是个好东西同一函数被反复以相同参数调用时可以用functools.lru_cache把结果缓存起来。这在递归、动态规划、重复查询的场景里效果立竿见影。from functools import lru_cache lru_cache(maxsize128) def fib(n): if n 2: return n return fib(n - 1) fib(n - 2)不加缓存算fib(40)可能要几十秒加了缓存瞬间出结果。但要注意lru_cache会把所有参数保存在内存里参数复杂或返回值巨大的时候慎用否则内存先爆。6.4 大文件分块处理与内存映射处理超大文件几个GB级别的文本我建议采用分块读取分块处理模式。设定一个合理的块大小比如8MB到64MBchunk_size 1024 * 1024 * 16 # 16MB with open(huge.log, r, bufferingchunk_size) as f: for chunk in iter(lambda: f.read(chunk_size), ): process(chunk)对于二进制文件mmap内存映射也是个高效选择它让文件像字符串一样访问避免了频繁read的拷贝开销。需要随机访问大文件时mmap的性能提升非常明显。6.5 小心隐式类型转换和频繁的异常两个容易忽略的性能杀手循环里频繁抛异常会非常慢。异常处理的机制原本就是为了处理异常情况不要拿来控制正常流程。能用if判断时就别用try except。隐式类型转换比如数字和字符串混用、list和tuple互搭都会额外产生中间对象。尽量保证同一容器内存放同类型元素。7. 一个真实案例把批量文本处理从25分钟优化到2分钟前面讲了很多知识点下面用一个我实际做过的项目把它们串起来。任务是对一批约50万条中文新闻标题做关键词抽取并统计词频原脚本跑一次25分钟优化后2分钟出头。第一次用cProfile分析热点集中在两个函数分词接口调用占了总耗时60%自定义的停用词过滤循环占了20%分词部分是非调不可的第三方接口没法优化。我把重点放在了后者。原本的过滤逻辑是这样的def filter_stopwords(tokens): result [] for t in tokens: if t not in stopwords: result.append(t) return resultstopwords是一个列表里面有3000多个词。每次in操作都是O(n)的列表遍历50万条文本乘以每条50个词就是巨大的无效计算。改成set之后stopwords_set set(stopwords_loader()) def filter_stopwords(tokens): return [t for t in tokens if t not in stopwords_set]这一步直接把过滤部分从14分钟降到了1分钟多。随后我又把循环里的len()、全局变量引用全部替换为局部变量省了约20秒。接着对整个流程做了生成器改造内存占用从1.2GB降到了400MB。最终优化效果25分钟降到2分多钟主要靠的是查找数据结构换掉和减少循环内函数查找基本上没用什么高级魔法。8. 性能优化工具箱值得常驻的几个模块和工具最后分享一个我固定放在工具集里的清单。你不需要一次全学会但碰到性能问题时知道它们能干什么能节约大量摸索时间。工具用途上手难度cProfile函数级CPU耗时分析低line_profiler行级性能分析低memory_profiler内存占用分析低pyperf微基准测试对比代码版本中snakevizcProfile结果可视化直观低py-spy线上进程采样分析不用改代码中py-spy是我个人非常推荐的一个工具尤其是在排查线上服务卡顿时。它可以直接对运行中的Python进程做采样打印出当前执行栈不需要重启服务不需要埋点。之前服务端有一个定时任务每隔半小时就卡死怎么都复现不了后来用py-spy采样两次直接定位到是一个正则回溯陷阱导致的长时间占用CPU。另外强烈建议在做性能优化之前先git commit一次确保可以随时回退。然后每做一步优化就重跑一次性能基准记录每次变化。这样你才知道哪个改动带来了真实的收益而不是靠我感觉快了。我个人的体会是Python性能优化最有价值的产出并不是那份提速后的代码而是你建立起来的测量能力。接触过一个从来不做profile的项目所有人都在猜瓶颈在哪各种互相甩锅一旦用工具把每段代码的耗时量化出来优化方向就变得非常明确。回到你自己写的代码上下次再遇到卡顿先别急着改写法把cProfile打开把数据量测出来再动手不迟。
返回列表