[Python, Программирование] Ультимативный гайд по поиску утечек памяти в Python

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
10-Дек-2020 13:31

Практика показывает, что в современном мире Docker-контейнеров и оркестраторов (Kubernetes, Nomad, etc) проблема с утечкой памяти может быть обнаружена не при локальной разработке, а в ходе нагрузочного тестирования, или даже в production-среде. В этой статье рассмотрим:
  • Причины появления утечек в Python-приложениях.
  • Доступные инструменты для отладки и мониторинга работающего приложения.
  • Общую методику поиска утечек памяти.
У нас есть много фреймворков и технологий, которые уже «из коробки» работают замечательно, что усыпляет бдительность. В итоге иногда тревога поднимается спустя некоторое время после проблемного релиза, когда на мониторинге появляется примерно такая картина:
Утечки плохи не только тем, что приложение начинает потреблять больше памяти. С большой вероятностью также будет наблюдаться снижение работоспособности, потому что GC придется обрабатывать всё больше и больше объектов, а аллокатору Python — чаще выделять память для новых объектов.Заранее предупрежу, что рассмотренные методы отладки приложения крайне не рекомендованы для использования в production-среде. Область их применения сводится к ситуациям:
  • Есть подозрение на утечку памяти. Причина абсолютно непонятна и проявляется при production-сценариях/нагрузках.
  • Мы разворачиваем приложение в тестовой среде и даем тестовый трафик, аналогичный тому, при котором появляется утечка.
  • Смотрим, какие объекты создаются, и почему память не отдается операционной системе.
Глобально утечка может произойти в следующих местах:
  • Код на Python. Здесь всё просто: создаются объекты в куче, которые не могут быть удалены из-за наличия ссылок.
  • Подключаемые библиотеки на других языках (C/C++, Rust, etc). Утечку в сторонних библиотеках искать гораздо сложнее, чем в коде на Python. Но методика есть, и мы ее рассмотрим.
  • Интерпретатор Python. Эти случаи редки, но возможны. Их стоит рассматривать, если остальные методы диагностики не дали результата.
Подключение к работающему приложению
  • PDB — старый добрый Python Debugger, о котором стали забывать из-за красивого интерфейса для отладки в современных IDE. На мой взгляд, для поиска утечек памяти крайне неудобен.
  • aiomonitor. Отличное решение для асинхронных приложений. Запускается в отдельной корутине и позволяет подключиться к работающему приложению с помощью NetCat. Предоставляет доступ к полноценному интерпретатору без блокировки основного приложения.
  • pyrasite. Запускается в отдельном процессе, и также как aiomonitor не блокирует и не останавливает основной поток, — можно смотреть текущее состояние переменных и памяти. Для работы pyrasite требуется установленный gdb. Это накладывает ограничения на использование, например, в Docker — требуется запуск контейнера с привилегированными правами и включение ptrace.
Утечки памяти: большие объектыЭто самые простые утечки памяти, потому что большие объекты очень легко отфильтровать. Для поиска будем использовать pympler и отладку через aiomonitor. Запустим в первом окне терминала main.py:
import tracemalloc
tracemalloc.start()
from aiohttp import web
import asyncio
import random
import logging
import sys
import aiomonitor
logger = logging.getLogger(__name__)
async def leaking(app):
    """
    Стартап утекающей корутины
    """
    stop = asyncio.Event()
    async def leaking_coro():
        """
        Утекающая корутина
        """
        data = []
        i = 0
        logger.info('Leaking: start')
        while not stop.is_set():
            i += 1
            try:
                return await asyncio.wait_for(stop.wait(), timeout=1)
            except asyncio.TimeoutError:
                pass
            # ЗДЕСЬ БУДЕМ УТЕКАТЬ!
            data.append('hi' * random.randint(10_000, 20_000))
            if i % 2 == 0:
                logger.info('Current size = %s', sys.getsizeof(data))
    leaking_future = asyncio.ensure_future(asyncio.shield(leaking_coro()))
    yield
    stop.set()
if __name__ == '__main__':
    logging.basicConfig(level=logging.INFO)
    loop = asyncio.get_event_loop()
    with aiomonitor.start_monitor(loop=loop):
        app = web.Application()
        app.cleanup_ctx.append(leaking)
        web.run_app(app, port=8000)
И подключимся к нему во втором:
nc 127.0.0.1 50101
Asyncio Monitor: 2 tasks running
Type help for available commands
monitor >>> console
Нас интересует отсортированный дамп объектов GC:
>>> from pympler import muppy
>>> all_objects = muppy.get_objects()
>>> top_10 = muppy.sort(all_objects)[-10:]
>>> top1 = top_10[0]
Мы можем убедиться, что самым большим объектом является наша добавляемая строка:
>>> type(top1)
<class 'str'>
Забавный факт: вызов pprint выводит информацию не в терминальную сессию aiomonitor, а в исходный скрипт. В то время как обычный print ведет себя наоборот.Теперь возникает вопрос: как же понять, где этот объект был создан? Вы наверняка заметили запуск tracemalloc в самом начале файла, — он нам и поможет:
>>> import  tracemalloc
>>> tb = tracemalloc.get_object_traceback(top1)
>>> tb.format()
['  File "main.py", line 41', "    data.append('hi' * random.randint(10_000, 20_000))"]
Просто и изящно! Для корректной работы tracemalloc должен быть запущен перед любыми другими импортами и командами. Также его можно запустить с помощью флага -X tracemalloc или установки переменной окружения PYTHONTRACEMALLOC=1 (подробнее: https://docs.python.org/3/library/tracemalloc.html). Чуть ниже мы рассмотрим другие полезные функции tracemalloc.Утечки памяти: много маленьких объектовПредставим, что в нашей программе начал утекать бесконечный связный список: много однотипных маленьких объектов. Попробуем отыскать утечку такого рода.
import tracemalloc
tracemalloc.start()
import asyncio
root = {
    'prev': None,
    'next': None,
    'id': 0
}
async def leaking_func():
    current = root
    n = 0
    while True:
        n += 1
        _next = {
            'prev': current,
            'next': None,
            'id': n
        }
        current['next'] = _next
        current = _next
        await asyncio.sleep(0.1)
if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    with aiomonitor.start_monitor(loop=loop):
        loop.run_until_complete(leaking_func())
Как и в прошлом примере, подключимся к работающему приложению, но для поиска маленьких объектов будем использовать objgraph:
>>> import objgraph
>>> objgraph.show_growth()
function                       6790     +6790
dict                           3559     +3559
tuple                          2676     +2676
list                           2246     +2246
weakref                        1635     +1635
wrapper_descriptor             1283     +1283
getset_descriptor              1150     +1150
method_descriptor              1128     +1128
builtin_function_or_method     1103     +1103
type                            949      +949
Во время первого запуск objgraph посчитает все объекты в куче. Дальнейшие вызовы будут показывать только новые объекты. Попробуем вызвать еще раз:
>>> objgraph.show_growth()
dict     3642       +30
Итак, у нас создается и не удаляется много новых маленьких объектов. Ситуацию усложняет то, что эти объекты имеют очень распространенный тип dict. Вызовем несколько раз функцию get_new_ids с небольшим интервалом:
>>> items = objgraph.get_new_ids()['dict']
>>> # Ждем некоторое время
>>> items = objgraph.get_new_ids()['dict']
>>> items
{4381574400, 4381574720, 4380522368, … }
Посмотрим на созданные объекты более пристально:
>>> from pprint import pprint
>>> # Получим объекты по их id
>>> objects = objgraph.at_addrs(items)
>>> pprint(objects, depth=2)
[{'id': 1077, 'next': {...}, 'prev': {...}},
{'id': 864, 'next': {...}, 'prev': {...}},
{'id': 865, 'next': {...}, 'prev': {...}},
{'id': 866, 'next': {...}, 'prev': {...}},
…]
На данном этапе мы уже можем понять, что это за объекты. Но если утечка происходит в сторонней библиотеке, то наша жизнь усложняется. Посмотрим с помощью вспомогательной функции, какие места в программе наиболее активно выделяют память:
def take_snapshot(prev=None, limit=10):
    res = tracemalloc.take_snapshot()
    res = res.filter_traces([
        tracemalloc.Filter(False, tracemalloc.__file__),
    ])
    if prev is None:
        return res
    st = res.compare_to(prev, 'lineno')
    for stat in st[:limit]:
        print(stat)
    return res
>>> sn = take_snapshot()
>>> # Немного подождем перед вторым вызовом
>>> sn = take_snapshot(sn):
/Users/saborisov/Work/debug_memory_leak/main.py:25 size=27.8 KiB (+27.8 KiB), count=230 (+230), average=124 B
...
Мы явно видим подозрительное место, на которое следует взглянуть более пристально.Я бы хотел обратить внимание, что за кадром осталась основная функциональность библиотеки objgraph: рисование графов связей объектов. Пожалуйста, попробуйте его, это фантастический инструмент для поиска хитрых утечек! С помощью визуализации ссылок на объект можно быстро понять, где именно осталась неудаленная ссылка.Сторонние C-ExtensionsЭто наиболее тяжелый в расследовании тип утечек памяти, потому что GC работает только с PyObject. Если утекает код на C, отследить это с помощью кода на Python невозможно. Искать утечки в сторонних библиотеках следует, если:
  • Основная куча объектов Python не растет (с помощью objgraph и pympler не удается найти утечки памяти).
  • Общая память приложения на Python продолжает бесконтрольно расти.
Для тестирования создадим небольшой модуль на Cython (cython_leak.pyx):
from libc.stdlib cimport malloc
cdef class PySquareArray:
    cdef int *_thisptr
    cdef int _size
    def __cinit__(self, int n):
        # Класс, который создает массив квадратов заданного размера
        cdef int i
        self._size = n
        self._thisptr = <int*>malloc(n * sizeof(int))
        for i in range(n):
            self._thisptr[i] = i * i
    def __iter__(self):
        cdef int i
        for i in range(self._size):
            yield self._thisptr[i]
И установочный файл (setup.py):
from setuptools import setup
from Cython.Build import cythonize
setup(
    name='Hello world app',
    ext_modules=cythonize("cython_leak.pyx"),
    zip_safe=False,
)
Запустим сборку: python setup.py build_ext --inplaceИ сделаем скрипт для тестирования утечки (test_cython_leak.py):
from cython_leak import PySquareArray
import random
while True:
    a = PySquareArray(random.randint(10000, 20000))
    for v in a:
        pass
Кажется, все объекты должны корректно создаваться и удаляться. На практике график работы скрипта выглядит примерно так:
Попробуем разобраться в причине с помощью Valgrind. Для этого нам понадобится suppression-файл и отключение Python-аллокатора:
PYTHONMALLOC=malloc valgrind --tool=memcheck --leak-check=full python3 test_cython_leak.py
После некоторого времени работы можно посмотреть отчет (нас интересуют блоки definitely lost):
==4765== 79,440 bytes in 1 blocks are definitely lost in loss record 3,351 of 3,352
==4765==    at 0x483B7F3: malloc (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so)
==4765==    by 0x544D13C: __pyx_pf_11cython_leak_13PySquareArray___cinit__ (cython_leak.c:1420)
==4765==    by 0x544D13C: __pyx_pw_11cython_leak_13PySquareArray_1__cinit__ (cython_leak.c:1388)
==4765==    by 0x544D13C: __pyx_tp_new_11cython_leak_PySquareArray (cython_leak.c:1724)
Здесь указан наш класс PySquareArray и утекающая функция cinit. Детали можно изучить в скомпилированном файле cython_leak.c. В чем же причина утечки? Конечно, в отсутствии деструктора:
from libc.stdlib cimport malloc, free
   ...
   def __dealloc__(self):
        free(self._thisptr)
После повторной компиляции и запуска можно увидеть абсолютно корректную работу приложения:
ЗаключениеЯ бы хотел отметить, что в компании мы считаем нагрузочное тестирование приложения с контролем потребления памяти одним из ключевых Quality Gate перед релизом. Я надеюсь, что этот гайд поможет вам быстрее и проще находить утечки памяти в своих приложениях
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_python, #_programmirovanie (Программирование), #_python, #_python3, #_memory, #_valgrind, #_leak, #_blog_kompanii_domklik (
Блог компании ДомКлик
)
, #_python, #_programmirovanie (
Программирование
)
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 08:16
Часовой пояс: UTC + 5