多处理-线程池内存泄漏?

时间:2018-07-10 19:46:03

标签: python memory-management memory-leaks multiprocessing threadpool

我正在观察无法解释的内存使用情况。下面,我提供了我的实际代码的精简版,但仍然表现出此行为。该代码旨在完成以下任务:

读取1000行的文本文件。每行是一个句子。将这1000个句子拆分为4个生成器。将这些生成器传递到线程池,并以250个句子并行运行特征提取。 在我的实际代码中,我从整个文件的所有句子中累积了功能和标签。 现在出现了奇怪的事情:分配了内存,但是即使不累积这些值也不会再次释放!它与我认为的线程池有关。总共占用的内存量取决于为任何给定单词提取多少个特征。我在这里用range(100)模拟。看看:

from sys import argv
from itertools import chain, islice
from multiprocessing import Pool
from math import ceil


# dummyfied feature extraction function
# the lengt of the range determines howmuch mamory is used up in total,
# eventhough the objects are never stored
def features_from_sentence(sentence):
    return [{'some feature'  'some value'} for i in range(100)], ['some label' for i in range(100)]


# split iterable into generator of generators of length `size`
def chunks(iterable, size=10):
    iterator = iter(iterable)
    for first in iterator:
        yield chain([first], islice(iterator, size - 1))


def features_from_sentence_meta(l):
    return list(map (features_from_sentence, l))


def make_X_and_Y_sets(sentences, i):
    print(f'start: {i}')
    pool = Pool()
    # split sentences into a generator of 4 generators
    sentence_chunks = chunks(sentences, ceil(50000/4))
    # results is a list containing the lists of pairs of X and Y of all chunks
    results = map(lambda x : x[0], pool.map(features_from_sentence_meta, sentence_chunks))
    X, Y = zip(*results)
    print(f'end: {i}')
    return X, Y


# reads file in chunks of `lines_per_chunk` lines
def line_chunks(textfile, lines_per_chunk=1000):
    chunk = []
    i = 0
    with open(textfile, 'r') as textfile:
        for line in textfile:
            if not line.split(): continue
            i+=1
            chunk.append(line.strip())
            if i == lines_per_chunk:
                yield chunk
                i = 0
                chunk = []
        yield chunk

textfile = argv[1]

for i, line_chunk in enumerate(line_chunks(textfile)):
    # stop processing file after 10 chunks to demonstrate
    # that memory stays occupied (check your system monitor)
    if i == 10:
        while True:
            pass
    X_chunk, Y_chunk = make_X_and_Y_sets(line_chunk, i)

我用来调试的文件有50000非空行,这就是为什么我在一个地方使用硬编码50000的原因。如果您想使用相同的文件,他是您的链接,方便您使用:

https://www.dropbox.com/s/v7nxb7vrrjim349/de_wiki_50000_lines?dl=0

现在,当您运行此脚本并打开系统监视器时,您将观察到内存已用完,并且使用情况一直持续到第10个块,在此我故意进入一个无休止的循环,以证明内存仍在使用,甚至虽然我从不存储任何东西。

您能告诉我为什么会这样吗?我似乎不知道应该如何使用多处理池。

1 个答案:

答案 0 :(得分:3)

首先,让我们澄清一些误会-尽管事实证明,这实际上并不是首先探索的正确途径。

当您使用Python分配内存时,当然必须从操作系统中获取该内存。

但是,释放内存时,它很少会返回操作系统,直到最终退出。相反,它进入一个“空闲列表”,或者实际上是出于不同目的的多个级别的空闲列表。这意味着,下次需要内存时,Python便已将其放置在内存中,并且可以立即找到它,而无需与OS进行对话来分配更多内存。通常,这会使占用大量内存的程序更快。

但这还意味着,尤其是在现代64位操作系统上,请通过查看活动监视器/任务管理器/等来尝试了解您是否确实存在任何内存压力问题。几乎是无用的。


标准库中的tracemalloc模块提供了低级工具,以查看内存使用情况到底发生了什么。在更高级别上,您可以使用类似memory_profiler的东西(如果启用了tracemalloc支持,这很重要)可以将这些信息与来自psutil这样的来源的操作系统级别信息放在一起。弄清楚事情的发展方向。

但是,如果您没有看到任何实际的问题-您的系统没有进入交换地狱,您没有遇到任何MemoryError异常,那么您的性能就不会遇到任何奇怪的问题线性上升到N,然后突然在N + 1处全部死,以此类推-通常,您一开始就不必理会这些。


如果您确实发现问题,那么幸运的是,您已经解决了一半。正如我在顶部提到的那样,您分配的大多数内存只有在最终退出后才返回到操作系统。但是,如果所有内存使用情况都发生在子进程中,并且这些子进程没有状态,则可以使它们退出并在需要时重新启动。

当然,这样做会提高性能,包括进程拆除和启动时间,以及必须重新开始的页面映射和缓存,以及要求OS重新分配内存等等。而且还存在复杂性成本–您不能只运行一个池并让它做它的事;您必须介入它的工作,并为您进行回收过程。

multiprocessing.Pool类中没有内置的支持。

您当然可以构建自己的Pool。如果您想花哨的话,可以查看multiprocessing的源代码并进行操作。或者,您可以从Process个对象列表和一对Queue对列表中构建一个琐碎的池。或者,您可以直接使用Process对象而无需抽象池。


内存问题的另一个原因是您的各个进程都很好,但是您的进程太多了。

事实上,这里似乎就是这种情况。

您可以在此函数中创建一个由4个工作人员组成的Pool

def make_X_and_Y_sets(sentences, i):
    print(f'start: {i}')
    pool = Pool()
    # ...

…,然后为每个块调用此函数:

for i, line_chunk in enumerate(line_chunks(textfile)):
    # ...
    X_chunk, Y_chunk = make_X_and_Y_sets(line_chunk, i)

因此,最终每个块都有4个新进程。即使每个人的内存使用量都很低,一次也要拥有数百个这样的内存会加起来。

更不用说您可能因为数百个进程争用4个内核而严重损害了时间性能,因此您浪费了时间进行上下文切换和OS调度,而不是进行实际工作。

正如您在评论中指出的那样,此问题的解决方法是微不足道的:只需创建一个全局pool即可,而不是为每个呼叫创建一个新的全局{}。


很抱歉,所有Columbo都在这里,但是……仅此而已……此代码在模块的顶层运行:

for i, line_chunk in enumerate(line_chunks(textfile)):
    # ...
    X_chunk, Y_chunk = make_X_and_Y_sets(line_chunk, i)

…这就是试图加速缓冲池和所有子任务的代码。但是该池中的每个子进程都需要import这个模块,这意味着它们都将最终运行相同的代码,并旋转另一个池和一组额外的子任务。

大概是在Linux或macOS上运行的,默认startmethodfork,这意味着multiprocessing可以避免使用此import,因此您不必有一个问题。但是,与其他启动方法一样,此代码基本上是一个占用您所有系统资源的前炸弹。其中包括spawn,这是Windows上的默认启动方法。因此,如果任何人都有可能在Windows上运行此代码,则应将所有这些顶级代码置于if __name__ == '__main__':保护中。