Kodokon kodokon.com

性能:性能剖析、GIL、生成器对比列表、内存陷阱

先测量,再优化:timeit 与 cProfile、GIL 真正阻止的是什么、生成器的节俭,以及字符串和切片的内存陷阱。

11 分钟 · 3 题

在 Kodokon 中打开本课

永远不要猜测,要测量。timeit 能隔离出一个可靠的微基准:它会关闭垃圾回收器,并重复执行以抹平噪声。cProfile 则勾勒出整个程序的调用全貌 - 按 cumtime(累计时间,包含子调用)排序,就能找出真正的罪魁祸首。任何性能工作的黄金法则是:那行慢代码几乎从来都不是你怀疑的那一行。

BASH
python -m timeit "sum(x * x for x in range(1000))"
python -m timeit -s "s = 'abc' * 100" "s.upper()"
python -m cProfile -s cumtime -m timeit "min(range(50))"
timeit 的 -s 选项会在测量之外准备好上下文。

GIL(全局解释器锁)保证在一个进程内,同一时刻只有一个线程执行 Python 字节码。结果就是:线程无法加速纯计算。不过它们对 I/O 依然完美,因为在网络或磁盘读取期间 GIL 会被释放,NumPy 这类许多 C 语言库也会释放它。要用计算把多个核心跑满,就得走 multiprocessingconcurrent.futures.ProcessPoolExecutor。Python 3.13 提供了一个实验性的、不带 GIL 的"自由线程"(free-threaded)变体,但它在生产环境中仍然罕见。

PYTHON
import sys

squares_list = [x * x for x in range(1_000_000)]
squares_gen = (x * x for x in range(1_000_000))

print(sys.getsizeof(squares_list), "bytes")
print(sys.getsizeof(squares_gen), "bytes")
print(sum(squares_gen))
列表大约 8 MB,生成器大约 200 字节。

无论序列有多长,一个生成器都只有几百字节重:它只保存自己的执行状态,并按需产出每一个值。把它们串成流水线 - sum(x * x for x in data) - 就能处理数据流,而无需实体化任何中间列表。有一个需要知道的取舍:生成器是一次性的。一旦耗尽,它就不再产出任何东西,而且悄无声息 - 这是一类经典的 bug 来源,第二次迭代看上去就像是"空的"。

PYTHON
import timeit

def concat(n: int) -> str:
    out = ""
    for i in range(n):
        out += str(i)
    return out

def join(n: int) -> str:
    return "".join(str(i) for i in range(n))

t1 = timeit.timeit(lambda: concat(20_000), number=10)
t2 = timeit.timeit(lambda: join(20_000), number=10)
print(f"concat: {t1:.3f} s / join: {t2:.3f} s")
在你自己的机器上测量:join 始终是可移植而稳妥的选择。

知识检测

确认你已牢记本课的重点内容。

  1. GIL 究竟阻止了什么?
    • Python 中任何形式的并行,包括 multiprocessing
    • 同一个进程的多个线程同时执行 Python 字节码 - I/O 和许多 C 代码都会释放它
    • 在单个程序中使用多个线程
    • 对同一个变量的并发写入
  2. 为什么生成器的 sys.getsizeof 与等价的列表相比小得可怜?
    • 生成器不存储任何元素:它只保存自己的执行状态,并按需计算每一个值
    • getsizeof 无法测量生成器
    • 生成器在内部压缩了自己的数据
  3. 为什么 ''.join(parts) 胜过在循环里反复执行 out += part
    • join 用多个线程来拼装字符串
    • 这是个迷思:从 Python 3 起两者性能完全相同
    • 由于 str 不可变,每次 += 都会重新复制整个字符串(平方级代价);而 join 会先算出最终大小,只分配一次内存
    • join 避免了创建生成器产生的中间 str 对象