Memray 实战教程(三):用 Python GC 引用计数与 functools.cache 定位 LRU 缓存引发的内存泄漏
Memray 实战教程(三):用 Python GC 引用计数与 functools.cache 定位 LRU 缓存引发的内存泄漏
导读
本教程是 Memray 内存剖析器系列演练的第三课,围绕 @functools.cache(即无上限的 lru_cache)装饰实例方法时意外“抓住”对象引用、导致内存无法回收的问题展开。你将学会用 memray run + memray flamegraph 生成火焰图定位异常分配点,用 --native 模式穿透 Python 层看清底层调用栈,并掌握三种修复缓存引发内存泄漏的实战方案,最终把练习程序的峰值内存降到 70MB 以下。
背景:Python 的自动内存管理与 GC 盲区
在上一课(Exercise 2 - Clinging Onto Memory)中,我们观察了 Python 自动释放内存的时机与方式。本课将进一步深入 Python 的自动回收逻辑,理解它“看似自动、实则有限”的一面。
在 Python 中,垃圾回收(Garbage Collection, GC)指的是程序识别并释放不再使用的内存块的过程。Python 的垃圾回收器在程序运行期间持续工作,当一个对象在内存中的引用计数(reference count)降到零时,它就会被激活并回收。
引用计数的变化规则如下:
- 引用计数增加:当对象被赋予一个新名字,或被放入一个容器(如 tuple、dict)时;
- 引用计数减少:当引用该对象的名称被重新赋值指向其他对象、指向该对象的变量超出作用域、或引用该对象的容器被销毁时。
自动垃圾回收非常有用,但它未必能清空我们期望它清空的所有内存块——这正是本课要揭示的现象:一个本应“用后即弃”的对象,可能因为被某个隐藏的引用持有而永远存活。
实验代码:lru_cache.py 里的“隐藏钉子”
本课练习位于 docs/tutorials/exercise_3/lru_cache.py。先来看核心代码:
import functools
from collections import Counter
# DO NOT CHANGE
FIRST_COUNTER_RANGE = 500
SECOND_COUNTER_RANGE = 1000
# DO NOT CHANGE
class Algorithms:
def __init__(self, inc: int):
self.inc = inc
@functools.cache # pylint: disable=W1518
def factorial_plus(self, n: int) -> int:
return n * self.factorial_plus(n - 1) + self.inc if n > 1 else 1 + self.inc
def generate_factorial_plus_last_digit(plus_range: int, factorial_range: int):
for i in range(plus_range):
A = Algorithms(i)
for j in range(factorial_range):
yield A.factorial_plus(j) % 10
def compare_counts_different_factorials():
counts_500 = Counter(
generate_factorial_plus_last_digit(FIRST_COUNTER_RANGE, FIRST_COUNTER_RANGE)
)
counts_1000 = Counter(
generate_factorial_plus_last_digit(SECOND_COUNTER_RANGE, SECOND_COUNTER_RANGE)
)
print(counts_500.most_common())
print(counts_1000.most_common())
if __name__ == "__main__":
compare_counts_different_factorials()
注意 factorial_plus 是一个实例方法,却被 @functools.cache 装饰。这意味着缓存键里必然包含 self——也就是 Algorithms 实例本身。外层循环会创建 500 个、1000 个 Algorithms(i) 实例,每个实例的每一次 factorial_plus(j) 调用都会把 (self, j) 作为键缓存起来。看起来只是几百个实例,为何内存会暴涨?这就是本课要排查的问题。
练习任务:仔细观察这段代码,找出与内存相关的 bug;然后运行 memray 并生成火焰图,看看内存分配方式是否与你预期的一致。
第一步:生成火焰图,观察分配热点
从教程一我们知道,用 Memray 包裹脚本并生成火焰图的命令是:
memray run docs/tutorials/exercise_3/lru_cache.py
运行完成后,Memray 会提示生成报告的命令。输出文件名形如 memray-lru_cache.py.<run-id>.bin(run-id 每次运行都会变化),然后执行:
memray flamegraph docs/tutorials/exercise_3/memray-lru_cache.py.<run-id>.bin
打开生成的 HTML 火焰图,我们会看到绝大多数分配都来自 factorial_plus() 方法里的 return 语句。这一点非常反常:这条语句看起来并没有做什么内存密集的操作:
return n * self.factorial_plus(n - 1) + self.inc if n > 1 else 1 + self.inc
下面的火焰图展示了这种情况——factorial_plus 的返回值成了内存分配的“重灾区”:
为什么会这样?@functools.cache 会在每次调用后把参数(包括 self)和返回值都缓存起来,返回值本身是整数,但缓存的键 (self, j) 持有了整个 Algorithms 实例的强引用。只要缓存不清空、不设上限,这些实例就永远不会被回收——于是每个 Algorithms(i) 及其关联的缓存项都“钉”在内存里,累积成巨量内存占用。
第二步:开启 --native 模式,穿透 Python 层
火焰图已经告诉我们“在哪里分配”,但还看不透“为什么这里分配”。这时需要借助 Memray 的 native 模式:
memray run --native docs/tutorials/exercise_3/lru_cache.py
memray flamegraph docs/tutorials/exercise_3/memray-lru_cache.py.<run-id>.bin
加上 --native 后生成的火焰图会额外显示 C/C++/Rust 层面的调用栈,帮助我们发现 Python 层之下的“内存重活”:
对比两张火焰图,你能发现什么新线索?native 火焰图会把隐藏在实际分配背后的底层函数暴露出来——例如 functools 缓存机制内部对 dict 的扩容、key 的哈希与存储等 C 层操作,从而证实“缓存本身在持续吃内存”这一判断。对 functools、numpy、cryptography 这类由 C、C++、Fortran 或 Rust 编写的库,native 模式尤其有用。
native 模式的原理:指令指针与符号化(symbolification)
为什么 --native 能看到底层调用栈?其原理在 docs/native_mode.rst 中有详细说明:
- 当向
run子命令传入--native标志时,Memray 会收集每次分配对应的 native 调用栈,并写入结果文件; - 这些信息是原始形态的:每个调用都由一个称为**“指令指针”(instruction pointer)或“程序计数器”(program counter)**的数字标识,它指向 CPU 即将执行的下一条指令的内存地址;
- 生成报告时,Memray 需要把这些指令指针转换成人类可读的信息——函数名、函数所在文件名、以及指令对应的文件内行号。这一转换过程称为 symbolification(符号化)。
Memray 采用两种符号化方式:
- DWARF 调试信息:从包含指令的可执行文件或共享库中提取调试信息,能得到函数名、文件名、行号,甚至能解析内联函数。只要调试信息可用,就优先使用此方法;
- 符号表信息:仅能提供函数名,无法提供文件名和行号,且符号表未必包含每个函数,因此是次优方案。
有两个关键注意事项:
- 符号化被推迟到生成报告时进行(以降低跟踪开销),因此报告必须在运行被跟踪程序的同一台机器上生成——因为需要检查当时使用的解释器可执行文件与已加载的共享库;换机器生成会导致符号化错误或报告不准确;
- 在 Linux 上可用
readelf -S $(which python) | grep debug检查解释器是否带 DWARF 调试信息;在 macOS 上,由于大多数 Python 二进制不包含调试信息,native 模式的符号化效果会差很多,此时可通过dsymutil为自己的 native 扩展生成 dSYM 包,或借助debuginfod从远程服务器按需获取调试信息(需设置DEBUGINFOD_URLS环境变量)。
从源码角度看,--native 标志在 src/memray/commands/run.py 中被解析,最终通过 Tracker(destination=..., native_traces=args.native, ...) 传给跟踪器(见 src/memray/commands/run.py),从而开启 native 调用栈采集。
挑战:把峰值内存压到 70MB
现在轮到你了。挑战目标:修改 docs/tutorials/exercise_3/lru_cache.py 中的代码(注意不要改动文件顶部的 FIRST_COUNTER_RANGE / SECOND_COUNTER_RANGE 常量),把程序的峰值内存降到 70MB。
验证方式:运行配套单元测试 docs/tutorials/tests/test_exercise_3.py,它包含两类用例:
- 内存上限测试:
@pytest.mark.limit_memory("200 MB")标记的test_lru_cache会强制执行 200MB 上限(由 pytest-memray 插件提供,教程依赖见 docs/tutorials/requirements-tutorial.txt),挑战目标 70MB 比它更严格; - 正确性测试:
test_algorithms_0、test_algorithms_5、test_generate_factorial_plus_last_digit_0等用例校验factorial_plus与生成器的计算结果,确保你在优化内存的同时不破坏功能。
运行测试:
pytest docs/tutorials/tests/test_exercise_3.py
修改代码后,用 memray run + memray flamegraph 重新生成报告,观察优化效果。
提示:两个关键线索
如果你卡住了,参考以下两个提示(教程原文档中以可折叠的 <details> 呈现):
提示一:理解 cache 装饰器的作用域
cache 装饰器适用于参数可哈希(hashable)的方法——它按唯一的参数组合列表缓存被装饰方法的返回结果。缓存中的结果会一直存活,直到老化出缓存(但我们没有设置缓存大小上限,所以这永远不会发生)或手动清空缓存。
再看一眼被缓存的方法:
@functools.cache
def factorial_plus(self, n: int) -> int:
return n * self.factorial_plus(n - 1) + self.inc if n else 1 + self.inc
思考:这里的方法调用中,哪些会被缓存?self 也是参数的一部分!
提示二:让 pylint 告诉你答案
把 docs/tutorials/exercise_3/lru_cache.py 第 16 行的注释 # pylint: disable=W1518 删掉,然后运行 pylint,看看它会给出什么提示(W1518 正是“在实例方法上使用 lru_cache/cache”这一警告)。
三种解决方案
修复这个内存问题有很多途径,教程原文档给出了三种代表性方案,完整参考实现位于 docs/tutorials/solutions/exercise_3/lru_cache.py。
方案一:把缓存挂在 self 上,随对象一起释放
问题的根源在于:cache 装饰器本质是调用 functools.lru_cache(maxsize=None),而 lru_cache 对象会**“记忆化”(memoize)返回结果,并在缓存中保留传入被装饰函数的所有参数值的引用**。所以,如果用一个对象作为参数调用被装饰函数,该对象会一直驻留内存,直到程序终止。当且仅当没有其他对象与它相等时,我们永远无法命中它的缓存——白白浪费缓存空间。这个场景在装饰实例方法时尤其常见,因为第一个参数永远是 self。
一个干净的解法:使用专用的记忆化方法,把缓存存放在 self 对象自身上。这样缓存会随对象一起被释放:
class Algorithms:
def __init__(self, inc: int):
self.inc = inc
self.factorial_plus = functools.cache(self._uncached_factorial_plus)
def _uncached_factorial_plus(self, n: int) -> int:
return n * self.factorial_plus(n - 1) + self.inc if n > 1 else 1 + self.inc
def generate_factorial_plus_last_digit(plus_range: int, factorial_range: int):
for i in range(plus_range):
A = Algorithms(i)
for j in range(factorial_range):
yield A.factorial_plus(j) % 10
这里每个 Algorithms(i) 实例都拥有自己独立的缓存(不再跨实例共享),缓存键中也不再有 self(因为 _uncached_factorial_plus 绑定的缓存由 self.factorial_plus 持有,键只包含 n),当实例离开作用域、引用计数归零时,缓存随之整体释放。
方案二:直接限制缓存大小
另一种做法是给缓存设置一个最大容量,通过直接向 @lru_cache 装饰器传参实现:
@functools.lru_cache(maxsize=10000)
def factorial_plus(self, n: int) -> int:
return n * self.factorial_plus(n - 1) + self.inc if n else 1 + self.inc
maxsize= 设置缓存中存储的最大值数量。
注意:
@cache只是用默认参数包装了@lru_cache;如果想自己设置缓存大小,就必须直接使用@lru_cache装饰器。
此方案通过限制缓存条目总数,间接约束了被缓存实例的数量,但要注意:若缓存键仍包含 self,被缓存的实例依旧会在其条目被淘汰前存活,只是总数被封顶。
方案三:周期性手动清空缓存
最后,还可以定期手动清理缓存,调用:
Algorithms.factorial_plus.cache_clear()
cache_clear() 会清空该缓存中的所有条目,从而释放被缓存的参数与返回值引用。
结论:理解装饰器,比会用更重要
@functools.cache 装饰器是提升程序效率的强大工具,但在使用前必须彻底理解它的工作原理。当我们用它装饰一个实例方法时,实际上把该类的实例(self)也加入了缓存数据的键中。这在使用多个实例时极易引发意外内存泄漏,原因在于:
- LRU 缓存会在其缓存中保留被装饰函数所有参数的引用;
- 因此,若用一个对象作为参数调用被装饰函数,该对象会一直驻留内存,直到程序终止或缓存被清空(这些被缓存对象的 GC 引用计数始终 > 0);
- 如果没有任何其他对象实例与用作缓存键的那个对象相等,我们永远无法命中缓存,却白白持有该对象作为缓存键,造成无谓的内存消耗。
本课展示了如何通过手动检查 Python 脚本或借助 pytest 插件两种方式使用 Memray,来捕捉这类及类似的内存异常行为。火焰图 + native 模式组合,是排查“分配点看起来人畜无害、内存却居高不下”类问题的利器。
如需继续深入,可以阅读:
- Memray 火焰图报告详解 与 run 子命令完整参数(如
--trace-python-allocators、--follow-fork、--live等进阶选项); - Python GC 引用计数机制与缓存方法调用的最佳实践,可参考 Python 官方文档与 cpython 关于“在方法上误用 lru_cache”的经典 issue;
- 系列教程的另外两课:Exercise 1 - Fibonacci Sequence(火焰图与 pytest 集成入门)与 Exercise 2 - Clinging Onto Memory(局部变量过度持有内存)。

