【发布时间】:2015-02-23 14:36:32
【问题描述】:
我在 Python3.2 中遇到了与 bytes 相关的内存管理问题。在某些情况下,ob_sval 缓冲区似乎包含我无法解释的内存。
对于特定的安全应用程序,我需要能够确保内存“归零”并在不再使用后尽快返回到操作系统。由于重新编译 Python 并不是一个真正的选择,我正在编写一个可以与 LD_PRELOAD 一起使用的模块:
- 通过将
PyObject_Malloc替换为PyMem_Malloc、将PyObject_Realloc替换为PyMem_Realloc并将PyObject_Free替换为PyMem_Free来禁用内存池(例如:如果在不使用WITH_PYMALLOC的情况下编译会得到什么)。我真的不在乎内存是否被池化,但这似乎是最简单的方法。 - 包装
malloc、realloc和free,以便跟踪请求了多少内存,并在释放时跟踪memset到0的所有内容。
粗略一看,这种方法似乎效果很好:
>>> from ctypes import string_at
>>> from sys import getsizeof
>>> from binascii import hexlify
>>> a = b"Hello, World!"; addr = id(a); size = getsizeof(a)
>>> print(string_at(addr, size))
b'\x01\x00\x00\x00\xd4j\xb2x\r\x00\x00\x00<J\xf6\x0eHello, World!\x00'
>>> del a
>>> print(string_at(addr, size))
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x13\x00'
最后错误的\x13 很奇怪,但不是来自我的原始值,所以起初我认为它没问题。我很快就找到了一些不太好的例子:
>>> a = b'Superkaliphragilisticexpialidocious'; addr = id(a); size = getsizeof(a)
>>> print(string_at(addr, size))
b'\x01\x00\x00\x00\xd4j\xb2x#\x00\x00\x00\x9cb;\xc2Superkaliphragilisticexpialidocious\x00'
>>> del s
>>> print(string_at(addr, size))
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00))\n\x00\x00ous\x00'
这里最后三个字节 ous 幸存下来。
所以,我的问题:
bytes 对象的剩余字节是怎么回事,为什么在调用 del 时它们不被删除?
我猜我的方法缺少类似于realloc 的内容,但我看不出bytesobject.c 中的内容。
我试图量化垃圾回收后剩余的“剩余”字节数,这在某种程度上似乎是可预测的。
from collections import defaultdict
from ctypes import string_at
import gc
import os
from sys import getsizeof
def get_random_bytes(length=16):
return os.urandom(length)
def test_different_bytes_lengths():
rc = defaultdict(list)
for ii in range(1, 101):
while True:
value = get_random_bytes(ii)
if b'\x00' not in value:
break
check = [b for b in value]
addr = id(value)
size = getsizeof(value)
del value
gc.collect()
garbage = string_at(addr, size)[16:-1]
for jj in range(ii, 0, -1):
if garbage.endswith(bytes(bytearray(check[-jj:]))):
# for bytes of length ii, tail of length jj found
rc[jj].append(ii)
break
return {k: len(v) for k, v in rc.items()}, dict(rc)
# The runs all look something like this (there is some variation):
# ({1: 2, 2: 2, 3: 81}, {1: [1, 13], 2: [2, 14], 3: [3, 4, 5, 6, 7, 8, 9, 10, 11, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 83, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100]})
# That is:
# - One byte left over twice (always when the original bytes object was of lengths 1 or 13, the first is likely because of the internal 'characters' list kept by Python)
# - Two bytes left over twice (always when the original bytes object was of lengths 2 or 14)
# - Three bytes left over in most other cases (the exact ones varies between runs but never has '12' in it)
# For added fun, if I replace the get_random_bytes call with one that returns an encoded string or random alphanumerics then results change slightly: lengths of 13 and 14 are now fully cleared too. My original test string was 13 bytes of encoded alphanumerics, of course!
编辑 1
我最初表示担心如果在函数中使用 bytes 对象,它根本不会被清理:
>>> def hello_forever():
... a = b"Hello, World!"; addr = id(a); size = getsizeof(a)
... print(string_at(addr, size))
... del a
... print(string_at(addr, size))
... gc.collect()
... print(string_at(addr, size))
... return addr, size
...
>>> addr, size = hello_forever()
b'\x02\x00\x00\x00\xd4J0x\r\x00\x00\x00<J\xf6\x0eHello, World!\x00'
b'\x01\x00\x00\x00\xd4J0x\r\x00\x00\x00<J\xf6\x0eHello, World!\x00'
b'\x01\x00\x00\x00\xd4J0x\r\x00\x00\x00<J\xf6\x0eHello, World!\x00'
>>> print(string_at(addr, size))
b'\x01\x00\x00\x00\xd4J0x\r\x00\x00\x00<J\xf6\x0eHello, World!\x00'
事实证明,这是我的要求未涵盖的人为问题。您可以查看该问题的 cmets 以了解详细信息,但问题来自 hello_forever.__code__.co_consts 元组将包含对 Hello, World! 的引用,即使在 a 从 locals 中删除之后也是如此。
在实际代码中,“安全”值将来自外部来源,永远不会像这样被硬编码和随后删除。
编辑 2
我还对strings 的行为表示困惑。有人指出,在将它们硬编码在函数中(例如:我的测试代码的工件)方面,它们也可能遇到与 bytes 相同的问题。它们还有另外两个风险,我无法证明这是一个问题,但会继续调查:
- Python 会在各个点执行字符串实习以加快访问速度。这应该不是问题,因为当最后一个引用丢失时,应该删除实习字符串。如果它被证明是一个问题,应该可以替换
PyUnicode_InternInPlace,这样它就不会做任何事情。 - Python 中的字符串和其他“原始”对象类型通常保留一个“空闲列表”,以便更快地为新对象获取内存。如果这被证明是一个问题,可以替换
Objects/*.c中的*_dealloc方法。
我还认为我看到了类实例未正确归零的问题,但我现在认为这是我的错误。
谢谢
非常感谢@Dunes 和@Kevin 指出了混淆我最初问题的问题。这些问题已留在上方的“编辑”部分以供参考。
【问题讨论】:
-
Python 可能正在对字符串进行实习。
-
Python 肯定会在此处实习字符串,它们保存在函数的常量列表中 --
hello_forever.__code__.co_consts。 -
您是否考虑过更改
_Py_Dealloc或Py_DECREF宏以在释放后将内存归零?而不是搞乱内存分配。 -
@Dunes:我不熟悉自动实习;我将再看看这些宏,看看我是否能让它们工作。乍一看,它看起来并不乐观,因为我之前的笔记表明 Py_DECREF -> _Py_Dealloc -> tp_dealloc -> object_dealloc -> tp_free -> PyObject_Del -> PyObject_Free -> PyMem_FREE -> free(例如:如果调用 Py_DECREF 则内存应该已归零)。不过,我很可能错过了链条上的一些东西。
-
我有点错过了你说重新编译不是一种选择的观点。此外,即使是相同类型的对象也可以有不同的大小,而查找对象大小的最简单和最干净的方法似乎是拦截对 malloc 的调用。也就是说,我认为你目前的方法是最好的。虽然我认为如果你可以重新编译 python,这会容易得多。
标签: python memory memory-pool