Мы продолжаем наш рассказ о причинах повышенного потребления памяти в языке Go. В предыдущей статье мы детально разобрали ошибки бизнес-логики приложения, которые могут привести к утечкам памяти. Сегодня же сосредоточимся на особенностях рантайма языка Go.
Привет, Хабр! Меня зовут Виталий Исаев, я занимаюсь бэкенд-разработкой в компании МойОфис. При отладке утечек памяти в Go у программиста в какой-то момент может возникнуть ощущение тупика. Все тривиальные ошибки бизнес-логики проверены, но утечки продолжаются, и что дальше делать — непонятно. Это означает, что пора переходить к исследованию особенностей рантайма и того, как они проявляются в конкретно взятой программе, работающей под определённой нагрузкой.
Рантайм Go — сложная, постоянно развивающаяся конструкция, в которой непросто разобраться, но мы постараемся выработать общие рекомендации по решению проблем, связанных с повышенным потреблением памяти.
Система управления памятью в рантайме Go зиждется на трёх ключевых элементах:
Аллокатор. Хорошо известен по большому количеству публикаций и выступлений, где его оценивают в целом комплиментарно.
Сборщик мусора. Так же как и аллокатор, часто обсуждается на конференциях и тематических форумах. Подвергается постоянной критике со стороны адептов Java.
Scavenger. О нём говорят гораздо меньше, но это именно он, а не сборщик мусора, как можно было бы подумать, возвращает неиспользуемую память операционной системе.
У каждого из них есть особенности, незнание которых может привести к повышенному потреблению памяти.
Аллокатор
Прежде всего нам необходимо вспомнить, как современные операционные системы организуют работу с памятью. Процессы не имеют прямого доступа к физической памяти. Вместо этого каждый процесс пользуется абстракцией виртуального адресного пространства, разделенного на страницы размером в несколько килобайт. Процесс ничего не знает о том, где в действительности хранятся страницы его адресного пространства. Эти страницы могут находиться либо в оперативной памяти (RAM), либо на диске в SWAP, либо вообще нигде (например, если память выделена, но к ней пока никто не обращался). Трансляцию адресов виртуального адресного пространства в адреса физической памяти осуществляет специальное устройство — MMU. ОС предпринимает все возможные меры по экономии оперативной памяти, по этой причине размер виртуального адресного пространства (VMS) практически всегда больше, чем размер оперативной памяти (RSS), реально потребляемой процессом.
Когда процессу не хватает памяти, он может действовать двумя способами. Можно воспользоваться низкоуровневыми интерфейсами операционной системы (системные вызовы brk, sbrk, mmapp), которые увеличивают размер виртуального адресного пространства, либо более высокоуровневыми внешними аллокаторами, например, из состава стандартной библиотеки языка C (malloc). В Linux почти любая программа на Go динамически слинкована с libc, однако рантайм Go не использует функцию malloc
, но реализует свой собственный аллокатор.
Причина этого решения заключается во внешней фрагментации памяти. Представим, что у нас есть 10-байтный массив памяти, куда мы последовательно записываем 4-байтный объект и три 2-байтных. Затем мы удаляем два 2-байтных объекта. В итоге в массиве 4 байта свободны. Но если придёт, например, 3-байтный объект, то записать его уже не получится, так как в массиве нет непрерывного пространства памяти подобного объёма.
В современных языках программирования широко распространены два подхода к борьбе с фрагментацией.
Прежде всего, можно переложить эту задачу на GC, чтобы после сборки мусора он переместил все оставшиеся объекты так, чтобы они компактно лежали рядом в непрерывной области памяти. Такой подход применяется в Java, C#, Haskell и многих других языках. Но поскольку у перемещённых объектов меняются адреса, приходится ещё и модифицировать ссылки у тех объектов, которые на них ссылаются. Это дорогая операция, которая может вызвать увеличение времени отклика процесса.
Альтернативный путь заключается во внедрении таких оптимизаций в аллокатор, при которых фрагментация будет если не полностью устранимой, то как минимум предсказуемой. Go идёт именно этим путём, при этом за образец был взят аллокатор TCMalloc.
Когда рантайму Go не хватает памяти, он запрашивает у системы сразу целую арену. У арен платформозависимый размер — на современных Linux это 64 МБ. Выделение памяти аренами позволяет снизить количество системных вызовов и переключений контекста.
Каждая арена обладает довольно сложной внутренней структурой. Память арены разделена на 8Кб страницы, а уже страницы объединяются в спаны разных размеров — от 8 до 80 Кб.
Каждый спан выступает в роли пула памяти для объектов определённого класса размеров. Всего существует 67 классов размеров, и для каждого выделяются спаны определённой протяжённости.
Внутри каждого спана для хранения объектов выделяются ячейки фиксированного размера. Например, во втором классе размеров можно хранить объекты длиной от 9 до 16 байт.
// unsafe.Sizeof(A{}) == 16
type A struct {
x int64
y int64
}
// unsafe.Sizeof(B{}) == 9
type B struct {
x int64
y bool
}
16-байтная структура идеально подходит для данного спана, ни один байт не пропадает зря. Но если нам придётся сохранить 9-байтные объекты, то окажется, что в каждой ячейке 7 байт остаются пустыми, и при таком режиме в спане будет впустую израсходовано почти 44% памяти. Это явление называется внутренней фрагментацией.
В исходниках рантайма есть таблица с характеристиками каждого класса размеров. Интерес представляет последний столбец, в котором показан теоретически рассчитанный худший случай фрагментации для каждого класса размеров. Как видно, у небольших объектов могут наблюдаться очень серьёзные проблемы с фрагментацией — в крайнем случае почти 90% памяти может уходить впустую.
Эмпирическая оценка фрагментации памяти может быть вычислена с помощью формулы:
Если вы видите в своём мониторинге значения фрагментации, которые вас не устраивают, значит, пришла пора заняться оптимизациями. Учёт особенностей аллокатора при выборе типов данных может существенно улучшить картину фрагментации.
Сборщик мусора
Сборка мусора в Go организована с помощью трассирующего сборщика мусора. Он базируется на трёхцветном mark-and-sweep алгоритме, опубликованном Дейкстрой и Лемпортом в 1978 г. По современным меркам он считается слегка устаревшим, но в Go выбрали именно его, потому что он хорошо параллелится по ядрам. При этом GC Go действительно требует остановки мира (STW), но на совсем короткие промежутки времени.
В Go не стали заимствовать у Java концепцию поколений объектов. Это связано с тем, что в Go гораздо большая роль отводится аллокациям на стеке. Короткоживущие объекты с большой вероятностью будут размещены на стеке, а не в хипе, поэтому идея о молодом поколении объектов не находит своего применения. Наконец, по уже упомянутым причинам Go не проводит компактизацию с целью дефрагментации хипа: задача борьбы с фрагментацией переложена на плечи аллокатора, да и компактизация привела бы к ухудшению масштабирования рантайма по ядрам из-за более частых STW.
В распоряжении GC находится пул воркеров – горутин, которые работают конкурентно с горутинами бизнес-логики приложения. В настоящее время фракция CPU, потребляемая воркерами GC, жёстко зафиксирована на уровне 25%. Если GC понимает, что не справляется с потоком мусора, он может слегка притормозить новые аллокации, заставляя некоторые горутины тратить часть времени на сборку мусора. Это оптимизация называется Mark Assist и позволяет довести потребляемую GC фракцию CPU до 30%.
За запуск сборки мусора отвечает GC Pacer. Сборка происходит тогда, когда размер хипа вырастает на определённый процент по сравнению с размером, который наблюдался после последнего прохода GC. Этот процент задаётся с помощью переменной GOGC, которая является единственной доступной снаружи настройкой сборщика мусора. Чем интенсивнее растёт хип, тем чаще вызывается GC, а если хип не растёт, то и GC не вызывается. Логику GC Pacer можно описать в виде псевдокода:
func gcPacer() {
nextGC := uint64(someSmallConst)
for {
sizeNow := getHeapSize()
// если хип достаточно сильно вырос, запускаем GC
if sizeNow >= nextGC {
runGC()
// вычисляем целевое значение размера хипа,
// при котором GC должен будет сработать в следующий раз
sizeAfter := getHeapSize()
nextGC = sizeAfter * (1 + GOGC/100)
}
}
}
К сожалению, у такого механизма запуска GC есть недостаток. Представим, что наш процесс работает в виртуальной машине с установленным лимитом на RSS в 10 Гб. Для простоты примем, что RSS процесса примерно равен размеру хипа ( хотя мы уже знаем, что это не так из-за метаданных аллокатора и фрагментации памяти). Допустим, после последнего прохода GC размер хипа составил 5.1 Гб — вроде бы до лимита ещё далеко. Дальше берём дефолтное значение GOGC
= 100% и пользуемся приведённым выше уравнением для вычисления целевого значения размера хипа NextGC (10.2 Гб). Оказывается, что оно выше, чем лимит на RSS. Иными словами, прежде чем GC сработает в следующий раз, процесс будет убит OOM Killer'ом!
Лимиты потребления оперативной памяти
Итак, в числе прочих проблем GC Pacer совершенно не учитывает системные лимиты на потребление памяти, из-за чего легко может довести процесс до ООМ. Было бы здорово, чтобы GC Pacer каким-то образом узнал о них и не позволял процессу аллоцировать памяти больше, чем положено. В той же Java сборщик мусора это давным-давно умеет делать, однако создатели Go неоднократно подчёркивали отсутствие этой функциональности.
Что же должна делать программа, если память почти исчерпана? В реальной жизни многие разработчики стараются отодвинуть ООМ путём вертикального масштабирования. Но если засыпать проблему деньгами всё же не представляется возможным, стоит обратить внимание на универсальную стратегию экономии памяти, которая неоднократно проверялась в разных проектах, написанных на разных языках программирования.
Прежде всего, нужно освобождать больше памяти, то есть чаще запускать GC. В Go для этого нужно выбрать более консервативное значение GOGC. Однако ручной подбор GOGC под конкретный сервис и конкретную нагрузку — не очень весёлое занятие (особенно в микросервисной архитектуре). Тем более, что здесь можно перестараться и столкнуться с явлением, которое пользователям Java известно как "GC death spiral". Его причиной является уже упоминавшаяся ранее петля обратной связи. Для GC она выглядит так: приложению нужно больше памяти — GC запускается чаще — на GC тратится больше ресурсов CPU — пропускная способность приложения падает — запросы копятся в очередях — приложению нужно ещё больше памяти, и здесь круг замыкается.
Поэтому первую меру необходимо дополнить второй. Нужно заставить бизнес-логику приложения аллоцировать меньше памяти. Например, если в приложении есть кэши, можно уменьшить их размер, либо полностью их сбросить. Также можно реализовать механизм backpressure и начать троттлить какую-то часть клиентских запросов, чтобы переложить на клиента некоторую долю ответственности за то, что происходит на сервере.
Столкнувшись несколько раз с проблемой исчерпания оперативной памяти в нашем микросервисном бэкенде, мы приняли решение сделать автоматизированную систему контроля потребления памяти. Мы оформили её в виде отдельной библиотеки, которую назвали MemLimiter.
Чтобы понять принцип действия MemLimiter, можно представить себе конвейер, на котором постоянно переиспользуется какой-то ресурс, например, вода. Вода в баке — это оперативная память. Она расходуется на аллокации, а возвращается в бак посредством работы GC.
Этот процесс можно описать с помощью простого дифференциального уравнения:
где dM/dt — скорость изменения количества доступной для аллокаций памяти, GC — скорость сборки мусора, Alloc — скорость новых аллокаций.
MemLimiter вводит понятие бюджета оперативной памяти и постоянно следит за его утилизацией. Внутри MemLimiter работает П-регулятор, который пытается «вытолкнуть» приложение из опасной зоны: чем ближе приложение к потенциальному ООМ, тем ниже значения GOGC и/или выше процент запросов, отбитых из-за троттлинга. Когда утилизация бюджета памяти нормализуется, управляющие параметры системы возвращаются к значениям по умолчанию.
При этом MemLimiter спроектирован специально для использования в микросервисах. В качестве middleware MemLimiter может быть проинтегрирован с различными RPC- и веб-фреймворками. В MemLimiter есть возможность учесть количество памяти, выделенное альтернативными аллокаторами (при условии, что программист способен самостоятельно организовать его учёт) — это поможет стабилизировать потребление памяти в сервисах, написанных на Cgo.
Для оценки эффективности MemLimiter были проведены нагрузочные тесты со специальным модельным сервисом, работающим в контейнере с ограничением объёма памяти в 1 Гб. В зависимости от значения управляющего коэффициента потребление памяти сервисом удаётся стабилизировать в диапазоне 600-950 Мб. Однако если MemLimiter отключить, то уже через 10-15 секунд сервис вылетает с OOM.
Трудный путь SetMemoryLimit
Функциональность, близкая к той, что была реализована в MemLimiter, запрашивалась пользователями очень давно, как минимум с 2013 года (1, 2, 3, 4, 5). В 2017 началась реальная работа: разработчики подготовили функцию SetMaxHeap, и как минимум до 2020 года она существовала в виде набора патчей к языку (1, 2). Идея была всё той же — по мере роста хипа надо запускать GC чаще. Вместе с функцией SetMaxHeap
был реализован и механизм backpressure: предполагалось, что приложение должно слушать нотификации рантайма через некий канал и в случае их наступления выполнять какие-то действия, которые позволили бы снизить нагрузку на GC.
Однако в ходе опытной эксплуатации этого патча внутри Google был выявлен целый ряд проблем. Функция SetMaxHeap
не учитывала размеры глобальных объектов и стека, несмотря на то, что они могли давать весомый вклад в суммарный RSS процесса. Также она никак не учитывала внутреннюю фрагментацию памяти, из-за которой RSS и суммарный размер объектов, размещённых в хипе, могли разительно отличаться. Удивило также и то, что в подавляющем большинстве случаев разработчики игнорировали нотификации из канала backpressure. По этим причинам функция SetMaxHeap
так и не попала в язык.
В 2021 году появился многообещающий proposal, в котором предлагалось реализовать новую функцию — SetMemoryLimit. Это более целостное решение, которое учитывает всю память, потребляемую рантаймом, а не только один хип. Ориентируясь на заданную верхнюю границу потребления памяти, рантайм сможет лучше утилизировать имеющуюся память и более агрессивно возвращать её операционной системе. В частности, SetMemoryLimit
сделает ненужным широко известный хак c heap ballast, который иногда используется теми, у кого хватает RAM, и кому частый запуск GC только мешает. Также исчезнет необходимость в ручном вызове функции FreeOSMemory при приближении к лимиту потребления памяти.
Реализация SetMemoryLimit
потребует серьёзного переосмысления алгоритма GC Pacer. Чтобы избежать скатывания GC в спираль смерти, вводится искусственное ограничение на потребление сборщиком мусора CPU — оно не превысит 50% даже в самых жёстких ситуациях, когда мусора очень много, а память почти исчерпана. В этом случае приложение сможет продолжить аллоцировать новую память, что с большой вероятностью приведёт к преодолению лимита, установленного SetMemoryLimit — в этом смысле он является мягким.
В общем, функция SetMemoryLimit
, выход которой ожидается в Go 1.19, должна решить многие проблемы, но всё же не все. Канал, с помощью которого можно было бы реализовать механизм backpressure в бизнес-логике приложения, в язык так и не попадёт. Также принципиально нерешённым остаётся вопрос с аллокациями в Cgo.
Scavenger
Элемент рантайма, ответственный за возврат оперативной памяти операционной системе, называется scavenger. Scavenger пытается усидеть на двух стульях. С одной стороны, требуется, чтобы RSS процесса оставался в разумных пределах. С другой стороны, чистить всю ненужную память «под ноль» — неэффективно, потому что когда она понадобится, её придётся заново аллоцировать, а это дорогая и долгая операция.
Важно отметить, что Go никогда не пользуется системным вызовом munmap, чтобы уменьшить размер виртуального адресного пространства — этот вызов считается дорогим, а виртуальная память, под которой нет физической памяти, наоборот, очень дёшева. Рантайм может лишь подсказать операционной системе, что определённые страницы виртуальной памяти ему больше не нужны — для этого используется сисколл madvice. Если под этими страницами виртуальной памяти находятся страницы физической памяти, ОС по своему усмотрению может забрать их себе для использования другими процессами. В вызове madvice
и заключается основная задача scavenger.
При этом новые аллокации в рантайме могут происходить как из обычных, так и из «возвращённых» операционной системе страниц. Этим объясняется контринтуитивное поведение показателя HeapReleased
. Кажется, что он должен монотонно расти, однако в действительности он может снизиться, если рантайм Go решит обратиться к «возвращённой» через madvice
области виртуальной памяти с целью размещения новых объектов.
Длительное время (до Go 1.11 включительно) scavenger был периодическим процессом, который запускался каждые 2.5 минуты, и освобождал страницы из-под спанов, которые не использовались более 5 минут. Аргументом madvice
был параметр MADV_DONTNEED
: он подсказывал ОС, что память можно забрать назад, при этом RSS процесса синхронно снижался в момент системного вызова.
В Go 1.12 добавили ещё один тип запуска scavenger. Теперь он вызывался синхронно в момент быстрого роста хипа. Для некоторых приложений он помог срезать пиковое потребление оперативной памяти. Ещё одним нововведением стала замена MADV_DONTNEED
на MADV_FREE
— это более ленивый вариант освобождения памяти, при котором RSS снижается не сразу, а по мере увеличения нагрузки на ОС. Сразу же после этого на разработчиков Go вылился шквал тикетов об утечках памяти, потому что Докер и Кубернетес не поняли этой ленивости и убивали процессы из-за превышения лимитов на RSS.
В Go 1.13 периодический scavenger был заменён на непрерывно работающий. Фоновая горутина запускает scavenger так, чтобы суммарные затраты на CPU от работы scavenger не превышали 1% CPU, однако в действительности тратится несколько больше, поскольку scavenger вынужден синхронизировать доступ к внутренним структурам рантайма с аллоцирующими горутинами. Целевое значение RSS, к которому фоновый scavenger пытается привести процесс, выражалось формулой:
где retainExtraPercent был равен 10, то есть scavenger закладывал 10% буфер от целевого размера хипа для переиспользования в новых аллокациях. В Go 1.14 эта формула была уточнена, чтобы более явным образом учитывать фрагментацию аллокатора.
В Go 1.16 было принято решение откатиться обратно на MADV_DONTNEED
, чтобы сделать оценку RSS процесса более точной.
Появление функции SetMemoryLimit
в Go 1.19 потребует модификации запуска scavenger. Теперь интенсивность работы scavenger будет управляться ПИ-регулятором: чем ближе потребление памяти будет приближаться к лимиту, тем выше будет фракция CPU, выделяемая на работу scavenger (вплоть до 10%).
Заключение
Во второй статье нашего небольшого цикла мы постарались разобрать те особенности рантайма языка Go, непонимание которых может привести к повышенному потреблению оперативной памяти: это фрагментация аллокатора, неоднозначный дизайн GC Pacer и нюансы работы scavenger в некоторых версиях языка.
Подводя промежуточные итоги, можно кратко сформулировать следующие рекомендации по предотвращению утечек памяти в Go:
Настройте мониторинг.
Через стандартный профилировщик проверьте отсутствие утечек горутин и утечек аллокаций на хипе; исключите накопление состояния в долгоживущих объектах.
Если в программе используется Cgo, используйте альтернативные аллокаторы (TCMalloc, Jemalloc). Постарайтесь как можно точнее оценить количество памяти, выделенной и освобождённой на стороне Cgo, чтобы быть уверенным в том, что вы возвращаете системе ровно столько же памяти, сколько и выделяете.
Если в программе используется
sync.Pool
, проверьте, что он хранит объекты, однородные по размеру.Проверьте эмпирическое значение фрагментации. Если оно слишком велико, попробуйте отрефакторить приложение так, чтобы выбор структур данных лучше соответствовал набору классов размеров рантайма.
Выберите более консервативное значение
GOGC
, но лучше попробуйте внедрить MemLimiter – библиотеку, которая помогает ограничить потребление памяти у микросервисов, написанных на Go, автоматически подстраивая GOGC и долю подавляемых клиентских запросов под лимит потребления оперативной памяти, а также предоставляет интерфейс для реализации механизма backpressure, специфичного для конкретной программы.Если вы по каким-то причинам продолжаете использовать Go 1.12 – 1.15, включите использование
MADV_DONTNEED
.Обратите внимание на функцию
SetMemoryLimit
, которая планируется к выходу в Go 1.19, однако помните, что она не предоставляет средства к реализации backpressure и не учитывает аллокации, выполненные на стороне Cgo.
Если вам нравится разбираться в сложных и неординарных проблемах, если вас не отталкивает необходимость погружения в тонкости рантайма Go, если вы фанатично относитесь к качеству и отказоустойчивости разрабатываемого софта, приходите работать в компанию МойОфис. Мы будем с радостью делиться собственным опытом и искать возможности для взаимного развития.
Полезные ссылки:
Abraham Marín Pérez. The Last Frontier in Java Performance: Remove the Garbage Collector. 2017
Ankur Anand. A visual guide to Go Memory Allocator from scratch (Golang)
Erik Engheim. Go Does Not Need a Java Style GC
Josh Snyder, Joseph Lynch. Garbage collecting unhealthy JVMs, a proactive approach
Michael Knyszek. Proposal: soft memory limit
Michael Knyszek. Proposal: GC Pacer redesign
Povilas Versockas. Go memory management
Rick Hudson. Getting to Go: The Journey of Go's Garbage Collector
Sharva Pathak. How we analyzed and fixed a Golang memory leak
Yves Junqueira. Enforcing memory limits in Go
Ross Engers. Go memory ballast: How I learnt to stop worrying and love the heap
Александр Алексеев. Десять решений проблемы stop the world при использовании автоматической сборки мусора
Антон Киреев. Go To Memory
xcono
Замечательный материал. Покорно благодарю. Было бы великолепно, если бы существовали книги в подобном, отличном от справочника стиле.