Python зависает бесконечно, пытаясь удалить глубоко рекурсивный объект - PullRequest
9 голосов
/ 21 мая 2019

Я написал троичное дерево поиска в 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()

Это печатает «Это печатается», но не «Это не печатается». Попытка удалить объект вручную имеет тот же эффект. Если я уменьшу количество вставленных строк с 27 миллионов до 25 миллионов, все будет хорошо - 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, хотя количество строк, необходимых для зависания, отличается (27 миллионов было там, где это произошло для обеих версий). Требуемая память на тот момент составляет около 80 гигабайт, и для печати первого оператора требуется 45 минут. Версия, которую я на самом деле использую, написана на cython, что уменьшает память и время выполнения, но сталкивается с той же проблемой.

Использование объекта работает по назначению, даже если вставлен миллиард строк. Сохранение объекта живым (возвращение его из функции или помещение его в глобальные переменные ()) откладывает проблему до выхода из Python - так что, по крайней мере, я могу убедиться, что вся работа выполнена в этот момент, но мне бы очень хотелось знать, что происходит здесь не так.

Редактировать: Я установил python через conda (4.6.2), и я нахожусь на узле 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 ]

6 голосов
/ 22 мая 2019

Обновление

В отчете об ошибке запуск на гигантской машине показал, что время восстановления хранилища дерева сократилось с почти 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, который предлагает «исправить это» путем полного удаления запросов.Он отлично работает в моем тестовом случае меньшего размера, в 10 раз, но у меня нет доступа к машине с достаточным объемом оперативной памяти для запуска оригинала.Поэтому я жду кого-то, кто это сделает, прежде чем объединить PR (кто знает? Там может быть более чем одним недостатком дизайна "огромного количества объектов").

Оригинальный ответ

Спасибо за хорошую работу по предоставлению исполняемого образца, воспроизводящего вашу проблему!Увы, я не могу запустить его - требует гораздо больше памяти, чем у меня.Если сократить число строк в десять раз, я получу около 100 000 000 Node экземпляров в 8 ГБ ОЗУ, и сборке мусора потребуется около 45 секунд, чтобы разрушить дерево (Python 3.7.3).Так что я предполагаю, что у вас есть около миллиарда Node экземпляров.

Я ожидаю, что вы не получите ответов, потому что здесь нет "общей проблемы", известной здесь, и она требует такой здоровенной машины, чтобы даже попробовать ее,Список рассылки python-dev может быть лучше задать или открыть вопрос по https://bugs.python.org.

Обычная причина очень медленного сбора мусора в конце цикла - это то, что память выгружается на диски затем "в обычном" порядке считывание объектов в ОЗУ происходит в тысячи раз дольше, чем обычно.Я предполагаю, что здесь не происходит.Если это так, то загрузка ЦП обычно падает почти до 0, поскольку процесс тратит большую часть своего времени на ожидание чтения с диска.

Реже, в реализации malloc / free базовой библиотеки C обнаруживается какой-то плохой шаблон.Но это также кажется маловероятным, поскольку эти объекты настолько малы, что Python запрашивает у C только «большие куски» оперативной памяти и разбивает их на части.

Так что я не знаю.Поскольку ничто не может быть исключено, вы также должны сообщить подробности об используемой ОС и о том, как был построен 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-devlist , и один человек пока ответил лично:

Я начал это, используя Python 3.7.3 |упаковано в conda-forge |(по умолчанию, 27 марта 2019 г., 23:01:00) [GCC 7.3.0] :: Anaconda, Inc. на linux

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

Требуется ~ 80 ГБ ОЗУи несколько часов, но не застряли.

Так что, если не появится кто-то еще, кто может воспроизвести его, нам, вероятно, здесь не повезло.По крайней мере, вам нужно больше информации о том, какую именно ОС вы используете, и как был построен Python.

4 голосов
/ 23 мая 2019

Не могли бы вы попробовать перекомпилировать Python?

В obmalloc.c есть макрос ARENA_SIZE, определяемый как:

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

Это значение по умолчанию не оптимизировано для очень больших систем памяти.

Ваш сценарий занимает много времени для сортировки арен по количеству свободных пулов в нем.Это может быть O (N ^ 2) в худшем случае, когда на многих аренах одинаковое количество свободных пулов.

Ваш скрипт освобождает блоки памяти в случайном порядке, он близок к наихудшему случаю.

N - количество арен здесь.Когда вы изменяете ARENA_SIZE на (1024 << 10), размер арены составляет 4x, N становится 1/4, а N ^ 2 становится 1/16.


Если вы не можете перекомпилировать Python, вы можетеиспользуйте malloc вместо pymalloc.

$ PYTHONMALLOC=malloc python3 yourscript.py

Вы можете переопределить malloc с помощью jemalloc или tcmalloc, используя LD_PRELOAD переменную окружения.

...