设置NDArray视图标志为何引发内存分配?是否存在内存泄漏?
问题背景
为什么给NDArray视图设置不可写标志会引发内存分配?这种分配是否处于可控范围?
看下面的代码:
import numpy as np import itertools def get_view(arr): view = arr.view() view.flags.writeable = False # 这行代码会导致内存增长? return view def main(): for _ in itertools.count(): get_view(np.zeros(1000)) if __name__ == "__main__": main()
运行这段代码时,设置视图为不可写的那行似乎引发了内存增长,但不确定这种增长是否可控。
需要解答两个问题:
- 为什么会出现这种情况?
- 这种内存增长是否能保证可控?这是NumPy的Bug吗?还是相关对象用了引用计数,但手动调用垃圾回收器也无法回收?
内存追踪测试代码
下面是添加了tracemalloc逻辑的测试程序,每调用10万次get_view就打印内存分配情况:
import numpy as np import tracemalloc import itertools import gc def log_diff(snapshot, prev_snapshot): diff = snapshot.compare_to(prev_snapshot, "lineno") reported = 0 for stat in diff: if "tracemalloc.py" in stat.traceback[0].filename: continue if stat.size_diff <= 0: continue print(f"#{reported}: {stat}") reported += 1 print("---") def get_view(arr): view = arr.view() view.flags.writeable = False # 这行代码会导致内存增长? return view def main(): tracemalloc.start() prev_snapshot = None for i in itertools.count(): get_view(np.zeros(1000)) if i % 100000 == 0: gc.collect(generation=2) snapshot = tracemalloc.take_snapshot() if prev_snapshot is not None: log_diff(snapshot, prev_snapshot) prev_snapshot = snapshot if __name__ == "__main__": main()
测试环境与观察结果
在Linux系统、Python 3.11.6、NumPy 1.26.4环境下,内存分配的数量看起来不稳定,但观察到的最大增长约为250次,前期增长较快,后期增速大幅放缓。
如果注释掉设置view.flags.writeable的代码行,内存占用不会增长。
tracemalloc输出结果:
#0: /home/sami/bug.py:22: size=3534 B (+3477 B), count=62 (+61), average=57 B #1: /home/sami/bug.py:29: size=84 B (+28 B), count=2 (+1), average=42 B --- #0: /home/sami/bug.py:22: size=5871 B (+2337 B), count=103 (+41), average=57 B #1: /home/sami/bug.py:15: size=72 B (+72 B), count=1 (+1), average=72 B --- --- #0: /home/sami/bug.py:22: size=6270 B (+399 B), count=110 (+7), average=57 B --- #0: /home/sami/bug.py:22: size=6327 B (+57 B), count=111 (+1), average=57 B --- #0: /home/sami/bug.py:22: size=7638 B (+1311 B), count=134 (+23), average=57 B --- #0: /home/sami/bug.py:22: size=7809 B (+171 B), count=137 (+3), average=57 B --- --- #0: /home/sami/bug.py:22: size=8436 B (+627 B), count=148 (+11), average=57 B --- #0: /home/sami/bug.py:22: size=8664 B (+228 B), count=152 (+4), average=57 B --- #0: /home/sami/bug.py:22: size=8892 B (+228 B), count=156 (+4), average=57 B --- --- #0: /home/sami/bug.py:22: size=9120 B (+228 B), count=160 (+4), average=57 B --- --- #0: /home/sami/bug.py:22: size=9177 B (+114 B), count=161 (+2), average=57 B --- ...
问题解答
1. 为什么会出现内存增长?
NumPy的数组视图默认和原数组共享底层的数据缓冲区。当你尝试将视图的writeable标志设为False时,NumPy不能直接修改原数组的可写属性——否则会影响其他共享该缓冲区的视图或原数组。因此,NumPy会创建一个新的只读数组包装对象,这个对象仍然共享原数据缓冲区,但拥有独立的标志位,标记为不可写。
在循环代码中,每次调用get_view都会生成这个只读包装对象,原数组会被该包装对象引用。虽然函数执行完后包装对象和原数组都应该被垃圾回收,但由于Python的分代GC机制,或是NumPy内部的临时缓存/对象复用逻辑,这些对象的回收会有延迟,导致短时间内内存出现增长。
2. 内存增长是否可控?这是Bug吗?
这种内存增长是可控的,不是NumPy的Bug。从tracemalloc的输出可以看到,内存增长到一定程度后就会大幅放缓,最终趋于稳定。原因如下:
- Python的垃圾回收器最终会回收这些延迟处理的对象,尤其是手动调用
gc.collect(generation=2)后,大部分未被回收的对象会被清理。 - NumPy内部可能会复用一些只读视图的内部结构,避免重复创建大量相同的对象,从而限制内存增长的上限。
手动调用GC无法立即回收所有对象,是因为部分对象可能还处于年轻代,或是被临时的内部引用持有,但这些引用最终会被释放,对象也会被回收,不会出现无限的内存泄漏。
内容的提问来源于stack exchange,提问作者Sami Liedes

