【问题标题】:Python3: Give unused interpreter memory back to the OSPython3:将未使用的解释器内存归还给操作系统
【发布时间】:2021-07-02 13:13:30
【问题描述】:

背景/基本原理

我有一个 Python 软件,它从测量仪器中获取基于事件的数据,对其进行处理并将结果写入磁盘。输入事件使用相当多的内存,接近 10MB/事件。当输入事件率很高时,处理可能不够快,导致事件堆积在内部队列中。这种情况一直持续到可用内存几乎用完,此时它会指示仪器限制采集速率(这有效,但会降低准确性)。通过使用psutil.virtual_memory().available 观察可用的系统内存来检测这一时刻。为了获得最佳结果,一旦从已处理的事件中获得足够的内存,就应该禁用限制。这就是问题所在。

看起来,CPython 解释器不(或不总是)将释放的内存返回给操作系统,这使得psutil(以及gnome-system-monitor)报告可用内存不足。但是,内存实际上是可用的,因为手动禁用限制将再次填满队列,而不会进一步增加消耗,除非将更多的事件放入队列中。

以下示例可能会显示此行为。在我的计算机上,可能有 50% 的调用显示了问题,而其余的则正确释放了内存。有几次内存在迭代 0 结束时被释放,但不是在迭代 1 和 2 结束时释放,所以行为似乎有点随机。

#!/usr/bin/env python3

import psutil
import time
import queue

import numpy as np

def get_avail() -> int:
    avail = psutil.virtual_memory().available
    print(f'Available memory: {avail/2**30:.2f} GiB')
    return avail

q: 'queue.SimpleQueue[np.ndarray]' = queue.SimpleQueue()

for i in range(3):
    print('Iteration', i)
    # Allocate data for 90% of available memory.
    for i_mat in range(round(0.9 * get_avail() / 2**24)):
        q.put(np.ones((2**24,), dtype=np.uint8))
    # Show remaining memory.
    get_avail()
    time.sleep(5)
    # The data is now processed, releasing the memory.
    try:
        n = 0
        while True:
            n += q.get_nowait().max()
    except queue.Empty:
        pass
    print('Result:', n)
    # Show remaining memory.
    get_avail()
    print(f'Iteration {i} ends')
    time.sleep(5)
print('Program done.')
get_avail()

预期的行为将是在打印结果之前可用内存不足,而打印后可用内存很高:

Iteration 0
Available memory: 22.24 GiB
Available memory: 2.17 GiB
Result: 1281
Available memory: 22.22 GiB
Iteration 0 ends

但是,它也可能以这样的方式结束:

Iteration 1
Available memory: 22.22 GiB
Available memory: 2.19 GiB
Result: 1280
Available memory: 2.36 GiB
Iteration 1 ends

集成对垃圾收集器的显式调用,例如

    print('Result:', n)
    # Show remaining memory.
    get_avail()
    gc.collect(0)
    gc.collect(1)
    gc.collect(2)
    get_avail()
    print(f'Iteration {i} ends')

没有帮助,内存可能仍在使用中。

我知道会有一些解决方法,例如检查队列大小而不是可用内存。但这会使系统更容易耗尽资源,以防其他进程碰巧消耗大量内存。使用multiprocessing 并不能解决问题,因为事件提取必须单线程完成,因此提取过程在其队列中总是会遇到同样的问题。

问题

  • 如何查询解释器的内存管理,以了解引用的对象使用了多少内存,以及有多少只是保留供将来使用而不返回给操作系统?

  • 如何强制解释器将保留的内存返还给操作系统,以便报告的可用内存实际增加?

目标平台是 Ubuntu 20.04+ 和 CPython 3.8+,不需要支持以前的版本或其他版本。

谢谢。

【问题讨论】:

  • 我认为您无法将内存“归还”给操作系统。一些完全未使用的页面可能会被调出,但除此之外,您会陷入困境,无法终止进程并开始新的进程。
  • 明确一点,垃圾收集器只需要打破引用循环;否则,Python 内存分配器可以回收的任何内存都是根据引用计数达到零的时间来完成的。不过,无论哪种方式,CPython 中的任何内容都不会将内存释放回操作系统。
  • 为什么要根据操作系统的可用内存来节流?如果 Python 仍然持有该内存,它将尽可能重用它,而不是向操作系统请求更多。
  • @Tom Karzes:确实有一些参考资料表明解释器永远不会将内存还给操作系统。但示例代码表明它可能会发生,但并非总是如此。
  • @chepner:需要限制以避免进程因内存不足而被终止。可用内存量用于决定何时进行节流,理想情况下,还用于决定何时返回全速采集。解释器内存当然会被重用,但是软件不知道这个保留的内存有多少是可用的。

标签: python python-3.x memory


【解决方案1】:

它不是 Python 或 NumPy

​​>

正如问题的 cmets 中已经指出的那样,观察到的效果并不特定于 Python(或 NumPy,因为内存实际上用于大型 ndarray)。相反,它是所使用的 C 运行时的一个特性,在本例中是 glibc。

堆和内存映射

当使用 malloc() 请求内存时(就像 NumPy 在分配数组时所做的那样),运行时决定是否应该将 brk 系统调用用于较小的块,或者使用 mmap 用于较大的块。 sbrk 用于增加堆大小。分配的堆空间可以返回给操作系统,但前提是堆顶部有足够的连续空间。这意味着恰好位于堆顶端的对象已经有几个字节可以有效地阻止进程将任何堆内存还给操作系统。内存没有被浪费,因为运行时将使用堆上的其他释放空间来后续调用malloc(),但内存仍由进程使用,因此在进程终止之前永远不会报告为可用。

通过mmap() 分配内存页面的效率较低,但它的好处是可以在不再需要时将这些页面还给操作系统。性能下降是因为每当映射或取消映射内存页面时都会涉及内核。特别是因为内核出于安全原因必须将映射页面归零。

mmap 阈值

malloc() 使用请求的内存量的阈值来决定它应该使用堆还是mmap()。这个阈值在最新版本的 glibc 中是动态的,但可以使用 mallopt() 函数更改:

M_MMAP_THRESHOLD

[...]

注意:现在,glibc 默认使用动态 mmap 阈值。阈值的初始值为128*1024,但是当大于当前阈值且小于等于DEFAULT_MMAP_THRESHOLD_MAX的块被释放时,阈值向上调整为被释放块的大小。当动态 mmap 阈值生效时,修剪堆的阈值也会动态调整为动态 mmap 阈值的两倍。如果设置了 M_TRIM_THRESHOLD、M_TOP_PAD、M_MMAP_THRESHOLD 或 M_MMAP_MAX 参数中的任何一个,则禁用 mmap 阈值的动态调整。

至少可以通过两种方式调整阈值:

  1. 调用mallopt()

  2. 通过设置环境变量MALLOC_MMAP_THRESHOLD_(注意结尾的下划线)。

应用于示例代码

该示例以 2**24 字节或 16MiB 的块分配(和取消分配)内存。根据理论,固定的MMAP_THRESHOLD 略低于此值应确保使用mmap() 分配所有大型数组,从而允许它们被取消映射并返回给操作系统。

首先运行没有修改:

$ ./test_mem.py 
Iteration 0
Available memory: 21.45 GiB
Available memory: 2.17 GiB
Result: 1235
Available memory: 21.50 GiB
Iteration 0 ends
Iteration 1
Available memory: 21.50 GiB
Available memory: 2.13 GiB
Result: 1238
Available memory: 3.95 GiB
Iteration 1 ends
Iteration 2
Available memory: 4.02 GiB
Available memory: 4.02 GiB
Result: 232
Available memory: 4.02 GiB
Iteration 2 ends
Program done.
Available memory: 4.02 GiB

在迭代 1 和 2 中不返回内存。

现在让我们设置一个 1MiB 的固定阈值:

$ MALLOC_MMAP_THRESHOLD_=1048576 ./test_mem.py 
Iteration 0
Available memory: 21.55 GiB
Available memory: 2.13 GiB
Result: 1241
Available memory: 21.52 GiB
Iteration 0 ends
Iteration 1
Available memory: 21.52 GiB
Available memory: 2.11 GiB
Result: 1240
Available memory: 21.52 GiB
Iteration 1 ends
Iteration 2
Available memory: 21.51 GiB
Available memory: 2.12 GiB
Result: 1239
Available memory: 21.53 GiB
Iteration 2 ends
Program done.
Available memory: 21.53 GiB

可以看出,内存在所有三个迭代中都成功归还给操作系统。 作为替代方案,也可以通过使用 ctypes 模块调用 mallopt() 将设置集成到 Python 脚本中:

#!/usr/bin/env python3

import ctypes
import psutil
import time
import queue

import numpy as np

libc = ctypes.cdll.LoadLibrary("libc.so.6")
M_MMAP_THRESHOLD = -3

# Set malloc mmap threshold.
libc.mallopt(M_MMAP_THRESHOLD, 2**20)

# ...

免责声明:这些解决方案/变通办法远非独立于平台,因为它们利用了特定的 glibc 功能。

注意

上面的文字主要回答了更重要的第二个问题“如何强制解释器将保留的内存还给操作系统,以便报告的可用内存实际上增加了?”。至于第一个问题“如何查询解释器的内存管理以找出被引用对象使用了多少内存以及仅保留多少以供将来使用而不返回给操作系统?”,我无法找到一个满意的答案。 malloc_stats():

libc = ctypes.cdll.LoadLibrary("libc.so.6")
# ... script here ...
libc.malloc_stats()

给出了一些数字,但这些结果:

Arena 0:
system bytes     = 1632264192
in use bytes     =    4629984
Total (incl. mmap):
system bytes     = 1632858112
in use bytes     =    5223904
max mmap regions =       1236
max mmap bytes   = 20725514240

对于脚本运行而不更改 mmap 阈值对我来说似乎有点混乱。 5MiB 可能是脚本结束时实际使用的内存,但是“系统字节”呢?该过程此时仍使用近 20GiB,因此指示的 1.6GiB 不知何故根本不适合图片。

另见

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2021-04-05
    • 2015-01-24
    • 2018-01-14
    • 2019-11-02
    • 2018-11-14
    相关资源
    最近更新 更多