[Python, Программирование] Ультимативный гайд по поиску утечек памяти в Python
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Практика показывает, что в современном мире 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
===========
Похожие новости:
- [Программирование, Проектирование и рефакторинг] Универсальная архитектура приложений
- [Программирование, Разработка мобильных приложений, Разработка под Android, Kotlin] Практическое руководство по использованию Hilt с Kotlin (перевод)
- [Программирование, C++] Введение в регулярные выражения в современном C++ (перевод)
- [Программирование, Разработка игр] Семь талантливых стажеров AI@Unity 2020. Часть 2 (перевод)
- [Python, Математика] PuLP-MiA: Мультииндексный аддон для PuLP (python-библиотека линейного программирования)
- [Глобальные системы позиционирования, Программирование, Компиляторы, Lua, Робототехника] Umka и трактор: первый опыт практического применения нового языка
- [JavaScript, Интерфейсы, ReactJS, TypeScript] Использование Effector в стеке React + TypeScript
- [Программирование, Отладка] Intel ControlFlag — система машинной отладки человеческих ошибок кода
- [Python, DevOps, Микросервисы] Конфигурируем сервис с помощью Vault и Pydantic
- [Программирование] Связные списки, трюки с указателями и хороший вкус (перевод)
Теги для поиска: #_python, #_programmirovanie (Программирование), #_python, #_python3, #_memory, #_valgrind, #_leak, #_blog_kompanii_domklik (
Блог компании ДомКлик
), #_python, #_programmirovanie (
Программирование
)
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 02:00
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Практика показывает, что в современном мире Docker-контейнеров и оркестраторов (Kubernetes, Nomad, etc) проблема с утечкой памяти может быть обнаружена не при локальной разработке, а в ходе нагрузочного тестирования, или даже в production-среде. В этой статье рассмотрим:
Утечки плохи не только тем, что приложение начинает потреблять больше памяти. С большой вероятностью также будет наблюдаться снижение работоспособности, потому что GC придется обрабатывать всё больше и больше объектов, а аллокатору Python — чаще выделять память для новых объектов.Заранее предупрежу, что рассмотренные методы отладки приложения крайне не рекомендованы для использования в production-среде. Область их применения сводится к ситуациям:
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 >>> from pympler import muppy
>>> all_objects = muppy.get_objects() >>> top_10 = muppy.sort(all_objects)[-10:] >>> top1 = top_10[0] >>> type(top1)
<class 'str'> >>> import tracemalloc
>>> tb = tracemalloc.get_object_traceback(top1) >>> tb.format() [' File "main.py", line 41', " data.append('hi' * random.randint(10_000, 20_000))"] 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()) >>> 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.show_growth()
dict 3642 +30 >>> 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 ...
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] from setuptools import setup
from Cython.Build import cythonize setup( name='Hello world app', ext_modules=cythonize("cython_leak.pyx"), zip_safe=False, ) 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
==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) from libc.stdlib cimport malloc, free
... def __dealloc__(self): free(self._thisptr) ЗаключениеЯ бы хотел отметить, что в компании мы считаем нагрузочное тестирование приложения с контролем потребления памяти одним из ключевых Quality Gate перед релизом. Я надеюсь, что этот гайд поможет вам быстрее и проще находить утечки памяти в своих приложениях =========== Источник: habr.com =========== Похожие новости:
Блог компании ДомКлик ), #_python, #_programmirovanie ( Программирование ) |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 02:00
Часовой пояс: UTC + 5