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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Практика показывает, что в современном мире Docker-контейнеров и оркестраторов (Kubernetes, Nomad, etc) проблема с утечкой памяти может быть обнаружена не при локальной разработке, а в ходе нагрузочного тестирования, или даже в production-среде. В этой статье рассмотрим:

  • Причины появления утечек в Python-приложениях.

  • Доступные инструменты для отладки и мониторинга работающего приложения.

  • Общую методику поиска утечек памяти.

У нас есть много фреймворков и технологий, которые уже «из коробки» работают замечательно, что усыпляет бдительность. В итоге иногда тревога поднимается спустя некоторое время после проблемного релиза, когда на мониторинге появляется примерно такая картина:

Утечки плохи не только тем, что приложение начинает потреблять больше памяти. С большой вероятностью также будет наблюдаться снижение работоспособности, потому что GC придется обрабатывать всё больше и больше объектов, а аллокатору Python — чаще выделять память для новых объектов.

Заранее предупрежу, что рассмотренные методы отладки приложения крайне не рекомендованы для использования в production-среде. Область их применения сводится к ситуациям:

  • Есть подозрение на утечку памяти. Причина абсолютно непонятна и проявляется при production-сценариях/нагрузках.

  • Мы разворачиваем приложение в тестовой среде и даем тестовый трафик, аналогичный тому, при котором появляется утечка.

  • Смотрим, какие объекты создаются, и почему память не отдается операционной системе.

Глобально утечка может произойти в следующих местах:

  • Код на Python. Здесь всё просто: создаются объекты в куче, которые не могут быть удалены из-за наличия ссылок.

  • Подключаемые библиотеки на других языках (C/C++, Rust, etc). Утечку в сторонних библиотеках искать гораздо сложнее, чем в коде на Python. Но методика есть, и мы ее рассмотрим.

  • Интерпретатор Python. Эти случаи редки, но возможны. Их стоит рассматривать, если остальные методы диагностики не дали результата.

Подключение к работающему приложению

  1. PDB — старый добрый Python Debugger, о котором стали забывать из-за красивого интерфейса для отладки в современных IDE. На мой взгляд, для поиска утечек памяти крайне неудобен.

  2. aiomonitor. Отличное решение для асинхронных приложений. Запускается в отдельной корутине и позволяет подключиться к работающему приложению с помощью NetCat. Предоставляет доступ к полноценному интерпретатору без блокировки основного приложения.

  3. 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 перед релизом. Я надеюсь, что этот гайд поможет вам быстрее и проще находить утечки памяти в своих приложениях

Источник: https://habr.com/ru/company/domclick/blog/532030/


Интересные статьи

Интересные статьи

В этой статье описываются операции по тестированию клиентской части приложения с помощью TestProject и pytest, а также способы выполнения тестов через GitHub Actions...
Продолжаем тему музыкального программирования — ранее мы говорили о языках Csound, SuperCollider и Pure Data, а сегодня рассказываем Python и библиотеках FoxDot, Pippi и Music-Code. ...
Python — это язык, который любят многие программисты. Этим языком невероятно легко пользоваться. Всё дело в том, что код, написанный на Python, отличается интуитивной понятностью и хорошей читабе...
Материал, перевод которого мы сегодня публикуем, посвящён инструментам, которые позволяют оснащать Python-проекты средствами форматирования кода, тестирования, непрерывной интеграции и анализа за...
Основанная в 1998 году компания «Битрикс» заявила о себе в 2001 году, запустив первый в России интернет-магазин программного обеспечения Softkey.ru.