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

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

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

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

Подготовка сцены

В Twitch у нас есть сервис под названием Visage, который функционирует как интерфейс нашего API. Visage - это центральный шлюз для всего внешнего API-трафика. Он отвечает за множество вещей, от авторизации до маршрутизации запросов до (в последнее время) серверного GraphQL. Таким образом, он должен масштабироваться, чтобы обрабатывать шаблоны пользовательского трафика, которые в некоторой степени находятся вне нашего контроля.

Например, типичный образец трафика, который мы видим, - это «шторм обновления». Это происходит, когда поток популярного вещателя падает из-за сбоя в подключении к Интернету. В ответ вещатель перезапускает поток. Обычно это заставляет зрителей неоднократно обновлять свои страницы, и внезапно нам приходится иметь дело с гораздо большим трафиком API.

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

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

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

Разведка колоды

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

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

  1. В устойчивом состоянии наше приложение запускало ~ 8–10 циклов сборки мусора (GC) в секунду (400–600 в минуту).
  2. ›30% циклов ЦП было потрачено на вызовы функций, связанных с сборщиком мусора.
  3. Во время скачков трафика количество циклов сборки мусора будет увеличиваться
  4. Размер нашей кучи в среднем был довольно небольшим (‹450Mib)

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

Что такое сборщик мусора (GC)?

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

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

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

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

Go’s GC

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

Начиная с версии 1.5, Go включает параллельный сборщик мусора с меткой и очисткой. Этот тип GC, как следует из названия, имеет две фазы: отметку и очистку. «Параллельный» просто означает, что он не останавливает мир (STW) на протяжении всего цикла сборки мусора, а скорее выполняется в основном одновременно с кодом нашего приложения. Во время фазы отметки среда выполнения просматривает все объекты, на которые приложение ссылается в куче, и помечает их как все еще используемые. Этот набор объектов известен как живая память. После этой фазы все остальное в куче, которое не помечено, считается мусором и во время фазы очистки будет освобождено очистителем.

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

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

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

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

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

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

Однако, как мы отмечали ранее, приложение Visage, работающее на собственной виртуальной машине с 64 ГБ физической памяти, очень часто выполняло сборку мусора, используя только ~ 400 МБ физической памяти. Чтобы понять, почему это было так, нам нужно вникнуть в то, как Go решает проблему компромисса между частотой сборки мусора и памятью, и обсудить стимул.

Pacer

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

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

Введите балласт

Балласт - морской. любой тяжелый материал, временно или постоянно перевозимый на судне для обеспечения желаемой осадки и устойчивости. - источник: dictionary.com

Балласт в нашем приложении - это большой объем памяти, обеспечивающий стабильность кучи.

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

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

  1. Зачем тебе это делать?
  2. Разве это не займет 10 ГБ моей драгоценной оперативной памяти?

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

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

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

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

Внедрение этого изменения сработало, как и ожидалось - мы увидели сокращение циклов GC примерно на 99%:

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

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

Снижение ЦП на коробку примерно на 30% означает, что, не глядя дальше, мы можем уменьшить наш парк на 30%, однако нас также волнует задержка API - подробнее об этом позже.

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

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

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

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

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

Запустим программу, а затем проверим с помощью ps:

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

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

Снова проверяем с помощью ps:

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

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

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

А как насчет задержки API?

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

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

GC помогает

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

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

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

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

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

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

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

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

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

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

  1. Убедитесь, что горутина не делает что-то непреодолимое (т. Е. Системную горутину).
  2. Выполните работу по маркировке GC
  3. Проверьте, есть ли у горутины задолженность по распределению, в противном случае верните
  4. Перейти 2

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

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

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

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

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

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

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

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

В (широком) резюме

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

Несколько заключительных мыслей

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

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

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

Хотите присоединиться к команде Twitch Video Platform вместе с Россом? Нажмите здесь, чтобы просмотреть наши вакансии.

Благодарности

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

Ссылки

Ричард Л. Хадсон - Путешествие сборщика мусора Го

Марк Пушер - Сборщик мусора в реальном времени Голанга в теории и на практике

Остин Клементс - Скорость одновременного выполнения сборщика мусора Go 1.5