前言
「Python 慢」是一个流传很广的结论,但很多人对它只有感觉、没有理解。常见的误解有两类:一类是把它归因于「解释器写得差」,另一类是以为「多开几个线程就快了」。这两个说法都不准确。
准确的说法是:Python(这里指 CPython 实现)的慢,主要来自它的执行模型——先编译成字节码,再由虚拟机逐条解释执行;同时它是动态类型语言,每次操作都要在运行时确定对象的类型。这两点决定了它把很多工作留到了运行时。这跟「写得差」不是一回事,它是一种设计取舍:换来的是灵活和开发效率。
本文会分三层讲:先讲执行模型为什么导致额外开销,再讲 GIL(全局解释器锁)划定了并发的边界,最后给出一套按层面组织的优化方案。全文不会给出任何未经测量的性能倍数——具体到你的场景快多少,请用timeit自己测。
需要声明:本机没有 Python 解释器,本文代码均无法在本环境实际执行,都是按 Python 官方文档整理的写法,请以官方文档为准。本文不声称任何代码被运行过,也不会给出「实测结果如下」之类的表述。
一、执行模型:解释执行加动态类型
解释执行
CPython 先把源码编译成字节码(bytecode),然后由一个循环(常被称作「求值循环」,evaluation loop)逐条取字节码、分派、执行。对比之下,编译型语言在编译期就把很多东西确定下来,运行时几乎没有这层「取指令—判断指令—跳转执行」的开销。Python 每条字节码都要走一遍这层分派,累加起来就是可观的开销。
动态类型
Python 的变量没有类型,值(对象)才有类型。a + b这一句,解释器在运行时才去看a是什么、b是什么、它们的__add__该怎么走。这就是「类型分派」。
同时,Python 里连整数都是对象:它有一个头部,装着引用计数、类型指针。你做一个整数运算,往往不只是做一次机器加法,还牵涉到对象的创建、引用计数的增减——这就是常说的「装箱」(boxing)成本。小的整数在 CPython 里会被缓存复用(大致是 -5 到 256 这个区间),但这是实现细节,不要依赖。
需要特别提醒:以上是「机制解释」,不要把它换算成「Python 比 C 慢多少倍」。那种数字要看具体任务、具体实现,本文不给,任何没出处的倍数都不可信。
引用计数与内存
CPython 用引用计数为主来管理内存,配合分代垃圾回收处理循环引用。引用计数的增减是常量时间操作,但每次对象赋值、传参都会触发,这也是执行模型的一部分成本。
二、GIL 与并发的边界
GIL 是「全局解释器锁」。它的作用是:在同一个进程内,同一时刻只有一个线程在执行 Python 字节码。这带来两个直接结论:
- 对 CPU 密集任务,多线程不会带来并行加速。因为同一时刻只有一个线程在跑字节码,多开线程只是轮流执行,还要付出切换成本。
- 对 I/O 密集任务,多线程是有效的。线程在等待 I/O(读写文件、网络请求)时会释放 GIL,别的线程可以趁机执行。所以网络爬取、文件批处理这类以等待为主的任务,多线程能提高吞吐。
CPU 密集任务的正确路径是多进程(multiprocessing),每个进程有独立的解释器和独立的 GIL,可以真正并行;或者把热点下沉到 C 扩展、原生扩展去实现。
关于「Python 已经没有 GIL 了」这个说法:不准确。CPython 从 3.13 起提供了实验性的自由线程(free-threaded,可禁用 GIL)构建(对应 PEP 703),但官方明确说明它是实验性的、默认不启用,需要用单独的构建(可执行文件名通常带t后缀)来运行。默认构建依然有 GIL。
| 任务类型 | 多线程 | 多进程 | 异步 I/O |
|---|
| 等待网络 / 文件 | 有效 | 可用但偏重 | 有效 |
| 大量纯计算 | 无明显加速 | 有效(能并行) | 不适用 |
| 混合型 | 视比例而定 | 视比例而定 | 视比例而定 |
三、优化方案:按层面下手
优化的第一原则是先测量,再优化,用标准库的timeit模块量出瓶颈在哪,而不是凭感觉改。
# 适用于 Python 3.8+
import timeit
# timeit(stmt, setup, number):把 stmt 执行 number 次,返回总秒数
setup = "data = list(range(10000))"
stmt = "sum(data)"
print(timeit.timeit(stmt, setup=setup, number=1000))需要比较不同重复次数下的稳定性时,用timeit.repeat,它返回一串结果,可以看波动。
1. 算法与数据结构
先问数据结构选对了没有。成员判断用列表是线性扫描,用集合或字典是哈希查找,在数据量大时通常更划算:
# 适用于 Python 3.8+
data = [i * 2 for i in range(10000)]
target = 9998
# 列表:逐个比较
in_list = target in data
# 集合:哈希查找
seen = set(data)
in_set = target in seen
print(in_list, in_set)两种写法结果相同,但复杂度不同。具体快多少,取决于数据规模和分布,请自己用timeit测。
2. 用内置函数和 C 实现替代手写循环
sum、max、min、any、all、sorted这些内置函数,以及str.join、map、filter,底层是用 C 实现的,它们把循环藏在 C 里跑,省掉了大量字节码分派。
# 适用于 Python 3.8+
nums = [3, 1, 4, 1, 5]
# 手写循环
total = 0
for n in nums:
total += n
# 内置实现
total2 = sum(nums)
print(total, total2)字符串拼接尤其要注意:+=反复拼接会不断产生新字符串对象,而join一次性算出结果。
# 适用于 Python 3.8+
parts = [str(i) for i in range(1000)]
# 逐次拼接
s1 = ""
for p in parts:
s1 += p
# 一次性拼接
s2 = "".join(parts)
print(len(s1) == len(s2))3. 减少重复计算
把循环里不变的计算提到循环外,是收益最直接的一类优化。函数调用同样如此:在循环里反复调re.compile去编译同一个正则,就是典型的重复计算。
# 适用于 Python 3.8+
import re
lines = ["a1", "b22", "c333"]
pattern = re.compile(r"\d+") # 编译一次
for line in lines:
m = pattern.search(line)
# 说明:re 模块内部会缓存最近编译过的模式,
# 但显式编译一次再复用,语义更清楚,也免去缓存查找。用functools.lru_cache做记忆化,可以避免对相同参数重复计算:
# 适用于 Python 3.8+
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(30))lru_cache的参数签名是lru_cache(maxsize=128, typed=False),也可以直接当无参装饰器用在函数上。它的缓存是线程安全的。适合的情况是「相同参数会被反复调用」;如果参数每次都不同,缓存只会白占内存。
4. 局部变量绑定
函数里的局部变量访问走的是「按索引取」这条较快路径,全局名和属性访问则要多几步查找。把频繁使用的全局名(比如某个模块里的函数)绑定成局部名,可以减少这部分开销。
# 适用于 Python 3.8+
import math
def calc(values):
sqrt = math.sqrt # 绑定为局部名
return [sqrt(v) for v in values]
print(calc([1, 4, 9]))5. 生成器替代一次性大列表
列表会把所有元素一次性放进内存,生成器则按需产出。当数据只需遍历一次、且体量很大时,生成器能显著降低内存峰值。
# 适用于 Python 3.8+
def count_up(n):
i = 0
while i < n:
yield i
i += 1
total = sum(count_up(5)) # 逐个产出,不构造整张列表
print(total)注意生成器只能遍历一次,遍历完就空了。
6.__slots__减少每实例开销
默认情况下,每个实例都带一个__dict__来存属性。用__slots__显式声明属性名后,实例不再需要那个字典,占用更小,属性访问路径也更直接。
# 适用于 Python 3.8+
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
print(p.x, p.y)代价是不能再给实例动态添加未声明的属性,且继承自没有__slots__的类时效果会被削弱。
7.array与memoryview
需要大量同类型数值时,array模块提供更紧凑的存储,memoryview允许在不复制底层缓冲区的前提下按视图访问数据。
# 适用于 Python 3.8+
from array import array
nums = array("i", range(10)) # 类型码 "i" 表示有符号整数
print(len(nums), nums[3])
mv = memoryview(b"hello")
print(mv[0]) # 直接读底层字节,不复制常见坑点
1. 不做测量就动手优化。
- ❌ 凭直觉改代码,改完发现瓶颈根本不在那。
- ✅ 先用
timeit量出热点,再针对热点改。
2. 以为多线程能让计算变快。
- ❌ 给一个纯计算的循环套上多线程,期望加速。
- ✅ 受 GIL 限制,CPU 密集要用多进程或原生扩展;I/O 密集才适合多线程。
3. 说「Python 已经没有 GIL 了」。
- ❌ 把 3.13 的实验性自由线程构建当成默认能力。
- ✅ 它默认不启用,需单独构建;默认解释器仍有 GIL。
4. 在循环里反复re.compile。
- ❌ 每次迭代都编译一遍同一个正则。
- ✅ 编译一次、循环外复用。
5. 用+=在循环里拼大量字符串。
- ❌ 逐次拼接,反复产生新字符串对象。
- ✅ 收集到列表里,用
str.join一次性拼。
6. 用列表做大量成员判断。
- ❌ 对一个大列表反复做
x in lst。 - ✅ 改用具哈希查找的集合或字典。
7. 给参数每次都不同的函数加缓存。
- ❌ 无脑上
lru_cache,缓存全不命中,只白占内存。 - ✅ 只在「相同参数会被反复调用」时使用。
8. 忽视生成器只能遍历一次。
- ❌ 把一个生成器遍历两遍,第二遍得到空结果。
- ✅ 需要多次遍历就物化成列表,或每次重新创建生成器。
总结
| 层面 | 手段 | 原理 |
|---|
| 执行模型 | 理解解释执行与动态类型 | 每次操作有分派与装箱开销 |
| 并发 | 多进程 / I/O 用线程 | GIL 限定了线程内的并行 |
| 数据结构 | 列表换集合 / 字典 | 哈希查找替代线性扫描 |
| 循环 | 用内置函数与join | 把循环下沉到 C 实现 |
| 重复计算 | 提到循环外、lru_cache | 同样的结果不重复算 |
| 内存 | 生成器、__slots__、array | 减少对象与字典开销 |
「Python 慢」的根源在执行模型和动态类型,而不是「解释器写得烂」;优化的顺序永远是先测量、再动手,选对数据结构往往比微调写法更有效;并发要认清 GIL 的边界,CPU 密集用多进程,I/O 密集才用线程。至于能快多少,请在你的数据上用timeit自己得出结论。