
每次有人聊起 Python 运行速度话题往往走向“怎么给 Python 提速”。我没法用一句话回答因为同样是慢不同场景的瓶颈可能完全不一样有的是 CPU 密集计算卡在循环里有的是 I/O 等网络响应有的是单纯内存分配太频繁。用错工具不仅白忙甚至可能拖垮代码的可维护性。这篇内容我把这些年真正落地用过的提速方案整理出来区分场景、给出可复跑的代码和避坑点希望能帮你在自己的项目里少走几条弯路。先说结论Python 慢不是耻辱但也不是一个只能用“继续用 C 重写”才能解决的问题。在实际项目里换解释器、JIT 编译、静态类型编译、事件循环替换、多进程并行都是成熟路子。你只需要先定位热点再决定选哪条路。1. 提升 Python 运行速度前先搞清楚它到底慢在哪1.1 动态类型和解释执行是两大根源Python 的默认实现是 CPython也就是社区通常说的官方解释器。CPython 会把.py文件先编译成字节码再由虚拟机一条条解释执行。这个过程本身没有复杂的运行时优化所以和编译型语言相比纯 Python 循环的吞吐量天然吃亏。更关键的约束是全局解释器锁也就是常说的 GIL。它保证同一进程内同一时刻只能有一个线程执行 Python 字节码。多线程看起来在并行处理但对 CPU 密集型任务线程切换反而增加开销。很多开发者在 web 服务里用多线程跑计算任务发现没有变快多半就是这个原因。动态类型也影响执行效率。比如一个a b解释器需要先检查 a 和 b 的类型决定走整数加法、字符串拼接还是列表合并然后才执行。每次循环都在重复这个流程不像静态类型语言那样编译阶段已经知道数据类型直接生成对应的机器指令。1.2 提速的本质上只有四条路理解了上面的原理提速思路就清晰了。第一条路是换一个带 JIT 的 Python 解释器典型工具是 PyPy它在运行时分析热点代码把频繁执行的部分编译成机器码。第二条路是在源码层直接做 JIT 编译比如用 Numba 给函数加装饰器。第三条路是把 Python 代码编译成 C 扩展代表工具是 Cython用静态类型声明换取执行速度。第四条路是绕开 GIL 或者让等待时间不浪费通过多进程、异步事件循环优化整体吞吐。这四条路没有绝对好坏。PyPy 最省事但扩展兼容性有限Numba 对数值计算很惊艳但不适合业务逻辑代码Cython 成熟稳定但是要写额外的类型声明和构建步骤异步方案则是把时间花在等待上的程序的天选答案。关键是根据你的程序画像来选择。2. 换解释器提速试试 PyPy2.1 PyPy 为什么这么能打PyPy 是 Python 语言的一个独立实现内置 JIT 编译器。它不是给代码加补丁而是直接把整个解释器替换掉。代码文件基本不用改还是用 py 文件运行只不过命令行工具从python换成了pypy3。JIT 的核心逻辑是程序在跑的时候解释器会统计哪些函数反复执行一旦认定是“热点”就把它编译成机器码。后面再调用这个函数走的就不是字节码解释流程而是直接执行机器码。用递归这种典型场景测试PyPy 的加速效果特别明显。下面这个计算斐波那契数的例子在 CPython 上跑到 n 比较大的时候会很吃时间但换 PyPy 往往能快五六倍甚至更多。def fib(n): if n 2: return n return fib(n - 1) fib(n - 2) print(fib(30))运行方式很简单安装好 pypy3 后直接执行pypy3 fib.py我曾在一个日志解析脚本上做过对比原脚本要解析两千万行文本里面大量使用字符串分割、正则匹配和列表操作。CPython 跑完大约 47 秒PyPy 跑完 8 秒出头。因为这类脚本没有复杂的 C 扩展依赖几乎是一次成功的迁移。2.2 PyPy 迁移成本和容易翻车的点PyPy 虽然省事但坑也不少。最典型的问题出在 C 扩展上。很多常用库为了追求性能底层用 C 或 C 实现并通过 Python 的 C 扩展接口暴露给调用方。PyPy 对这种 C 扩展的整体支持度不如 CPython。比如基于 NumPy 生态的一系列工具在 PyPy 上要么装不上要么运行效率达不到预期。如果项目里重度依赖 Pandas、Scrapy、部分数据库驱动迁移前一定要先做小规模验证。实际操作中我见过一个爬虫项目为了提速把整个代码切到 PyPy结果某个采集组件依赖的 C 扩展无法安装最后不得不回滚。后来只把其中的纯字符串处理子模块单独拎出来跑 PyPy通过队列通信和主程序交互才算实现部分加速。另外 PyPy 的启动速度比 CPython 慢内存占用也更高。短小的一次性脚本或者本身只跑几百毫秒的任务用 PyPy 反而不划算。JIT 预热需要时间程序跑得不够久收益根本覆盖不了成本。所以 PyPy 更适合“跑一次要几十秒甚至几分钟”的长时间脚本。3. 给数值计算就地加速Numba3.1 一段代码看懂 njitNumba 是专门面向数值计算的 JIT 编译器尤其适合写了大量 for 循环的 NumPy 代码。它和 PyPy 的“整包替换”不同是精准到函数的。你在需要的函数上加一个装饰器Numba 会在第一次调用时通过 LLVM 把函数编译成机器码后续调用直接执行编译后的版本。看一个经典场景给一个大数组的所有元素求平方和。用纯 Python 循环写一万个元素跑慢很正常如果数据到千万级那基本上要等很久。但 Numba 的版本几乎能吃掉循环的大部分开销速度接近手动编写的 C 代码。import numpy as np from numba import njit njit def sum_of_squares(arr): total 0.0 for i in range(arr.shape[0]): total arr[i] * arr[i] return total data np.random.rand(10_000_000) print(sum_of_squares(data))注意上面用的是njit它是jit(nopythonTrue)的简写意思是不允许进入 Python 解释模式必须全部编译成机器码。加了njit后Numba 对代码的类型要求和限制会更严格但速度最有保障。在实际项目里Numba 特别适合蒙特卡洛模拟、金融定价、图像处理、数值积分这类逻辑简单但数据量大的任务。我优化过一个期权定价模型先用 NumPy 向量化写已经比纯 Python 快不少了但内部的循环里仍然有复杂分支向量化不好压。把核心函数加上njit后整体计算缩短了十倍左右。3.2 实战中的限制与 trickNumba 不是万能编译器它在 no python 模式下只支持有限的数据类型和标准库函数。如果你在njit函数里随手用一个自定义对象或者调用某个 Python 动态特性编译会失败。常见的做法是把自定义数据类型改用 NumPy 数组或普通标量表达先把业务逻辑“降级”成数学计算。另外一个容易被忽略的点是第一次调用的编译耗时。函数越复杂编译时间越长。如果项目只是偶尔调用一次这个函数那首轮延迟可能让人难以接受。解决办法有两个一种是加cacheTrue参数让编译结果缓存到磁盘下个进程启动时直接复用另一种是先用小数据触发编译把预热时间放在启动阶段而不是卡在关键任务上。njit(cacheTrue) def slow_math(x): ...如果你对精度要求没那么苛刻还可以开启 fastmath 优化允许编译器重排运算、使用更激进的指令级优化。但这会改变浮点运算的舍入行为不是所有场景都能接受需要自己评估误差范围。4. 优雅的静态编译方案Cython4.1 从纯 Python 到 .pyxCython 是 Python 的超集允许在代码里写类似 C 的静态类型声明最终编译成 C 代码并包装成 Python 扩展模块。相比 PyPy 和 NumbaCython 更靠近底层能够精细控制哪些变量用 Python 对象、哪些变量用原生 C 类型。先看一个最简单的 Cython 示例把数组平方和写成.pyx文件# fast_sum.pyx def fast_sum(double[:] arr): cdef int i, n arr.shape[0] cdef double total 0.0 for i in range(n): total arr[i] * arr[i] return totaldouble[:]是内存视图类型表示这个参数是一个双精度浮点数一维数组。声明之后内部的i是 C inttotal是 C double。循环里的加法不再经过 Python 对象层速度自然上去。要让这段代码运行需要准备一个setup.py构建脚本# setup.py from setuptools import setup from Cython.Build import cythonize setup( ext_modulescythonize(fast_sum.pyx), )然后在命令行里构建当前目录的扩展模块python setup.py build_ext --inplace构建完成后当前目录会生成一个类似fast_sum.*.so的文件直接用 Python import 进来调用即可。4.2 增量改造而不是重写Cython 最让我喜欢的特性是它可以增量改造。你不用一下子就重写所有代码而是先在性能热点函数上加静态类型声明保留函数签名和调用方式不变让大部分 Python 代码继续保持原样。这样做风险低回归测试能兜底。实际项目里我的改造流程一般是这样的先用性能分析工具定位最耗时的函数把函数复制成一个.pyx文件保留函数名和参数结构然后逐行检查哪些循环变量可以声明成 C 类型哪些容器能换成内存视图。接着构建扩展模块在 Python 里 import 并做结果对比。确认数值一致后再把调用方切到新的扩展模块。Cython 还支持和 C 代码直接互操作。比如cdef extern from c_lib.h可以声明引入外部的 C 函数这是其他工具不太容易做到的。如果你的团队有 C/C 函数库用 Cython 做胶水层是经典方案。构建环节是 Cython 的主要门槛。在 Windows 上需要安装对应的 C/C 编译器不同 Python 版本对应的工具链版本还不一样。好在多数发行版都有现成的构建工具链装起来不算困难但配置问题仍然会被初学者卡很久。这里建议先在一个简单 demo 上打通闭环再对真实项目动手。5. 不要只看计算I/O 和并行工具同样重要5.1 uvloop 把事件循环拉满如果你的 Python 程序不是 CPU 密集而是大量网络请求、数据库读写、消息队列消费那瓶颈不在解释器执行速度而在等待。这类程序用 asyncio 异步模型能在一个线程里同时管理成千上万个连接。但 asyncio 的默认事件循环是用纯 Python 实现的把网络事件分发开来的过程中依然有额外开销。uvloop 是一个把底层事件循环替换成 C 实现的工具API 和 asyncio 几乎一样只需要在进程启动时指定策略。它在 Linux 和 macOS 上表现突出。我在一个内部监控服务上做过对比接入 uvloop 后同样配置下每秒处理的请求数提升了大约两到三成代码改动只有几行。import asyncio import uvloop asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) asyncio.run(main())注意要在创建事件循环之前设置好策略一般放在入口文件最前面。uvloop 并不是所有操作系统都支持Windows 环境的支持相对有限如果你是跨平台部署需要加条件判断或者直接继续用自带 asyncio。5.2 多进程绕开 GIL但要算好通信成本当程序确实需要用到多核 CPU 时多线程并不是一个好的选择因为 GIL 限制了同一时刻的活跃执行。多进程是更可靠的方式。Python 的concurrent.futures.ProcessPoolExecutor可以直接把任务分给多个进程去执行近乎天然绕开 GIL。from concurrent.futures import ProcessPoolExecutor def worker(x): # 这里是 CPU 密集型计算 return x * x with ProcessPoolExecutor(max_workers4) as pool: results list(pool.map(worker, range(1000)))这个方案听起来简单但有一个非常隐蔽的成本进程之间传递数据需要序列化。如果每次任务传入的数据量很大或者每个任务的计算时间很短序列化和 IPC 的开销就会超过多进程并行带来的收益。最好的主力场景是“任务大、参数小”比如每个 worker 要独立计算好几秒只给它传一个整数索引。另一种适用于并行计算场景的工具是 Taichi它用装饰器风格定义计算内核编译器自动做并行化和数据布局优化。Taichi 在处理粒子模拟、物理仿真、图形学算法时效率非常高因为它能感知字段布局自动把计算映射到 CPU 多核或 GPU。如果你在做这类偏图形学或数值仿真的事情值得把它当成 Numba 之外的另一手准备。6. 如何选工具不同任务对号入座6.1 一张表说清楚适用场景我整理了一个快速选型参考表你可以直接拿去对照自己的项目类型。这里的加速幅度是我在典型场景下观察到的经验值不保证每个场景都一样但排序和思路是有共性的。项目类型推荐工具改造成本加速收益主要坑点纯 Python 长耗时脚本PyPy低通常数倍第三方 C 扩展兼容性数值计算、循环密集型Numba低数十倍部分 Python 类型不支持需要精确控制底层接口Cython中高数倍到数十倍构建链配置高并发网络服务uvloop极低吞吐明显提升平台兼容性CPU 密集且无明显依赖关系ProcessPoolExecutor中随核数扩展序列化通信开销物理仿真、粒子类计算Taichi中很高需要接受新语法这张表不能完全替代基准测试但它能帮你快速锁定第一优先尝试的工具。我见过有人明明写的是网络服务却花了一周时间用 Cython 重写业务逻辑结果收益被网络延迟完全淹没。这类判断失误本质上就是没有做场景分类。6.2 组合使用比单点优化更实际真实项目往往不是一个工具打天下而是多工具协同。比如一个服务模块核心计算用 Numba 加速网络 IO 用 uvloop任务调度用多进程池暴露给 Python 的复杂对象用 Cython 封装。这样做既发挥了每个工具的长处又把风险限制在小模块内。组合的避坑原则是隔离。每个加速模块都应该有独立的入口和明确的输入输出格式最好是只接受 NumPy 数组、基本类型、字节串这类跨模块友好的数据。不要把带状态的复杂对象直接传进多进程或 JIT 函数否则调试成本会直线上升。我习惯在每个加速模块的边界处写简单的函数日志和结果校验。原因是加速工具一旦编译后Python 的 traceback 往往不如原始代码清晰。如果函数内部崩溃你看到的报错可能非常底层。边界校验能帮你快速定位是参数传入的问题还是工具本身的问题。7. 常见问题与排查实录7.1 编译错误和环境问题这几个工具最容易卡的环节是环境。PyPy 的 C 扩展安装失败时错误信息会提示缺少某个二进制兼容层。不要硬装先查一查目标库在 PyPy 下的支持列表。Numba 报“cannot determine Numba type”时说明函数里的某个变量没法推断类型一般把逻辑简化成基础数值运算就能解决。Cython 报链接错误时先检查 Python 开发头文件和 C 编译器是否齐全再检查是否有权限写入系统目录。构建 Cython 模块时我建议在虚拟环境下操作并且每次修改.pyx后都要重新执行build_ext --inplace。如果你发现改动不生效多半是 import 到了旧缓存。可以先把临时生成的.c文件和.so文件删除再重新构建一次。还要注意 Python 版本。像 Numba、Taichi 这类依赖 LLVM 的库通常滞后于最新 Python 版本。你在官方文档看到“已支持”和“当前环境可用”之间可能还有距离。升级 Python 版本本身也许能带来少量速度提升但代价可能是某些加速工具无法使用升级前务必确认整个依赖链。7.2 性能结果测起来不一致可能是预热问题很多人做完优化后直接用脚本开始和结束时间对比发现效果忽高忽低。这在 JIT 工具里非常常见。JIT 需要预热尤其是 PyPy 和 Numba。第一次调用会先编译后面几次才是优化后的速度。如果你只跑一次很可能计的是编译时间。正确做法是先做几轮“热身调用”再进入正式计时。比如 Numbasmall np.random.rand(1000) sum_of_squares(small) # 预热触发编译 big np.random.rand(10_000_000) start time.perf_counter() sum_of_squares(big) print(time.perf_counter() - start)同时内存缓存页的冷热也会造成误差。最好同一个用例交替跑三轮以上取中位数。不要因为某一轮结果就武断判断优化是否有效。也只有在这种严谨测试下你才能判断某个工具是否真的适配当前任务。7.3 这几年踩坑踩出来的经验我在优化一个大规模数据处理模块时最初直接上 PyPy结果核心库不支持被迫回滚。后来改用 Cython 做了增量改造但很多函数类型标注写得不到位速度提升并不理想。最后把所有循环逻辑重构成 NumPy 数组只对最内层计算使用 Numba才拿到稳定收益。整个过程让我明白没有任何一个工具能压制所有情况。我现在的基本套路是先用cProfile把热点函数找出来计算热点占总耗时的比例。如果热点占 90% 以上那值得花一周做深度优化如果最高热点只占 30%那先考虑调整整体架构而不是急着用编译器。如果你的程序里没有任何热点所有函数时间都差不多那其实不需要专门的加速工具。先优化数据结构和算法把 O(n²) 改成 O(n log n)效果往往比上一堆编译器好得多。Python 生态里很多“慢”都是冗余计算和不合理的数据结构造成的这类问题用工具是治标不治本。最后再给一个小建议凡是用到 JIT 或编译模块的项目最好在 CI 里加一道冒烟测试强制调用一次核心加速函数保证它在每次变更后仍能正常编译和运行。否则很容易出现“今天代码能跑明天换台机器就报编译器版本不匹配”的情况。把这些基础工作做扎实提速工具才能真正变成你项目里的稳定资产。