当前位置:首页>python>我以为 Python 很慢是因为它是解释型语言,直到我用 dis 看了它到底在干什么

我以为 Python 很慢是因为它是解释型语言,直到我用 dis 看了它到底在干什么

  • 2026-10-11 07:30:31
我以为 Python 很慢是因为它是解释型语言,直到我用 dis 看了它到底在干什么

有个问题我被问过很多次:"Python 为什么慢?"

我以前的回答一直是:"因为它是解释型语言,运行时才翻译,不像 C 那样编译成机器码。"

这个答案没错,但很空洞。直到我第一次用 dis 模块把一段 Python 代码翻开来看,才意识到"解释型语言"这四个字背后到底藏着什么。


dis 是 Python 标准库里自带的字节码反汇编工具。Python 源代码在运行之前会先被编译成字节码,再交给 CPython 虚拟机执行——dis 能让你直接看这个字节码长什么样。

用法很简单:

import dis

def add(a, b):
    return a + b

dis.dis(add)

输出(Python 3.12):

  2           RESUME                   0

  3           LOAD_FAST                0 (a)
              LOAD_FAST                1 (b)
              BINARY_OP                0 (+)
              RETURN_VALUE

每一行就是一条虚拟机指令。LOAD_FAST 是从局部变量加载值,BINARY_OP 是执行加法,RETURN_VALUE 是返回。

这段代码很简单,指令也很少。但事情在复杂一点的场景下就开始有意思了。


有一次我在优化一段数据处理代码,发现一个循环明显比预期慢,于是把两种写法拿出来对比。

第一种,每次循环都做属性访问:

import dis

def sum_with_attr(data):
    result = 0
    for x in data:
        result += x
    return result

dis.dis(sum_with_attr)

第二种,先把 append 这类方法缓存到局部变量:

def collect_items(data):
    result = []
    append = result.append   # 缓存方法引用
    for x in data:
        append(x)
    return result

dis.dis(collect_items)

我把 collect_items 和不缓存的版本对比了一下字节码数量,发现缓存版本每次循环少了 LOAD_FAST(加载 result)和 LOAD_METHOD(查找 append 方法)两条指令。循环跑一百万次,这个差距就很可观了。

实际测一下:

import timeit

data = list(range(100000))

def collect_no_cache():
    result = []
    for x in data:
        result.append(x)
    return result

def collect_with_cache():
    result = []
    append = result.append
    for x in data:
        append(x)
    return result

t1 = timeit.timeit(collect_no_cache, number=100)
t2 = timeit.timeit(collect_with_cache, number=100)

print(f"不缓存: {t1:.3f}s")
print(f"缓存 append: {t2:.3f}s")
print(f"提速比: {t1/t2:.2f}x")

输出:

不缓存: 0.823s
缓存 append: 0.612s
提速比: 1.34x

快了 34%,只是因为少了一次属性查找。


用 dis 能看到的东西不只是循环优化。

有一次我看到有人说"全局变量比局部变量慢",我一直没搞清楚为什么,直到我用 dis 对比了两者的字节码:

import dis

G = 42

def use_global():
    return G

def use_local():
    x = 42
    return x

print("=== use_global ===")
dis.dis(use_global)
print("=== use_local ===")
dis.dis(use_local)

输出:

=== use_global ===
  2           RESUME                   0

  3           LOAD_GLOBAL              0 (G)
              RETURN_VALUE

=== use_local ===
  4           RESUME                   0

  5           LOAD_CONST               1 (42)
              STORE_FAST               0 (x)

  6           LOAD_FAST                0 (x)
              RETURN_VALUE

LOAD_GLOBAL 和 LOAD_FAST 是不同的指令。全局变量需要在全局命名空间里做字典查找,而局部变量直接通过索引访问一个数组,快得多。

这就解释了为什么在热点函数里,把常用的全局变量或模块属性赋给局部变量是一个常见的优化手段:

import math

# 慢:每次循环都要 LOAD_GLOBAL math,再 LOAD_ATTR sin
def calc_slow(data):
    return [math.sin(x) for x in data]

# 快:把 math.sin 缓存到局部变量
def calc_fast(data):
    _sin = math.sin
    return [_sin(x) for x in data]

dis 还有一个用法我特别喜欢:看列表推导式和 map 到底有什么区别。

import dis

def with_list_comp(data):
    return [x * 2 for x in data]

def with_map(data):
    return list(map(lambda x: x * 2, data))

print("=== list comprehension ===")
dis.dis(with_list_comp)
print("=== map + lambda ===")
dis.dis(with_map)

输出(精简版):

=== list comprehension ===
  2           RESUME                   0
              GET_ITER
              ...(listcomp 内联到函数里)

=== map + lambda ===
  5           RESUME                   0
              LOAD_GLOBAL              1 (list)
              LOAD_GLOBAL              3 (map)
              LOAD_CONST               1 (<code object <lambda>>)
              MAKE_FUNCTION            0
              LOAD_FAST                0 (data)
              CALL                     2
              CALL                     1
              RETURN_VALUE

map + lambda 的字节码涉及到创建函数对象(MAKE_FUNCTION)和两次函数调用(CALL),开销比列表推导式更大。这就是为什么大多数场景下列表推导式比 map(lambda ...) 更快的底层原因。


当然,dis 不是银弹。

它给你看的是 CPython 字节码,不是机器码。字节码指令的数量不完全等于运行时间,因为每条指令的实际开销差异很大——CALL 比 LOAD_FAST 重得多。

而且 Python 版本不同,字节码格式也在变。Python 3.11 和 3.12 对解释器做了大幅度重构,很多指令被合并或优化,生成的字节码和老版本不一样。所以用 dis 看到的结果要结合实际 benchmark 来判断,不能光数指令数。

但即便如此,dis 作为一个"翻开引擎盖看看"的工具,依然非常值得用。


我用 dis 最大的收获不是某个具体的优化技巧,而是一种理解方式的转变:

写代码的时候,不再把"这行代码"当成一个原子操作,而是会多想一步:这背后大概会产生几条指令?有没有不必要的查找?有没有重复的调用?

这个意识养成之后,很多"直觉"就有了根据,而不是靠模糊的经验猜猜猜。

如果你对 Python 底层感兴趣,可以试着把自己常写的几种模式都用 dis.dis() 看一遍——有时候结果会让你很意外。


最后,别忘了关注「有为大青年」,我们下期见~

最新文章

随机文章