Tracemalloc python как включить

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

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

  • Причины появления утечек в Python-приложениях.
  • Доступные инструменты для отладки и мониторинга работающего приложения.
  • Общую методику поиска утечек памяти.

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

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

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

  • Есть подозрение на утечку памяти. Причина абсолютно непонятна и проявляется при production-сценариях/нагрузках.
  • Мы разворачиваем приложение в тестовой среде и даем тестовый трафик, аналогичный тому, при котором появляется утечка.
  • Смотрим, какие объекты создаются, и почему память не отдается операционной системе.

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

  • Код на Python. Здесь всё просто: создаются объекты в куче, которые не могут быть удалены из-за наличия ссылок.
  • Подключаемые библиотеки на других языках (C/C++, Rust, etc). Утечку в сторонних библиотеках искать гораздо сложнее, чем в коде на Python. Но методика есть, и мы ее рассмотрим.
  • Интерпретатор Python. Эти случаи редки, но возможны. Их стоит рассматривать, если остальные методы диагностики не дали результата.
Читайте также:  Java stream api тренажер

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

  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] 

Мы можем убедиться, что самым большим объектом является наша добавляемая строка:

Забавный факт: вызов 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

Посмотрим на созданные объекты более пристально:

>>> from pprint import pprint >>> # Получим объекты по их id >>> objects = objgraph.at_addrs(items) >>> pprint(objects, depth=2) [, '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 = 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 перед релизом. Я надеюсь, что этот гайд поможет вам быстрее и проще находить утечки памяти в своих приложениях

Источник

Оцените статью