GO memory ballast

Балласт памяти Go: как я перестал волноваться и полюбил кучу(heap)

Mikalailupish
Clean Code

--

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

  1. Такие изменения часто включают погружение в вещи, с которыми вы не знакомы, и которые вы не понимаете.
  2. Даже у самого проработанного кода, есть расходы на обслуживание каждой оптимизации, которую вы добавляете, и это обычно (хотя и не всегда) довольно линейно с количеством строк кода, которые вы в конечном итоге добавляете/изменяете.

Недавно мы выпустили небольшое изменение, которое уменьшило загрузку процессора наших интерфейсных серверов API в Twitch на ~30% и уменьшило общую задержку 99-го процентиля API во время пиковой нагрузки на ~45%.

Этот пост об изменении, процессе его поиска и объяснения того, как он работает.

Setting the stage

Создаем условия: У нас есть сервис в Twitch под названием Visage, который функционирует как наш интерфейс API. Visage является центральным шлюзом для всего внешнего исходящего трафика API. Он отвечает за кучу вещей, от авторизации до маршрутизации запросов, до серверного Grapql. Таким образом, он должен масштабироваться, чтобы обрабатывать шаблоны трафика пользователей, которые находятся несколько вне нашего контроля.

В качестве примера мы видим распространенный шаблон трафика “ refresh storm.”Это происходит, когда поток популярного вещателя падает из-за сбоя в их интернет-соединении. В ответ вещатель перезапускает стрим. Это обычно заставляет зрителей многократно обновлять свои страницы, и внезапно у нас появляется намного больше трафика API, с которым нужно иметь дело.

Visage-Это приложение Go (построенное с Go 1.11 во время этого изменения), которое работает на EC2 с балансировщиком нагрузки. Находясь на EC2, он по большей части хорошо масштабируется по горизонтали.

Однако, даже с магией EC2 и групп автоматического масштабирования, у нас все еще есть проблема борьбы с очень большими всплесками трафика. Во время “штормов обновления”, мы часто имеем дело с миллионами запросов в течении нескольких секунд, учитывая нашу нормальную загрузку 20х. Кроме того, мы увидели, что задержка API значительно ухудшается, когда наши frontend серверы находились под большой нагрузкой.

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

Scouting the deck

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

Поэтому, взглянув на профили нашего приложения Go, мы сделали следующие замечания:

  1. В установившемся состоянии наше приложение запускало ~8–10 циклов сбора мусора (GC) в секунду (400–600 в минуту).
  2. >30% циклов CPU были потрачены на вызов функций, связанных с GC
  3. Во время всплесков трафика количество циклов GC увеличится
  4. Наш размер кучи в среднем был довольно небольшим ()
применение Visage циклов GC в секунду
применение Visage использовано кучи в MIB

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

What is a garbage collector (GC) ?

Что такое GC: В современных приложениях обычно существует два способа выделения памяти: stack и heap. Большинство программистов знакомы со stack с первого времени написания рекурсивной программы, которая бы вызвала переполнение стека . Куча, с другой стороны, представляет собой пул памяти, который может использоваться для динамического выделения.

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

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

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

Go’s GC

Garbege collector от Go:

GCs-это сложные части программного обеспечения, поэтому я сделаю все возможное, чтобы все было понятным.

Начиная с v1.5, Go включали GC mark-and-sweep. Этот тип GC, как следует из названия, имеет две фазы: mark и sweep. Он не останавливает приложение (STW) для всего цикла GC, а скорее работает в основном одновременно с нашим кодом приложения. На этапе пометки среда выполнения обойдет все объекты, на которые приложение имеет ссылки в куче, и пометит их как все еще используемые. Этот набор объектов называется оперативной памятью. После этой фазы все остальное в куче, что не отмечено, считается мусором, и во время фазы sweep, будет перемещено.

Обобщим следующие термины:

Размер кучи-включает в себя все выделения, сделанные в куче; некоторые полезные, некоторые мусор.

Оперативная память-относится ко всем выделениям, на которые в настоящее время ссылается запущенное приложение; не мусор.

Оказывается, что для современных операционных систем подметание (освобождение памяти) является очень быстрой операцией, поэтому во время процесса GC для GC метки и развертки Go в значительной степени доминирует компонент метки, а не время подметания.

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

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

Как мы уже отмечали ранее, приложение Visage, которое работает на своей собственной виртуальной машине с 64GiB физической памяти, проводило очистку очень часто, используя только ~400MiB физической памяти. Чтобы понять, почему это было так, нам нужно копнуть в том, как Go решает компромисс частоты / памяти GC и обсудить Пейсер.

Pacer

Пейсер: GO GC использует pacer, чтобы определить, когда запускать следующий цикл GC. Pacing моделируется как проблема управления, где он пытается найти правильное время, чтобы вызвать цикл GC, чтобы он достиг цели размера кучи. Pacer Go по умолчанию будет пытаться инициировать цикл GC каждый раз, когда размер кучи удваивается. Это делается путем установки следующего размера триггера кучи во время фазы завершения метки текущего цикла GC. Таким образом, пометив всю оперативную память, он может принять решение о запуске следующего GC, когда общий размер кучи в 2 раза больше, чем текущий набор. Значение 2x происходит от переменной GOGC среда выполнения использует для установки коэффициента триггера.

Pacer в нашем случае превосходно справлялся с сохранением мусора на нашем heap до минимума, но это было ценой ненужной работы, так как мы использовали только ~0.6% памяти нашей системы.

Enter the ballast

Создайте балласт: Балласт в нашем приложении-это большое выделение памяти, которое обеспечивает стабильность кучи.

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

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

  1. Зачем тебе это делать?
  2. Не будет ли это использовать до 10 гиб моего драгоценного ОЗУ?

Начнем с 1. Зачем тебе это делать? Как отмечалось ранее, GC будет срабатывать каждый раз, когда размер heap удваивается. Размер кучи-это общий размер выделений в куче. Поэтому, если балласт 10 гиб выделяется, следующий GC будет срабатывать только тогда, когда размер кучи вырастет до 20 гиб. В этот момент будет примерно 10 гиб балласта + 10 гиб других распределений.

Когда GC работает, балласт не будет сметен как мусор, так как мы все еще держим ссылку на него в нашей основной функции, и, таким образом, он считается частью оперативной памяти. Так как большинство выделений в нашем приложении существует только в течение короткого времени жизни запроса API, большая часть 10 GiB распределения будет сметена, уменьшая кучу обратно до чуть более ~10 GiB снова (т. е., 10GiB балласта плюс все, что в запросах полета, имеют выделения и считаются оперативной памятью.) Теперь следующий цикл GC будет происходить, когда размер кучи (в настоящее время просто больше, чем 10 GiB) удваивается снова.

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

Если вам интересно, почему мы используем массив байтов для балласта, это гарантирует, что мы добавим только один дополнительный объект к фазе метки. Поскольку массив байтов не имеет никаких указателей(кроме самого объекта), GC может пометить весь объект за O (1).

Это изменение сработало как ожидалось — мы видели ~ 99% сокращение циклов GC:

Log base 2 масштабный график, показывающий циклы GC в минуту

Так что это выглядит хорошо, как насчет загрузки процессора?

Загрузка ЦП приложения Visage

Зеленая синусоидальная метрика загрузки CPU обусловлена ежедневными колебаниями нашего трафика. Можно увидеть шаг вниз после изменения.

~30% сокращение CPU в коробке означает, не глядя дальше, мы можем уменьшить колебания на 30%, однако мы также заботимся о задержке API — подробнее об этом позже.

Как упоминалось выше, среда выполнения Go предоставляет переменную средыGOGC, которая позволяет очень грубую настройку GC pacer. Это значение управляет коэффициентом роста heap до запуска GC. Мы решили не использовать это, так как у него есть некоторые очевидные подводные камни:

  • Пропорция сама по себе для нас не важна; объем памяти, которую мы используем.
  • Мы должны были бы установить значение очень высоко, чтобы получить тот же эффект, что и балласт, делая значение восприимчивым к небольшим изменениям в размере живого heap.
  • Рассуждать о живой памяти и скорости ее изменения нелегко; думать об общей используемой памяти просто.

Для тех, кто заинтересован, есть предложение добавить флаг целевого размера кучи к GC, который, как мы надеемся, скоро появится в среде выполнения Go.

Теперь на 2. Не будет ли это использовать до 10Gib моей драгоценной оперативной памяти? Я успокою тебя. Ответ: Нет, не будет, если вы не сделаете это намеренно. Память в системах nix (и даже Windows) фактически адресована и сопоставлена через таблицы страниц ОС. При выполнении вышеуказанного кода массив, на который указывает балластный срез, будет выделен в виртуальном адресном пространстве программы. Только если мы попытаемся прочитать или записать в срез, произойдет ошибка страницы, из-за которой физическая оперативная память, поддерживающая виртуальные адреса, будет выделена.

Мы можем легко подтвердить это с помощью следующей тривиальной программы:

Мы запустим программу и затем проверим с ps:

Это показывает, что чуть более 100MiB памяти было выделено практически процессу-V irtual S i Z e( VSZ ), в то время как ~5MiB было выделено в резидентном наборе — R esident S et S ize (RSS ), i.e физическая память.

Теперь давайте изменим программу для записи в половину базового массива байтов, поддерживающего срез:

Снова проверять с ps:

Как и ожидалось, половина массива байтов теперь находится в RSS, занимая физическую память. VSZ не изменяется, так как в обеих программах существует виртуальное распределение одинакового размера.

Для тех, кто заинтересован, столбец MINFL — это количество незначительных ошибок страницы-это количество ошибок страницы, возникших в процессе, который требовал загрузки страниц из памяти. Если ОС удалось выделить нашу физическую память хорошо и последовательно, то каждая ошибка страницы сможет сопоставить более одной страницы оперативной памяти, уменьшая общее количество ошибок страницы, которые происходят.

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

What about the API latency?

Как насчет задержки API: Как упоминалось выше, мы видели улучшение задержки API (особенно во время высокой нагрузки) в результате выполнения GC меньшей частоты. Первоначально мы думали, что это может быть связано с уменьшением времени паузы GC — это количество времени, когда GC фактически останавливает процесс во время цикла GC. Однако время паузы GC до и после изменения существенно не отличалось. Кроме того, время паузы составляло порядка миллисекунд одной цифры, а не 100 миллисекунд, которые мы наблюдали при пиковой нагрузке.

Чтобы понять, откуда пришло это улучшение задержки, нам нужно немного поговорить о функции Go GC под названием assists.

GC assists

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

Поскольку у Go уже есть фоновый работник GC, термин assistотносится к нашим goroutines, помогающим фоновому работнику. В частности, помощь в работе знака.

Чтобы понять это немного больше, давайте возьмем пример:

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

Заглянув внутрь runtime.mallocgcфункции, мы увидим интересный путь к коду.

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

источник: runtime / malloc.Вперед

В коде выше строка if assistG.gcAssistBytes < 0 проверяет, находится ли наша goroutine в долге распределения. Долг по распределению-это причудливый способ сказать, что эта goroutine выделяла больше, чем она делала работу GC во время цикла GC.

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

Поэтому, предполагая, что это первый раз, когда наш goroutine выделяет в течение текущего цикла GC, он будет вынужден делать работу GC assist. Интересной линией здесь является звонок на gcAssistAlloc

Эта функция отвечает за некоторую уборку и в конечном итоге вызывает gcAssistAlloc1 для выполнения фактической работы GC assist. Я не буду вдаваться в детали gcAssistAllocфункций, но по существу он делает следующее:

  1. Проверяет, что goroutine не делает что-то непередаваемое (т. е. система goroutine)
  2. Выполнение работ GC mark
  3. Проверьте, если goroutine все еще имеет долг распределения, в противном случае возврат

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

В нашем интерфейсе API это означало, что ответы API будут видеть увеличенную задержку во время циклов GC. Как упоминалось ранее, по мере увеличения нагрузки на каждый сервер скорость выделения памяти будет увеличиваться, что, в свою очередь, увеличивает скорость циклов GC (часто до 10s или 20s циклов/с). Больше циклов GC,как мы теперь знаем, означает больше работы GC assist для goroutines, обслуживающих API, и, следовательно, больше задержки API.

Вы можете увидеть это достаточно ясно из трассировки выполнения нашего приложения. Ниже приведены два фрагмента из той же трассировки выполнения Visage; один во время выполнения цикла GC и один во время его отсутствия.

Трассировка выполнения во время цикла GC

Трассировка показывает, какие goroutines работают на каком процессоре. Все, что помеченоapp-code-это goroutine, выполняющий полезный код для нашего приложения (например, логика для обслуживания запроса API). Обратите внимание, как помимо четырех выделенных процессоров, выполняющих код GC, наши другие goroutines задерживаются и вынуждены делать MARK ASSIST(т. е. runtime.gcAssistAlloc) работу.

Профиль из того же приложения, что и выше, не во время цикла GC

Сравните это с этим профилем из того же запущенного приложения не во время цикла GC. Здесь наши goroutines тратят большую часть своего времени на запуск нашего кода приложения, как и ожидалось.

Таким образом, просто уменьшив частоту GC, мы увидели близкое к падению ~99% в работе mark assist, что привело к улучшению~45% в задержке 99-го процентиля API при пиковом трафике.

Вам может быть интересно, почему Go выбрал бы такой странный дизайн (используя голевые передачи) для своего GC, но на самом деле это имеет большой смысл. Основная функция GC заключается в том, чтобы гарантировать, что мы сохраняем кучу до разумного размера и не позволяем ей расти без мусора. Это достаточно легко в GC stop-the-world (STW), но в параллельном GC нам нужен механизм, гарантирующий, что распределения, происходящие в течение цикла GC, не растут без ограничений. На мой взгляд, каждый goroutine платит налог на распределение пропорционально тому, что он хочет выделить в цикле GC-довольно элегантный дизайн.

Для действительно всестороннего написания этого выбора дизайна см. Этот документ Google .

In (sweeping) summary

подведем итог:

  1. Мы заметили, что наши приложения делают много работы GC
  2. Мы развернули балласт памяти
  3. Оно уменьшил циклы GC путем позволения куче вырасти более большой
  4. Задержка API улучшена, так как Go GC меньше задерживает нашу работу с голевыми передачами
  5. Распределение балласта в основном бесплатно, потому что оно находится в виртуальной памяти
  6. О балластах проще рассуждать, чем настраивать GOGCзначение
  7. Начните с малого балласта и увеличьте с испытанием

Some final thoughts

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

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

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

Выражение благодарности

Я хотел бы поблагодарить Хилтнера за его неоценимую помощь в расследовании и копании во многих тонкостях Go runtime и GC. Также Спасибо Жако Ле Ру, Даниэлю Бауману, Спенсеру Нельсону и рису за помощь в редактировании и корректуре этого поста.

Ссылки на литературу

Hudson — The Journey of Go’s Garbage Collector

Mark Pusher-GOLANG’s Real-time GC в теории и практике

Austin Clements-Go 1.5 параллельный сборщик мусора

--

--