Я работал с Go большую часть года, внедряя масштабируемую инфраструктуру блокчейна в Orbs, и это был захватывающий год. В течение 2018 года мы исследовали, какой язык выбрать для нашей реализации блокчейна. Это побудило нас выбрать Go, потому что мы понимали, что у него хорошее сообщество и потрясающий набор инструментов.

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

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

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

На момент написания мы не смогли найти в Интернете какой-либо инструмент, который мог бы помочь нам в этом. Поскольку существует формат дампа ядра и достаточно простой способ экспортировать его из пакета отладки, возможно, он уже используется в Google. При поиске в Интернете кажется, что он находится в конвейере Golang, создается программа для просмотра дампа ядра, но не похоже, что кто-то работает над этим. Сказав это, даже без доступа к такому решению, с помощью существующих инструментов мы обычно можем добраться до первопричины.

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

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

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

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

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

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

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

иди инструмент pprof

Чтобы охватить все, что делает этот инструмент, потребуется более одного сообщения в блоге. Одна вещь, которая потребовала времени, - это выяснить, как использовать этот инструмент, чтобы получить что-то действенное. Я сконцентрирую этот пост на его особенностях, связанных с памятью.

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

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

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

профили pprof

Принцип работы pprof заключается в использовании профилей.

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

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

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

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

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

Куча

Говоря абстрактно, именно здесь ОС (операционная система) хранит память объектов, которые использует наш код. Это память, которая позже собирается «сборщиком мусора» или освобождается вручную на языках, не использующих сборщик мусора.

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

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

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

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

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

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

// lookup takes a profile name
pprof.Lookup("heap").WriteTo(some_file, 0)

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

Второй, который более интересен, - включить его через HTTP (конечные точки на базе Интернета). Это позволяет вам извлекать данные adhoc, из работающего контейнера в вашей среде e2e / test или даже из производственной. Это еще одно место, где среда выполнения Go и набор инструментов выделяются. Вся документация пакета находится здесь, но TL; DR вам нужно будет добавить в свой код как таковой:

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.

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

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

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

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

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

> go tool pprof heap.out

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Копаем глубже, ищем первопричину

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Итак, что мы могли исправить?

Что ж, это отстой, это была человеческая ошибка. В то время как процесс был образовательным (и делиться заботой), нам не стало лучше, или нет?

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

Чтобы изучить аннотированный исходный код функции, мы запустим list lazy. Мы используем lazy, так как имя функции, которую мы ищем, - lazyCalcOffsets(), и мы не знаем, что другие функции в нашем коде начинаются с lazy. Конечно, можно набрать list lazyCalcOffsets.

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

Далее мы видим, что make () занимает некоторую память. В этом есть смысл, это указатель на структуру данных. Но мы также видим, что назначение в строке 43 занимает память, то есть создает выделение.

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

Следующее следует воспринимать с недоверием: было бы нормально сказать, что использование map[int]T, когда данные не являются разреженными или могут быть преобразованы в последовательные индексы, обычно следует пытаться с реализацией среза, если потребление памяти является релевантным. рассмотрение. Тем не менее, большой фрагмент при расширении может замедлить операцию, тогда как на карте это замедление будет незначительным. Не существует волшебной формулы для оптимизации.

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

Вот так просто: вместо карты мы теперь используем срез. Из-за того, как мы получаем данные, которые загружаются лениво, и как мы позже получаем доступ к этим данным, кроме этих двух строк и структуры, содержащей эти данные, никаких других изменений кода не требовалось. Как это повлияло на потребление памяти?

Давайте посмотрим на benchcmp всего пару тестов

Тесты чтения инициализируют структуру данных, которая создает выделения. Мы видим, что время выполнения улучшилось примерно на 30%, выделения уменьшились на 50%, а потребление памяти на ›90% (!)

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

Снова посмотрев на pprof и взяв профиль кучи из того же теста, мы увидим, что теперь потребление памяти фактически снизилось примерно на 90%.

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

Полный дамп ядра

Как уже упоминалось, именно здесь мы сейчас видим самые большие ограничения, связанные с инструментами. Когда мы исследовали эту проблему, мы были одержимы возможностью без особого успеха добраться до корневого объекта. Go со временем развивается быстрыми темпами, но это развитие имеет свою цену в случае полного дампа или представления памяти. Формат полного дампа кучи по мере его изменения не имеет обратной совместимости. Последняя версия описана здесь и для записи полного дампа кучи можно использовать debug.WriteHeapDump().

Хотя сейчас мы не застреваем, потому что нет хорошего решения для изучения полных дампов. pprof ответил на все наши вопросы до сих пор.

Обратите внимание, Интернет запоминает много информации, которая больше не актуальна. Вот некоторые вещи, которые вам следует игнорировать, если вы собираетесь попытаться открыть полный дамп самостоятельно, начиная с версии 1.11:

  • Нет возможности открыть и отладить полный дамп ядра на MacOS, только Linux.
  • Инструменты на https://github.com/randall77/hprof предназначены для Go1.3, существует форк для 1.7+, но он также не работает должным образом (неполный).
  • viewcore на https://github.com/golang/debug/tree/master/cmd/viewcore на самом деле не компилируется. Это достаточно легко исправить (внутренние пакеты указывают на golang.org, а не на github.com), но, это тоже не работает, не на MacOS, возможно, на Linux.
  • Также https://github.com/randall77/corelib не работает на MacOS.

pprof UI

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

go tool pprof -http=:8080 heap.out

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

Пользовательский интерфейс фактически познакомил меня с графиками пламени, которые очень быстро выявляют виновные области кода.

Заключение

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

Еще несколько хороших книг: