среда, 18 декабря 2019 г.

Исследование утечек памяти в Golang с помощью pprof

В этом посте я коснусь особенностей того, как исследовать утечку памяти в Go, подробно описав шаги, предпринятые, чтобы найти, понять и устранить ее.

Набор инструментов, предлагаемый Golang, является исключительным, но имеет свои ограничения. В первую очередь следует отметить ограниченную способность исследовать полные дампы ядра. Полный дамп ядра - это образ памяти (или пользовательской памяти), занятый процессом, выполняющим программу.

Мы можем представить отображение памяти в виде дерева, и обход этого дерева проведет нас через различные распределения объектов и отношений. Это означает, что все, что находится в корне, является причиной для "удержания" памяти, а не для ее сбора (сбора мусора). Поскольку в Go нет простого способа проанализировать полный дамп ядра, добраться до корней объекта, который не собирается сборщиком мусора, сложно.

Утечки памяти

Утечки памяти или давление памяти (memory pressure) могут проявляться во многих формах во всей системе. Обычно мы обращаемся к ним как к ошибкам, но иногда их коренная причина может заключаться в проектных решениях.

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

  • Слишком много распределений (allocations), неправильное представление данных
  • Интенсивное использование отражения (reflection) или строк (strings)
  • Использование глобальных переменных (globals)
  • Осиротевшие, бесконечные горутины (goroutines)

В Go самый простой способ создать утечку памяти - определить глобальную переменную, массив и добавить данные в этот массив.

Однако обычно программы имеют более сложную структуру и намного больше кода, поэтому найти в них утечку становится сложнее.

Golang дает нам удивительный инструмент под названием pprof. Этот инструмент, когда освоен, может помочь в расследовании и в обнаружении проблем с памятью. Еще одна его цель - исследовать проблемы с процессором, но в этом посте мы не будем вдаваться в подробности, связанные с процессором.

go tool pprof

Пакет pprof создает файл дампа кучи (heap), который вы можете позже проанализировать/визуализировать, чтобы получить карту:

  • Текущих распределений памяти
  • Общего (накопительного) распределения памяти

У инструмента есть возможность сравнивать снимки. Это может позволить вам сравнить отображение разницы во времени того, что произошло прямо сейчас и 30 секунд назад, например. Для стрессовых сценариев это может быть полезно для определения проблемных областей вашего кода.

Профили pprof

Pprof работает с использованием профилей.

Профиль - это набор трассировок стека, показывающих последовательности вызовов, которые привели к появлению определенного события, такого как распределение (allocation).

Файл runtime/pprof/pprof.go содержит подробную информацию и реализацию профилей.

Go имеет несколько встроенных профилей, которые мы можем использовать в обычных случаях:

  • goroutine - стек трассировок всех текущих goroutines
  • heap - выборка выделений памяти живых объектов
  • allocs - выборка всех прошлых распределений памяти
  • threadcreate - стек трассировок, которые привели к созданию новых потоков ОС
  • block - стек трассировок, которые привели к блокировке примитивов синхронизации
  • mutex - стек трассировок держателей конфликтующих мьютексов

При рассмотрении проблем с памятью мы сосредоточимся на профиле heap. Профиль allocs идентичен в отношении сбора данных, который он делает. Разница между ними заключается в том, как инструмент pprof читает там во время запуска. Профиль allocs запустит pprof в режиме, который отображает общее количество байтов, выделенных с момента запуска программы (включая байты, собранные мусором). Обычно мы будем использовать этот режим при попытке сделать наш код более эффективным.

Куча (Heap)

Абстрактно, куча (heap) - это где OS (Операционная система) хранит память объектов, которые использует наш код. Это память, которую впоследствии получает "сборщик мусора" или освобождается вручную на языках без сборщика мусора.

Куча - не единственное место, где происходит выделение памяти, часть памяти также выделяется в стеке. Цель стека краткосрочная. В Go стек обычно используется для присваиваний, которые происходят внутри замыкания функции. Другое место, где Go использует стек, - это когда компилятор "знает", сколько памяти необходимо зарезервировать до времени выполнения (например, для массивов фиксированного размера). Есть способ запустить компилятор Go, чтобы он выводил анализ того, где выделения "уходят" из стека в кучу, но в этом посте мы не будем затрагивать это.

В то время как данные кучи должны быть "освобождены" и скопированы, данные стека не нужны. Это означает, что гораздо эффективнее использовать стек там, где это возможно.

Это абстрактное резюме различных мест, где происходит выделение памяти.

Получение данных кучи (heap) с помощью pprof

Существует два основных способа получения данных для этого инструмента. Первый обычно является частью теста или ветви и включает импорт runtime/pprof и затем вызов pprof.WriteHeapProfile(some_file) для записи информации кучи.

Обратите внимание, что WriteHeapProfile является синтаксическим сахаром для запуска:

// Lookup принимает имя профиля
pprof.Lookup("heap").WriteTo(some_file, 0)

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

Второй, более интересный, - включить его через HTTP (веб ендпоинты). Это позволяет извлекать данные из запущенного контейнера в вашей тестовой среде или даже из production среды. Вам нужно добавить в ваш код следующее:

import (  
    "net/http"  
    _ "net/http/pprof"
)
...
func main() {  
    ...  
    http.ListenAndServe("localhost:8080", nil)
}

"Побочным эффектом" импорта net/http/pprof является регистрация конечных точек pprof в корневом каталоге веб-сервера в /debug/pprof. Теперь, используя curl, мы можем получить файлы с информацией для исследования:

curl -sK -v http://localhost:8080/debug/pprof/heap > heap.out

Добавление http.ListenAndServe() выше требуется только в том случае, если ваша программа ранее не имела прослушивателя http. Если он у вас есть, он зацепит его, и вам не нужно снова слушать. Существуют также способы настроить его с помощью ServeMux.HandleFunc(), который более понятен для более сложной программы с поддержкой http. Например:

router := mux.NewRouter()
router.PathPrefix("/debug/pprof/").Handler(http.DefaultServeMux)

Использование pprof

Итак, мы собрали данные, что теперь? Как упоминалось выше, есть две основные стратегии анализа памяти с помощью pprof. Один из них заключается в рассмотрении текущих распределений (байтов или количества объектов), называемых inuse. Другой просматривает все выделенные байты или количество объектов во время выполнения программы, называемой alloc, независимо от того, были ли они собраны сборщиком мусора, то есть суммирование всех выборок.

Это хорошее место, чтобы повторить, что профиль heap является выборкой распределения памяти. За кулисами pprof использует функцию runtime.MemProfile, которая по умолчанию собирает информацию о распределении на каждые 512 КБ выделенных байтов. Можно изменить MemProfile для сбора информации обо всех объектах. Обратите внимание, что, скорее всего, это замедлит работу вашего приложения.

Это означает, что по умолчанию есть некоторый шанс, что проблема может возникнуть с более мелкими объектами, которые попадут под радар pprof. Для большой кодовой базы/долго работающей программы это не проблема.

Как только мы собрали файл профиля, пришло время загрузить его в интерактивную консоль pprof. Сделайте это, запустив:

> go tool pprof heap.out

Давайте посмотрим на отображаемую информацию

Type: inuse_spaceTime: Jan 22, 2019 at 1:08pm (IST)Entering interactive mode (type "help" for commands, "o" for options)(pprof)

Здесь важно отметить Type: inuse_space. Это означает, что мы смотрим на данные о распределении определенного момента (когда мы захватили профиль). Type является значением конфигурации sample_index, и возможными значениями являются:

  • inuse_space - объем памяти, выделенной и еще не освобожденной
  • inuse_objects - количество объектов, выделенных и еще не освобожденных
  • alloc_space - общий объем выделенной памяти (независимо от освобождения)
  • alloc_objects - общее количество объектов, выделенных (независимо от освобожденных)

Теперь введите top в интерактивном режиме, будет вывод наибольших потребителей памяти.

Мы можем видеть строку, рассказывающую нам о Dropped Nodes, это означает, что они отфильтрованы. Узел (Node) - это запись объекта или "узел" в дереве. Удаление узлов - это хорошая идея, чтобы уменьшить шум, но иногда это может скрывать основную причину проблемы с памятью. Мы увидим пример этого, продолжая наше расследование.

Если вы хотите включить все данные профиля, добавьте опцию -nodefraction = 0 при запуске pprof или введите nodefraction = 0 в интерактивном режиме.

В выводимом списке мы видим два значения, flat и cum.

  • flat означает память, выделенную этой функцией и удерживаемой этой функцией
  • cum означает, что память была выделена этой функцией или функцией, вызванной стеком

Одна только эта информация может иногда помочь нам понять, есть ли проблема. Возьмем, к примеру, случай, когда функция отвечает за выделение большого объема памяти, но не удерживает ее. Это будет означать, что какой-то другой объект указывает на эту память и сохраняет ее выделенной, то есть у нас может быть проблема с дизайном системы или ошибка.

Еще одна хитрость в верхней части интерактивного окна заключается в том, что он на самом деле работает в top10. Команда top поддерживает формат topN, где N - количество записей, которые вы хотите увидеть.

Визуализация

В то время как topN предоставляет текстовый список, есть несколько очень полезных опций визуализации, которые поставляются с pprof. Можно набрать png или gif и многое другое (полный список см. go tool pprof -help).

В нашей системе визуальный вывод по умолчанию выглядит примерно так:

Поначалу это может пугать, но это визуализация потоков выделения памяти (в соответствии с трассировкой стека) в программе. Чтение графа не так сложно, как кажется. Белый квадрат с номером показывает выделенное пространство (и совокупный объем памяти, который он занимает прямо сейчас на краю графа), а каждый более широкий прямоугольник показывает функцию выделения.

Обратите внимание, что на изображении выше png с режима выполнения inuse_space. Много раз вы также должны взглянуть на inuse_objects, так как это может помочь в поиске проблем с распределением.

Копать глубже, находить первопричину

До сих пор мы смогли понять, что выделяет память в нашем приложении во время выполнения. Это помогает нам понять, как наша программа ведет себя.

В нашем случае мы могли видеть, что память сохраняется membuffer'ами, которые являются нашей библиотекой сериализации данных. Это не означает, что у нас есть утечка памяти в этом сегменте кода, это означает, что память удерживается этой функцией. Важно понимать, как читать граф и вывод pprof в целом. В этом случае, понимая, что когда мы сериализуем данные, что означает, что мы выделяем память для структур и примитивных объектов (int, string), они никогда не освобождаются.

Поспешив с выводами или неверно истолковав граф, мы могли бы предположить, что один из узлов на пути к сериализации отвечает за сохранение памяти, например:

Где-то в цепочке мы видим нашу библиотеку журналов, отвечающую за >50 МБ выделенной памяти. Это память, которая выделяется функциями, вызываемыми нашим логгером. Это на самом деле ожидаемо. Логгер вызывает выделение памяти, поскольку ему необходимо сериализовать данные для вывода их в журнал и, следовательно, он вызывает выделение памяти в процессе.

Мы также можем видеть, что на пути выделения памяти память сохраняется только при сериализации и больше нигде. Кроме того, объем памяти, сохраняемой логгером, составляет около 30% от общего объема. Вышесказанное говорит нам, что, скорее всего, проблема не в логгере. Если бы это было 100%, или что-то близкое к этому, то мы должны были бы искать там - но это не так. Это может означать, что логируется что-то, чего не должно быть, но это не утечка памяти логгером.

Самое время представить еще одну команду pprof, которая называется list. Она принимает регулярное выражение, которое будет фильтровать то, что перечислить. "Список" (list) в действительности представляет собой аннотированный исходный код, связанный с распределением. В контексте логгера, который мы рассматриваем, мы выполним list RequestNew, так как мы хотим видеть вызовы, сделанные в логгере. Эти вызовы поступают из двух функций, которые начинаются с одного и того же префикса.

Мы можем видеть, что сделанные распределения находятся в столбце cum, что означает, что выделенная память сохраняется в стеке вызовов. Это соответствует тому, что граф также показывает. В этот момент легко увидеть, что причина, по которой логгер выделял память, заключается в том, что мы отправили ему весь "block" объект. Нужно было как минимум сериализовать некоторые его части (наши объекты являются объектами-оболочками, которые всегда реализуют некоторую функцию String()). Это полезное сообщение в журнале или хорошая практика? Вероятно, нет, но это не утечка памяти, не на стороне логгера или кода, который вызвал логгер.

list может найти исходный код при поиске в вашей среде GOPATH. В случаях, когда корень, который он ищет, не совпадает, что зависит от вашей машины сборки, вы можете использовать опцию -trim_path. Это поможет исправить его и позволить увидеть аннотированный исходный код. Не забудьте установить свой git на правильный коммит, который работал, когда был захвачен профиль кучи.

Так почему память сохраняется?

Подоплекой этого расследования стало подозрение, что у нас проблема - утечка памяти. Мы пришли к этому понятию, поскольку увидели, что потребление памяти было выше, чем то, что мы ожидаем от системы. Кроме того, мы видели, что оно постоянно увеличивается, что является еще одним сильным показателем для "здесь проблемы".

На этом этапе, в случае Java или .Net, мы бы открыли некоторый анализатор или профилировщик "gc root" и добрались до реального объекта, который ссылается на эти данные и создает утечку. Как объяснено, это не совсем возможно с Go, как из-за проблем с инструментами, так и из-за низкоуровнего представления памяти в Go.

Не вдаваясь в детали, мы не думаем, что Go сохраняет какой объект хранится по какому адресу (за исключением, возможно, указателей). Это означает, что в действительности для понимания того, какой адрес памяти представляет какой элемент вашего объекта (структуры), потребуется какое-то отображение на выход профиля кучи. Говоря о теории, это может означать, что перед выполнением полного дампа ядра следует также взять профиль кучи, чтобы адреса могли быть сопоставлены с выделенной строкой и файлом и, таким образом, объектом, представленным в памяти.

На данный момент, поскольку мы знакомы с нашей системой, было легко понять, что это больше не ошибка. Это было (почти) по замыслу. Но давайте продолжим исследовать, как получить информацию из инструментов (pprof), чтобы найти основную причину.

При установке nodefraction = 0 мы увидим всю карту выделенных объектов, включая меньшие. Давайте посмотрим на результат:

У нас есть два новых поддерева. Напомним еще раз, pprof профиль heap - это выборка памяти. Для нашей системы, которая работает - мы не пропускаем никакой важной информации. Более длинное новое дерево зеленого цвета, которое полностью отсоединено от остальной системы, является тестовым прогоном, это не интересно.

Более короткое, синего цвета, у которого есть ребро, соединяющее ее со всей системой, - inMemoryBlockPersistance. Это имя также объясняет "утечку", которую мы себе представили. Это серверная часть данных, которая хранит все данные в памяти и не сохраняется на диске. Приятно отметить, что мы сразу увидели, что в нем находятся два больших объекта. Почему два? Поскольку мы видим, что размер объекта составляет 1,28 МБ, а функция сохраняет 2,57 МБ, то есть два из них.

Проблема понятна на данный момент. Мы могли бы использовать delve (отладчик), чтобы увидеть, что это массив, содержащий все блоки для in-memory persistence драйвера, который у нас есть.

Пользовательский интерфейс pprof

Еще одна деталь, о которой нужно знать, когда дело доходит до pprof, - это его интерфейс. Это может сэкономить много времени при начале расследования любой проблемы, связанной с профилем, созданным с помощью pprof.

go tool pprof -http=:8080 heap.out

В этот момент он должен открыть веб-браузер. Если это не так, перейдите к порту, на котором вы его установили. Это позволяет вам изменять параметры и получать визуальную обратную связь намного быстрее, чем вы можете из командной строки. Очень полезный способ потреблять информацию.

Для просмотра графов в системе должна быть установлена graphviz. Для установки например на Ubuntu: sudo apt-get install graphviz

Вывод

Go - это захватывающий язык с очень богатым набором инструментов, с pprof вы можете сделать гораздо больше.


Читайте также: