Python无限期挂起,尝试删除深度递归对象

时间:2019-05-20 22:09:49

标签: python memory memory-management tree recursive-datastructures

我用Python编写了一个三元搜索树,我注意到当该树变得很深时,尝试删除它会导致Python无限期挂起。这是产生此行为的代码的剥离版本:

import random
import sys
from collections import deque


class Node():
    __slots__ = ("char", "count", "lo", "eq", "hi")

    def __init__(self, char):
        self.char = char
        self.count = 0

        self.lo = None
        self.eq = None
        self.hi = None


class TernarySearchTree():
    """Ternary search tree that stores counts for n-grams
    and their subsequences.
    """

    def __init__(self, splitchar=None):
        self.root = None
        self.splitchar = splitchar

    def insert(self, string):
        self.root = self._insert(string, self.root)

    def _insert(self, string, node):
        """Insert string at a given node.
        """
        if not string:
            return node

        char, *rest = string

        if node is None:
            node = Node(char)

        if char == node.char:
            if not rest:
                node.count += 1
                return node
            else:
                if rest[0] == self.splitchar:
                    node.count += 1
                node.eq = self._insert(rest, node.eq)

        elif char < node.char:
            node.lo = self._insert(string, node.lo)

        else:
            node.hi = self._insert(string, node.hi)

        return node


def random_strings(num_strings):
    random.seed(2)
    symbols = "abcdefghijklmnopqrstuvwxyz"

    for i in range(num_strings):
        length = random.randint(5, 15)
        yield "".join(random.choices(symbols, k=length))


def train():
    tree = TernarySearchTree("#")
    grams = deque(maxlen=4)

    for token in random_strings(27_000_000):
        grams.append(token)
        tree.insert("#".join(grams))

    sys.stdout.write("This gets printed!\n")
    sys.stdout.flush()


def main():
    train()

    sys.stdout.write("This doesn't get printed\n")
    sys.stdout.flush()


if __name__ == "__main__":
    main()

这将打印出“此内容已打印”,但不会打印出“此内容未打印”。尝试手动删除对象具有相同的效果。如果我将插入的字符串的数量从2700万减少到2500万,那么一切都很好-Python会打印出两个语句,然后立即退出。我试图运行GDB,这是我得到的回溯:

#0  pymalloc_free.isra.0 (p=0x2ab537a4d580) at /tmp/build/80754af9/python_1546061345851/work/Objects/obmalloc.c:1797
#1  _PyObject_Free (ctx=<optimized out>, p=0x2ab537a4d580)
    at /tmp/build/80754af9/python_1546061345851/work/Objects/obmalloc.c:1834
#2  0x0000555555701c18 in subtype_dealloc ()
    at /tmp/build/80754af9/python_1546061345851/work/Objects/typeobject.c:1256
#3  0x0000555555738ce6 in _PyTrash_thread_destroy_chain ()
    at /tmp/build/80754af9/python_1546061345851/work/Objects/object.c:2212
#4  0x00005555556cd24f in frame_dealloc (f=<optimized out>)
    at /tmp/build/80754af9/python_1546061345851/work/Objects/frameobject.c:492
#5  function_code_fastcall (globals=<optimized out>, nargs=<optimized out>, args=<optimized out>, co=<optimized out>)
    at /tmp/build/80754af9/python_1546061345851/work/Objects/call.c:291
#6  _PyFunction_FastCallKeywords () at /tmp/build/80754af9/python_1546061345851/work/Objects/call.c:408
#7  0x00005555557241a6 in call_function (kwnames=0x0, oparg=<optimized out>, pp_stack=<synthetic pointer>)
    at /tmp/build/80754af9/python_1546061345851/work/Python/ceval.c:4616
#8  _PyEval_EvalFrameDefault () at /tmp/build/80754af9/python_1546061345851/work/Python/ceval.c:3124
#9  0x00005555556ccecb in function_code_fastcall (globals=<optimized out>, nargs=0, args=<optimized out>, 
    co=<optimized out>) at /tmp/build/80754af9/python_1546061345851/work/Objects/call.c:283
#10 _PyFunction_FastCallKeywords () at /tmp/build/80754af9/python_1546061345851/work/Objects/call.c:408
#11 0x00005555557241a6 in call_function (kwnames=0x0, oparg=<optimized out>, pp_stack=<synthetic pointer>)
    at /tmp/build/80754af9/python_1546061345851/work/Python/ceval.c:4616
#12 _PyEval_EvalFrameDefault () at /tmp/build/80754af9/python_1546061345851/work/Python/ceval.c:3124
#13 0x00005555556690d9 in _PyEval_EvalCodeWithName ()
    at /tmp/build/80754af9/python_1546061345851/work/Python/ceval.c:3930
#14 0x0000555555669fa4 in PyEval_EvalCodeEx () at /tmp/build/80754af9/python_1546061345851/work/Python/ceval.c:3959
#15 0x0000555555669fcc in PyEval_EvalCode (co=co@entry=0x2aaaaac08300, globals=globals@entry=0x2aaaaaba8168, 
    locals=locals@entry=0x2aaaaaba8168) at /tmp/build/80754af9/python_1546061345851/work/Python/ceval.c:524
#16 0x0000555555783664 in run_mod () at /tmp/build/80754af9/python_1546061345851/work/Python/pythonrun.c:1035
#17 0x000055555578d881 in PyRun_FileExFlags ()
    at /tmp/build/80754af9/python_1546061345851/work/Python/pythonrun.c:988
#18 0x000055555578da73 in PyRun_SimpleFileExFlags ()
    at /tmp/build/80754af9/python_1546061345851/work/Python/pythonrun.c:429
#19 0x000055555578db3d in PyRun_AnyFileExFlags ()
    at /tmp/build/80754af9/python_1546061345851/work/Python/pythonrun.c:84
#20 0x000055555578eb2f in pymain_run_file (p_cf=0x7fffffffd240, filename=0x5555558c5440 L"minimal.py", 
    fp=0x5555559059a0) at /tmp/build/80754af9/python_1546061345851/work/Modules/main.c:427
#21 pymain_run_filename (cf=0x7fffffffd240, pymain=0x7fffffffd350)
    at /tmp/build/80754af9/python_1546061345851/work/Modules/main.c:1627
#22 pymain_run_python (pymain=0x7fffffffd350) at /tmp/build/80754af9/python_1546061345851/work/Modules/main.c:2876
#23 pymain_main () at /tmp/build/80754af9/python_1546061345851/work/Modules/main.c:3037
#24 0x000055555578ec4c in _Py_UnixMain () at /tmp/build/80754af9/python_1546061345851/work/Modules/main.c:3072
#25 0x00002aaaaaf0d3d5 in __libc_start_main () from /lib64/libc.so.6
#26 0x0000555555733982 in _start () at ../sysdeps/x86_64/elf/start.S:103

如果我尝试从这一点开始逐步执​​行,则执行将在obmalloc.c中通过三行循环-GDB在1796-98行中说,但数字似乎已关闭,并且回溯中的文件(在/ tmp / )不存在。

在Python 3.7.3和3.6上都会发生这种情况,尽管引起挂断所需的字符串数量有所不同(两个版本的挂起发生在2700万)。此时所需的内存约为80 GB,并且需要45分钟才能打印出第一条语句。我实际使用的版本是用cython编写的,虽然减少了内存和运行时间,但是却遇到了同样的问题。

即使插入了十亿个字符串,使用该对象也可以按预期工作。使对象保持活动状态(将其从函数中返回或放到globals()中)将问题推迟到Python退出之前-至少我可以确保所有工作都在这一点上完成,但是我真的很想知道会发生什么在这里错了。

编辑:我通过conda(4.6.2)安装了python,并且我在linux服务器节点上:

> uname -a
Linux computingnodeX 3.10.0-862.14.4.el7.x86_64 #1 SMP Wed Sep 26 15:12:11 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux

2 个答案:

答案 0 :(得分:5)

更新

在错误报告中,在一台巨型计算机上运行表明,回收树存储的时间从将近5小时减少到大约70秒:

master:

build time 0:48:53.664428
teardown time 4:58:20.132930

patched:

build time 0:48:08.485639
teardown time 0:01:10.46670

(建议的修复程序)

这是针对CPython项目的pull request,该项目建议通过完全删除搜索来“解决此问题”。对于我的小10倍的测试用例,它可以正常工作,但是我无法访问具有足够RAM来运行原始计算机的机器。因此,我正在等待可以合并PR的人(谁知道?这里可能存在多个“大量对象”设计缺陷)。

原始回复

感谢您提供可再现您的问题的可执行示例的出色工作! las,我无法运行它-需要比我更多的内存。如果将字符串的数量减少十倍,最终将在大约8GB的内存中得到大约100,000,000个Node实例,并且垃圾回收需要大约45秒的时间才能拆除树(Python 3.7.3) )。因此,我猜测您大约有十亿个Node实例。

我希望您不会收到答复,因为这里没有已知的“一般性问题”,并且甚至需要这么大的机器才能尝试。 python-dev邮件列表可能是一个更好的询问场所,或在https://bugs.python.org上发布了一个问题。

运行结束时垃圾收集非常缓慢的常见原因是内存已换出到磁盘,然后以“随机”顺序将对象读回RAM所需的时间比“正常”长数千倍。 ,将其拆除。我假设在这里没有发生。如果是这样,CPU使用率通常会下降到接近于0,因为该过程将花费大部分时间等待磁盘读取。

通常,在底层C库的malloc / free实现中会遇到一些错误的模式。但这在这里似乎不太可能,因为这些对象足够小,以至于Python只向C请求RAM的“大块”并自行分割。

所以我不知道。由于无法排除一切,因此您还应该提供有关所使用的操作系统以及Python的构建方式的详细信息。

只是为了好玩,您可以尝试使用此方法来了解事物在停滞之前能走多远。首先将此方法添加到Node

def delete(self):
    global killed
    if self.lo:
        self.lo.delete()
        self.lo = None
    if self.eq:
        self.eq.delete()
        self.eq = None
    if self.hi:
        self.hi.delete()
        self.hi = None
    killed += 1
    if killed % 100000 == 0:
        print(f"{killed:,} deleted")

train()的末尾添加以下内容:

tree.root.delete()

然后将对main()的呼叫替换为:

killed = 0
main()
print(killed, "killed")

可能不会显示有趣的东西。

不想再找别人

我在python-dev mailing list上发布了有关此内容的注释,到目前为止,有一个人私下回答:

  

我使用Python 3.7.3开始了它|由conda-forge打包| (默认值,2019年3月27日,23:01:00)       [GCC 7.3.0] :: Linux上的Anaconda,Inc。

$ python fooz.py
This gets printed!
This doesn't get printed
  

它确实占用了约80 GB的RAM和几个小时,但没有卡住。

因此,除非弹出可以复制的人,否则我们在这里很不幸。您至少需要提供有关正在使用的操作系统以及Python如何构建的更多信息。

答案 1 :(得分:4)

您可以尝试重新编译Python吗?

在obmalloc.c中,有一个ARENA_SIZE宏定义为:

#define ARENA_SIZE              (256 << 10)     /* 256KB */

此默认值并未针对大型内存系统进行优化。

您的脚本需要很长时间才能根据其中的可用池数对竞技场进行排序。 最坏的情况是当多个竞技场具有相同数量的空闲池时,它可能是O(N ^ 2)。

您的脚本以随机顺序释放内存块,这几乎是最坏的情况。

N是这里的竞技场数量。当您将ARENA_SIZE更改为(1024 << 10)时, 竞技场的大小是4倍,N变成1/4,N ^ 2变成1/16。


如果无法重新编译Python,则可以使用malloc代替pymalloc。

$ PYTHONMALLOC=malloc python3 yourscript.py

您可以使用LD_PRELOAD环境变量使用jemalloc或tcmalloc覆盖malloc。