Руководство по сборщику мусора Go
Введение
Это руководство предназначено для того, чтобы помочь продвинутым пользователям Go лучше понимать затраты их приложений, предоставляя представление о сборщике мусора Go. Оно также даёт рекомендации по тому, как пользователи Go могут использовать эти знания для улучшения использования ресурсов их приложений. В руководстве не предполагается знание принципов работы сборщика мусора, но предполагается знакомство с языком программирования Go.
Язык Go отвечает за размещение значений Go; в большинстве случаев разработчику Go не нужно заботиться о том, где хранятся эти значения, или почему, если вообще. На практике, однако, эти значения часто необходимо хранить в физической памяти компьютера, а физическая память — это конечный ресурс. Поскольку она конечна, память должна управляться тщательно и перерабатываться, чтобы избежать исчерпания памяти во время выполнения Go-программы. Задача реализации Go — выделять и перерабатывать память по мере необходимости.
Другое название автоматической переработки памяти — сборка мусора. На высоком уровне, сборщик мусора (или GC, для краткости) — это система, которая перерабатывает память от имени приложения, определяя, какие части памяти больше не требуются. Стандартный инструментарий Go включает в себя библиотеку среды выполнения, которая поставляется с каждым приложением, и эта библиотека среды выполнения включает в себя сборщик мусора.
Обратите внимание, что существование сборщика мусора, описанного в этом руководстве, не гарантируется спецификацией Go
Следовательно, данное руководство касается конкретной реализации языка программирования Go и может не применяться
к другим реализациям.
В частности, следующее руководство относится к стандартной toolchain (компилятор и инструменты gc).
Gccgo и Gollvm используют очень похожую реализацию GC, поэтому многие из этих концепций применимы, но детали могут
отличаться.
Кроме того, это живой документ, который будет меняться со временем для лучшего отражения последнего релиза Go. В настоящий момент данный документ описывает сборщик мусора, начиная с версии Go 1.19.
Где живут значения Go
Прежде чем мы перейдем к сборщику мусора (GC), рассмотрим память, которая не нуждается в управлении со стороны GC.
Например, значения Go, не являющиеся указателями, хранящиеся в локальных переменных, скорее всего, вообще не будут управляться GC языка Go, поскольку Go вместо этого организует выделение памяти, связанной с лексической областью видимости, в которой она создается. В общем случае, это более эффективно, чем полагаться на GC, потому что компилятор Go способен заранее определить, когда эту память можно освободить, и сгенерировать машинные инструкции для очистки. Обычно выделение памяти для значений Go таким образом называют "выделением на стеке", поскольку это место хранится в стеке горутины.
Значения Go, для которых выделение памяти невозможно осуществить таким образом, потому что компилятор Go не может определить их продолжительность жизни, говорят, что побегают на кучу. "Куча" может быть рассмотрена как общее место для выделения памяти, когда значения Go должны быть размещены где-либо. Процесс выделения памяти на куче обычно называют "динамическим выделением памяти", поскольку и компилятор, и среда выполнения могут сделать очень мало предположений о том, как эта память используется и когда она может быть очищена. Вот где появляется GC: это система, специально предназначенная для идентификации и очистки динамических выделений памяти.
Существует множество причин, по которым значение Go может побежать на кучу. Одной из причин может быть то, что его размер определяется динамически. Рассмотрим, например, резервный массив среза, начальный размер которого определяется переменной, а не константой. Обратите внимание, что побег на кучу должен быть транзитивным: если ссылка на значение Go записывается в другое значение Go, которое уже было определено как побегающее, то и это значение также должно побегать.
Уходит ли значение Go в кучу или нет, зависит от контекста, в котором оно используется, и алгоритма анализа ускользания компилятора Go. Было бы ненадежно и сложно пытаться точно перечислить, когда значения ускользают: сам алгоритм довольно сложен и изменяется между релизами Go. Подробнее о том, как определить, какие значения ускользают, а какие нет, см. в разделе об устранении выделения памяти в куче.
Трассирующая сборка мусора
Сборка мусора может относиться ко многим различным методам автоматического освобождения памяти; например, подсчет ссылок. В контексте этого документа сборка мусора относится к трассирующей сборке мусора, которая идентифицирует используемые, так называемые живые, объекты путем транзитивного отслеживания указателей.
Давайте определим эти термины более строго.
-
Объект—Объект — это динамически выделенный фрагмент памяти, который содержит одно или несколько значений Go.
-
Указатель—Адрес памяти, который ссылается на любое значение внутри объекта. Естественно, это включает значения Go вида
*T, но также включает части встроенных значений Go. Строки, слайсы, каналы, карты и значения интерфейсов содержат адреса памяти, которые сборщик мусора должен отслеживать.
Вместе объекты и указатели на другие объекты образуют граф объектов. Для идентификации живой памяти сборщик мусора обходит граф объектов, начиная с корней программы, указателей, которые определяют объекты, которые определенно используются программой. Два примера корней — это локальные переменные и глобальные переменные. Процесс обхода графа объектов называется сканированием. Другая фраза, которую вы можете увидеть в документации Go, — является ли объект достижимым, что просто означает, что объект может быть обнаружен в процессе сканирования. Также обратите внимание, что за одним исключением, как только память становится недостижимой, она остается недостижимой.
Этот базовый алгоритм является общим для всех трассирующих сборщиков мусора. Трассирующие сборщики мусора различаются тем, что они делают после того, как обнаруживают, что память живая. Сборщик мусора Go использует технику пометки-выметания, что означает, что для отслеживания своего прогресса сборщик мусора также помечает значения, которые он встречает, как живые. После завершения трассировки сборщик мусора затем проходит по всей памяти в куче и делает всю память, которая не помечена, доступной для выделения. Этот процесс называется выметанием.
Одна альтернативная техника, с которой вы можете быть знакомы, — фактически переместить объекты в новую часть памяти и оставить указатель пересылки, который позже используется для обновления всех указателей приложения. Мы называем сборщик мусора, который перемещает объекты таким образом, перемещающим сборщиком мусора; у Go неперемещающий сборщик мусора.
Цикл сборки мусора
Поскольку сборщик мусора (GC) в Go представляет собой маркирующий (mark-sweep) сборщик мусора, он в целом работает в два этапа: этап маркировки и этап сбора (sweep). Хотя это утверждение может показаться очевидным, оно содержит важное наблюдение: освободить память обратно в доступ для выделения невозможно до тех пор, пока вся память не будет просканирована, поскольку может существовать не просканированный указатель, который удерживает объект живым. В результате, процесс сбора должен быть полностью отделён от процесса маркировки. Кроме того, сборщик мусора может вообще не быть активным, если нет задач, связанных с работой сборщика мусора. Сборщик мусора непрерывно переходит между этими тремя фазами: сбор, выключение и маркировка, что известно как цикл сборщика мусора (GC cycle). Для целей данного документа предположим, что цикл сборщика мусора начинается с фазы сбора, затем переходит в состояние выключения, а затем — в фазу маркировки.
Следующие несколько разделов будут посвящены формированию интуитивного понимания затрат, связанных со сборщиком мусора, чтобы помочь пользователям настраивать параметры сборщика мусора в своих интересах.
Понимание затрат
Сборщик мусора является врожденной сложной частью программного обеспечения, построенной на ещё более сложных системах. Легко уйти в детали при попытке понять работу сборщика мусора и настроить его поведение. Этот раздел предназначен для предоставления структуры рассуждений относительно затрат, связанных со сборщиком мусора Go, и его параметров настройки.
Для начала рассмотрим модель затрат сборщика мусора, основанную на трёх простых аксиомах.
-
Сборщик мусора использует только два ресурса: физическую память и время процессора (CPU).
-
Затраты памяти сборщика мусора включают живую память кучи (live heap memory), новую память кучи, выделенную до начала фазы маркировки, а также место для метаданных, которые, хотя и пропорциональны предыдущим затратам, в сравнении с ними незначительны.
Затраты памяти GC за цикл N = живая куча из цикла N-1 + новая куча
Живая память кучи — это память, определённая как живая предыдущим циклом сборщика мусора, тогда как новая память кучи — это любая память, выделенная в текущем цикле, которая может быть или не быть живой к концу цикла. Сколько памяти является живым в любой момент времени — это свойство программы, и сборщик мусора напрямую не может контролировать это.
-
Затраты времени процессора (CPU) сборщика мусора моделируются как фиксированная стоимость на цикл и переменная стоимость, пропорциональная размеру живой кучи.
Время CPU GC за цикл N = Фиксированная стоимость CPU на цикл + средняя стоимость CPU на байт * живая куча, найденная в цикле N
Фиксированная стоимость времени CPU на цикл включает в себя действия, которые происходят за фиксированное количество раз в каждом цикле, например, инициализацию структур данных для следующего цикла сборщика мусора. Эта стоимость обычно мала и включена лишь для полноты картины.
Большая часть затрат ЦП GC приходится на маркировку и сканирование, что учитывается в модели через предельные затраты. Средние затраты на маркировку и сканирование зависят от реализации GC, но также и от поведения программы. Например, большее количество указателей означает больше работы для GC, поскольку как минимум GC должен обойти все указатели в программе. Структуры, такие как связные списки и деревья, также сложнее для параллельного обхода GC, что увеличивает средние затраты на байт.
Эта модель игнорирует затраты на очистку (sweeping), которые пропорциональны общему объему кучи, включая память, которая является мертвой (так как она должна быть доступна для выделения). Для текущей реализации GC в Go очистка происходит намного быстрее, чем маркировка и сканирование, поэтому её стоимость в сравнении с ними считается пренебрежимо малой.
Эта модель проста, но эффективна: она точно классифицирует основные затраты GC. Она также показывает, что общие затраты ЦП сборщика мусора зависят от общего количества циклов GC в заданном временном интервале. Наконец, в этой модели заложена фундаментальная компромиссная зависимость времени/пространства для GC.
Чтобы понять это, рассмотрим ограниченную, но полезную ситуацию: стационарное состояние (steady state). Стационарное состояние приложения с точки зрения GC определяется следующими свойствами:
-
Скорость, с которой приложение выделяет новую память (в байтах в секунду), постоянна.
Это означает, что с точки зрения GC, рабочая нагрузка приложения выглядит примерно одинаково со временем. Например, для веб-сервиса это будет постоянный уровень запросов с, в среднем, одинаковым типом запросов, при этом средняя продолжительность жизни каждого запроса остается примерно постоянной.
-
Предельные затраты GC постоянны.
Это означает, что статистика графа объектов, такая как распределение размеров объектов, количество указателей и средняя глубина структур данных, остаётся неизменной от цикла к циклу.
Рассмотрим пример. Допустим, некоторое приложение выделяет 10 МиБ/с, GC может сканировать с частотой 100 МиБ/ЦП-секунда (это условная величина), а фиксированные затраты GC равны нулю. Стационарное состояние не делает никаких предположений о размере живой кучи, но для простоты предположим, что живая куча этого приложения всегда составляет 10 МиБ. (Примечание: постоянная живая куча не означает, что вся новая выделенная память мертва. Это означает, что после запуска GC некоторая смесь старой и новой памяти кучи остаётся живой.) Если каждый цикл GC происходит ровно каждую секунду ЦП, то наше примерное приложение в стационарном состоянии будет иметь общий размер кучи 20 МиБ на каждом цикле GC. И с каждым циклом GC будет необходимо 0.1 ЦП-секунды для выполнения своей работы, что приведёт к 10% перегрузке.
Теперь предположим, что каждый цикл GC происходит реже — один раз в 2 секунды процессора. Тогда наше примерное приложение в установившемся состоянии будет иметь общий размер кучи 30 МиБ на каждом цикле GC. Но при каждом цикле GC GC по-прежнему будет тратить всего 0.1 секунды процессора на выполнение своей работы. Это означает, что накладные расходы GC снизились с 10% до 5%, но при этом использовалось на 50% больше памяти.
Это изменение в соотношении времени и памяти — это фундаментальный компромисс времени и пространства, упомянутый ранее. И частота GC находится в центре этого компромисса: если мы запускаем GC чаще, то используем меньше памяти, и наоборот. Но насколько часто GC на самом деле запускается? В Go принятие решения о том, когда следует запускать GC, является основным параметром, который пользователь может контролировать.
GOGC
На высоком уровне GOGC определяет компромисс между CPU и памятью GC.
Он работает, определяя целевой размер кучи после каждого цикла GC — целевое значение общего размера кучи в следующем цикле. Целью GC является завершить цикл сборки мусора до того, как общий размер кучи превысит целевой размер кучи. Общий размер кучи определяется как размер живой кучи в конце предыдущего цикла, плюс любая новая память кучи, выделенная приложением с момента предыдущего цикла. В то же время целевой размер памяти кучи определяется следующим образом:
Целевой размер кучи = Живая куча + (Живая куча + GC корни) * GOGC / 100
В качестве примера рассмотрим Go-программу с размером живой кучи 8 МиБ, 1 МиБ стеков горутин и 1 МиБ указателей в глобальных переменных. Тогда при значении GOGC равном 100 объем новой памяти, который будет выделен до следующего запуска GC, составит 10 МиБ, или 100% от 10 МиБ работы, что в сумме даст 18 МиБ общего размера кучи. При значении GOGC равном 50 — это будет 50%, или 5 МиБ. При значении GOGC равном 200 — это будет 200%, или 20 МиБ.
Примечание: начиная с Go 1.18, GOGC включает только набор корней (root set). Ранее он учитывал только живую кучу. Часто объем памяти в стеках горутин незначителен, и размер живой кучи доминирует над другими источниками работы GC, но в случаях, когда программы имеют сотни тысяч горутин, GC делал плохие оценки.
Целевой размер кучи управляет частотой GC: чем больше целевой размер, тем дольше GC может ждать начала следующей фазы пометки и наоборот. Хотя точная формула полезна для прогнозирования, лучше всего думать о GOGC как о параметре, который выбирает точку на компромиссе между CPU и памятью GC. Ключевым моментом является то, что удвоение GOGC приведет к удвоению накладных расходов памяти кучи и примерно в два раза снизит затраты CPU на GC, и наоборот. (Чтобы увидеть полное объяснение, см. приложение.)
Примечание: целевой размер кучи — это всего лишь цель, и существует несколько причин, по которым цикл GC может не завершиться именно в этот момент. Во-первых, достаточно большой аллокационный запрос в куче может просто превысить целевой размер. Однако другие причины появляются в реализациях GC, выходящих за рамки модели GC, которая использовалась в этом руководстве до сих пор. Для получения дополнительных сведений обратитесь к разделу о задержках, но полные детали можно найти в дополнительных ресурсах.
Параметр GOGC может быть настроен с помощью переменной окружения GOGC (которую распознают все программы
на Go), либо через API SetGCPercent в
пакете runtime/debug.
Обратите внимание, что GOGC также может использоваться для полного отключения GC (при условии, что не применяется лимит памяти), установив GOGC=off или вызвав SetGCPercent(-1).
Концептуально, это эквивалентно установке GOGC в значение бесконечности, поскольку количество новой памяти до
запуска GC не ограничено.
Чтобы лучше понять всё, что было рассмотрено выше, попробуйте интерактивную визуализацию ниже, построенную на модели затрат GC, обсуждавшейся ранее. Эта визуализация демонстрирует выполнение некоторой программы, для завершения которой требуется 10 секунд CPU-времени. В первую секунду она выполняет шаг инициализации (увеличивая размер живой кучи), после чего переходит в стабильное состояние. Приложение выделяет в общей сложности 200 МиБ, при этом одновременно живая куча составляет 20 МиБ. Предполагается, что единственная значимая работа GC связана с живой кучей, и что (нереалистично) приложение не использует дополнительную память.
Используйте ползунок для изменения значения GOGC и посмотрите, как приложение отреагирует в плане общего времени выполнения и накладных расходов GC. Каждый цикл GC заканчивается, когда новая куча опускается до нуля. Время, затраченное на снижение новой кучи до нуля, представляет собой совокупное время фазы пометки цикла N и фазы очистки цикла N+1. Обратите внимание, что эта визуализация (и все остальные в этом руководстве) предполагает, что приложение приостанавливается во время выполнения GC, поэтому затраты CPU на GC полностью отражаются временем, необходимым для снижения новой кучи до нуля. Это сделано исключительно для упрощения визуализации; та же самая интуиция остаётся справедливой. Ось X всегда показывает полное время CPU-времени выполнения программы. Обратите внимание, что дополнительное CPU-время, используемое GC, увеличивает общую продолжительность выполнения.
Обратите внимание, что сборщик мусора (GC) всегда требует определённых ресурсов CPU и пиковой памяти. По мере увеличения значения GOGC снижается нагрузка на CPU, но пиковая потребность в памяти возрастает пропорционально размеру живой кучи. При уменьшении значения GOGC пиковая потребность в памяти снижается за счёт дополнительной нагрузки на CPU.
Примечание: график отображает время CPU, а не время по истечении Wall-clock для завершения программы. Если программа выполняется на одном CPU и полностью использует его ресурсы, то эти значения эквивалентны. Реальная программа, скорее всего, работает на многопроцессорной системе и не использует 100% ресурсы CPU в течение всего времени. В таких случаях влияние GC на wall-time будет ниже.
Примечание: сборщик мусора Go имеет минимальный общий размер кучи 4 МиБ, поэтому если целевое значение, установленное через GOGC, меньше этого порога, оно округляется вверх. Визуализация отражает эту особенность.
Вот ещё один пример, немного более динамичный и реалистичный. Опять же, приложение требует 10 секунд CPU-времени для завершения без GC, но в стабильном состоянии скорость выделения памяти резко возрастает примерно на середине, а размер живой кучи немного меняется в первый период. Этот пример демонстрирует, как может выглядеть стабильное состояние, когда размер живой кучи действительно изменяется, и как более высокая скорость выделения памяти приводит к более частым циклам сборки мусора.
Ограничение памяти
До Go 1.19 параметром, который можно было использовать для изменения поведения GC, был только GOGC. Хотя он отлично подходит для настройки компромисса между ресурсами CPU и памятью, он не учитывает, что доступная память ограничена. Рассмотрим, что происходит при кратковременном росте размера живой кучи: так как GC выбирает общий размер кучи пропорционально размеру живой кучи, GOGC должен быть настроен таким образом, чтобы учитывать пиковый
Живой размер кучи, даже если в обычном случае более высокое значение GOGC обеспечивает лучшее соотношение между затратами и выгодой.
Визуализация ниже демонстрирует эту ситуацию с кратковременным пиком кучи.
Если пример рабочей нагрузки выполняется в контейнере с чуть более чем 60 МиБ доступной памяти, то значение GOGC не может быть увеличено выше 100, даже если остальные циклы GC имеют достаточно памяти для использования этой дополнительной памяти. Более того, в некоторых приложениях такие кратковременные пики могут быть редкими и трудно предсказуемыми, что приводит к случайным, неизбежным и потенциально затратным условиям нехватки памяти.
Поэтому в релизе Go 1.19 было добавлено использование ограничения памяти среды выполнения.
Ограничение памяти может быть установлено либо через переменную среды GOMEMLIMIT, которую распознают
все программы на Go, либо через функцию SetMemoryLimit, доступную в пакете runtime/debug.
Это ограничение памяти устанавливает максимальное значение общего объема памяти, который может использовать среда
выполнения Go.
Конкретный набор включаемой памяти определяется через runtime.MemStats
как выражение
Sys - HeapReleased
или эквивалентно в терминах пакета runtime/metrics,
/memory/classes/total:bytes - /memory/classes/heap/released:bytes
Поскольку сборщик мусора Go имеет явный контроль над тем, сколько памяти кучи он использует, он устанавливает общий размер кучи на основе этого ограничения памяти и того объема памяти, который использует среда выполнения Go.
Визуализация ниже показывает ту же самую одномерную стационарную рабочую нагрузку из раздела GOGC, но на этот раз с дополнительными 10 МиБ перегрузки из среды выполнения Go и с настраиваемым ограничением памяти. Попробуйте изменить значения GOGC и ограничения памяти и посмотрите, что произойдет.
Обратите внимание, что когда предел памяти снижается ниже пикового объема памяти, определяемого значением GOGC (42 МиБ при GOGC, равном 100), сборщик мусора запускается чаще, чтобы удерживать пиковый объем памяти в пределах заданного лимита.
Возвращаясь к нашему предыдущему примеру пикового увеличения временной кучи, установив предел памяти и увеличив значение GOGC, можно получить выгоду от обоих подходов: отсутствие превышения лимита памяти и лучшая экономия ресурсов. Попробуйте интерактивную визуализацию ниже.
Обратите внимание, что при некоторых значениях GOGC и пределе памяти использование памяти останавливается на уровне установленного лимита, однако остальная часть выполнения программы по-прежнему подчиняется правилу общего размера кучи, установленному значением GOGC.
Это наблюдение приводит к еще одному интересному моменту: даже если значение GOGC установлено в off, предел памяти по-прежнему учитывается! На самом деле, эта конкретная конфигурация представляет собой максимизацию экономии ресурсов, поскольку она устанавливает минимальную частоту запуска сборщика мусора, необходимую для поддержания заданного лимита памяти. В данном случае, все
Во время выполнения программы размер кучи может увеличиваться до достижения лимита памяти.
Теперь, хотя лимит памяти — это мощный инструмент, его использование не обходится без определённой цены, и, конечно, не отменяет полезности GOGC.
Рассмотрим, что происходит, когда живая куча становится достаточно большой, чтобы общий объём используемой памяти подошёл к лимиту. В визуализации стабильного состояния выше попробуйте отключить GOGC, а затем постепенно снижать лимит памяти всё дальше и дальше, чтобы увидеть, что произойдёт. Обратите внимание, что общее время, которое тратит приложение, начнёт неограниченно расти, поскольку GC постоянно выполняется, чтобы поддерживать невозможный лимит памяти.
Такая ситуация, при которой программа не может достигать разумного прогресса из-за постоянных циклов GC, называется thrashing (стресс-состояние/перегрузка). Она особенно опасна, потому что фактически останавливает работу программы. Ещё хуже то, что она может произойти в точно такой же ситуации, которую мы пытались избежать с помощью GOGC: достаточно большая внезапная пикировка кучи может привести к бесконечной блокировке программы! Попробуйте уменьшить лимит памяти (примерно до 30 МиБ или ниже) в визуализации внезапного скачка кучи и обратите внимание, как именно поведение ухудшается именно с момента пика кучи.
Во многих случаях бесконечная блокировка программы хуже, чем ошибка «out-of-memory», которая обычно приводит к гораздо более быстрому завершению работы.
По этой причине лимит памяти определяется как мягкий. Среда выполнения Go не даёт никаких гарантий, что она будет поддерживать этот лимит памяти во всех обстоятельствах; она лишь обещает некоторое разумное количество усилий. Это смягчение лимита памяти критически важно для предотвращения поведения thrashing, поскольку оно даёт GC способ выйти из тупика: позволить использованию памяти превышать лимит, чтобы избежать слишком длительного пребывания в GC.
Внутренне это работает так: GC устанавливает верхний предел количества времени ЦП, которое он может использовать в
течение определённого временного окна (с некоторой гистерезисной обратной связью для очень коротких скачков
использования ЦП).
Этот предел в настоящее время установлен примерно на уровне 50%, с временным окном 2 * GOMAXPROCS
секунд ЦП.
Последствием ограничения времени ЦП GC является то, что работа GC откладывается, в то время как программа на Go
может продолжать выделять новую память в куче, даже превышая лимит памяти.
Интуитивное понимание лимита в 50% времени ЦП GC основано на худшем случае влияния на программу с достаточным объёмом доступной памяти. В случае неправильной настройки лимита памяти, когда он установлен слишком низким, программа замедлится максимум в 2 раза, поскольку GC не может забрать больше 50% времени ЦП у программы.
Примечание: визуализации на этой странице не имитируют ограничение времени ЦП GC.
Рекомендуемое использование
Хотя лимит памяти является мощным инструментом, и среда выполнения Go предпринимает шаги для смягчения самых серьезных последствий неправильного использования, важно использовать его с осторожностью. Ниже приведена коллекция рекомендаций о том, где лимит памяти наиболее полезен и применим, а где он может причинить больше вреда, чем пользы.
-
Следует воспользоваться лимитом памяти, когда среда выполнения вашего Go-программы полностью находится под вашим контролем, и Go-программа — единственная программа, имеющая доступ к определенному набору ресурсов (например, к резервированию памяти, как в случае с лимитом памяти контейнера).
Хороший пример — развертывание веб-сервиса в контейнерах с фиксированным объемом доступной памяти.
В этом случае хорошим правилом является оставить дополнительные 5–10% свободного места для учета источников памяти, о которых среда выполнения Go не знает.
-
Следует свободно изменять лимит памяти в реальном времени, чтобы адаптироваться к изменяющимся условиям.
Хороший пример — программа с использованием cgo, где C-библиотеки временно требуют значительно больше памяти.
-
Не следует устанавливать GOGC в off при наличии лимита памяти, если Go-программа может делиться частью своей ограниченной памяти с другими программами, и эти программы обычно не зависят от Go-программы. Вместо этого следует сохранить лимит памяти, поскольку он может помочь сдержать нежелательное временное поведение, но установить GOGC на более маленькое, разумное значение для среднего случая.
Хотя может показаться заманчивым попытаться «зарезервировать» память для сопрограмм, если программы не полностью синхронизированы (например, Go-программа вызывает подпроцесс и блокируется, пока его вызываемая функция выполняется), результат будет менее надежным, поскольку в конечном итоге обе программы потребуют больше памяти. Позволив Go-программе использовать меньше памяти, когда она этого не требует, можно достичь более надежного результата в целом. Эта рекомендация также применима к ситуациям перераспределения (overcommit), когда суммарный лимит памяти контейнеров, запущенных на одной машине, превышает фактическую физическую память, доступную на этой машине.
-
Не следует использовать лимит памяти при развертывании в среде выполнения, которой вы не управляете, особенно если использование памяти вашей программы пропорционально входным данным.
Хороший пример — CLI-инструмент или настольное приложение. Встраивание лимита памяти в программу, когда неясно, какие именно входные данные она может получить или сколько памяти может быть доступно в системе, может привести к запутанным сбоям и плохой производительности. Кроме того, продвинутый конечный пользователь всегда может установить лимит памяти, если захочет.
-
Не следует устанавливать лимит памяти, чтобы избежать нехватки памяти, когда программа уже близка к пределам памяти среды выполнения.
Это эффективно заменяет риск нехватки памяти на риск серьезного замедления работы приложения, что часто не является предпочтительным компромиссом, даже с учетом усилий, предпринимаемых Go для смягчения эффекта thrashing (пагинационных колебаний). В такой ситуации было бы намного более эффективно либо увеличить лимиты памяти в среде выполнения (и только потом установить ограничение на использование памяти), либо уменьшить значение переменной GOGC (что обеспечивает гораздо более чистый компромисс по сравнению с мерами по смягчению thrashing).
Задержки (Latency)
Визуализации в данном документе моделируют приложение как приостановленное во время выполнения GC. Существуют реализации GC, которые ведут себя именно так, и их называют "stop-the-world" GC.
Однако GC в Go не является полностью stop-the-world и выполняет большую часть своей работы параллельно с работой приложения. Это делается в первую очередь для снижения задержек (latency) приложения. А именно, это касается общего времени выполнения единицы вычислений (например, веб-запроса). До сих пор в данном документе в основном рассматривалась пропускная способность (throughput) приложения (например, количество обработанных веб-запросов в секунду). Следует отметить, что каждый пример в разделе цикл GC сосредоточен на общем времени CPU, затраченном на выполнение программы. Однако такое время мало информативно для веб-сервиса. Хотя пропускная способность по-прежнему важна для веб-сервиса (например, количество запросов в секунду), часто задержка каждого отдельного запроса имеет еще большее значение.
Что касается задержек, то stop-the-world GC может потребовать значительного времени для выполнения своих фаз пометки и сборки мусора, в течение которых приложение, а в контексте веб-сервиса любой активный запрос, не может продолжать выполнение. В отличие от этого, GC в Go избегает пропорциональности длительности глобальных пауз приложения размеру кучи и выполняет основной алгоритм отслеживания (tracing) во время активного выполнения приложения. (Паузы зависят алгоритмически от значения GOMAXPROCS, но чаще всего они определяются временем, необходимым для остановки выполняющихся горутин.) Сборка мусора параллельно имеет свои издержки: на практике она часто приводит к снижению пропускной способности по сравнению с эквивалентным stop-the-world сборщиком мусора. Однако важно отметить, что меньшая задержка не означает автоматически меньшую пропускную способность, и производительность GC в Go постепенно улучшается как с точки зрения задержек, так и с точки зрения пропускной способности.
Параллельная природа текущего GC в Go не отменяет ничего из вышеизложенного: ни одно из утверждений не полагалось на этот выбор архитектуры. Частота запуска GC по-прежнему остается основным способом компромисса между временем CPU и памятью для достижения пропускной способности, и на самом деле, она также играет роль в оптимизации задержек. Это связано с тем, что большая часть затрат GC возникает во время активной фазы пометки (mark phase).
Таким образом, ключевым моментом является то, что снижение частоты запуска GC может также привести к улучшению задержек Это относится не только к сокращению частоты сборок мусора (GC) за счёт изменения параметров настройки, таких как увеличение GOGC и/или лимита памяти, но и к оптимизациям, описанным в руководстве по оптимизации.
Однако задержки (latency) часто сложнее понять, чем пропускная способность (throughput), потому что они представляют собой результат момента к моменту выполнения программы, а не просто агрегацию затрат. В результате, связь между задержками и частотой сборок мусора менее прямая. Ниже приведён список возможных источников задержек для тех, кто захочет углубиться в тему подробнее.
- Кратковременные остановки всего мира (stop-the-world) при переходе GC между фазами пометки (mark) и сбора (sweep),
- Задержки планировщика, поскольку GC использует 25% ресурсов процессора во время фазы пометки,
- Пользовательские горутины, участвующие в работе GC в ответ на высокую скорость выделения памяти,
- Записи указателей, требующие дополнительной работы во время фазы пометки GC, и
- Запущенные горутины должны быть приостановлены для сканирования их корней.
Эти источники задержек видны в трассах выполнения, кроме случаев, когда записи указателей требуют дополнительной работы.
Финализаторы, очистки и слабые указатели
Сборка мусора создаёт иллюзию бесконечной памяти, используя лишь конечный объём памяти. Память выделяется, но никогда не освобождается вручную, что позволяет использовать более простые API и конкурентные алгоритмы по сравнению с управлением памятью вручную. (Некоторые языки с ручным управлением памятью используют альтернативные подходы, такие как «умные указатели» и отслеживание владения на этапе компиляции, чтобы гарантировать освобождение объектов, но эти функции глубоко встроены в соглашения дизайна API этих языков.)
Только живые объекты — те, которые достижимы из глобальной переменной или вычисления в какой-либо горутине — могут влиять на поведение программы. В любой момент после того, как объект становится недостижимым ("мертвым"), он может быть безопасно переиспользован GC. Это позволяет реализовать широкий спектр подходов к GC, таких как трассирующий метод, используемый в Go сегодня. Смерть объекта не является наблюдаемым событием на уровне языка.
Однако стандартная библиотека времени выполнения Go предоставляет три функции, нарушающие эту иллюзию: очистки (cleanups), слабые указатели (weak pointers) и финализаторы (finalizers). Каждая из этих функций предоставляет способ наблюдать и реагировать на смерть объекта, а в случае с финализаторами — даже отменить её. Это, конечно, усложняет программы на Go и добавляет дополнительную нагрузку на реализацию GC. Тем не менее, эти функции существуют, потому что они полезны в различных ситуациях, и программы на Go постоянно используют их и получают от этого выгоду.
Для получения подробной информации о каждой функции обратитесь к документации соответствующего пакета (runtime.AddCleanup, weak.Pointer, runtime.SetFinalizer). Ниже приведены общие рекомендации по использованию этих возможностей, обзоры типичных проблем, с которыми можно столкнуться при работе с каждой из них, а также советы по тестированию использования этих функций.
Общие рекомендации
-
Пишите модульные тесты.
Точное время очистки, слабых указателей и финализаторов сложно предсказать, и легко убедить себя в том, что всё работает, даже после множества последовательных выполнений. Однако легко допустить тонкие ошибки. Написание тестов для них может быть непростым, но, учитывая их сложность, тестирование становится ещё более важным, чем обычно.
-
Избегайте прямого использования этих возможностей в типичном Go-коде.
Это низкоуровневые функции с тонкими ограничениями и поведением. Например, нет гарантии, что очистка или финализаторы будут выполнены при завершении программы или вообще. Длинные комментарии в документации API должны рассматриваться как предупреждение. Большинство Go-кода не получают выгоды от прямого использования этих возможностей, только косвенно.
-
Инкапсулируйте использование этих механизмов внутри пакета.
По возможности не позволяйте использовать эти механизмы проникать в публичный API вашего пакета; предоставляйте интерфейсы, которые делают использование их трудным или невозможным. Например, вместо того чтобы просить пользователя настроить очистку некоторой памяти, выделенной в C, для освобождения её, напишите обёртку и скройте эту деталь внутри.
-
Ограничьте доступ к объектам с финализаторами, очистками и слабыми указателями только внутри пакета, который создал и применил их.
Это связано с предыдущим пунктом, но стоит выделить явно, поскольку это очень мощный паттерн для использования этих возможностей менее подвержены ошибкам. Например, пакет unique использует слабые указатели внутри, но полностью инкапсулирует объекты, на которые они указывают. Эти значения никогда не могут быть изменены остальными частями приложения, их можно только копировать через метод Value, сохраняя иллюзию бесконечной памяти для пользователей пакета.
-
По возможности предпочитайте детерминированную очистку ресурсов, отличных от памяти, с финализаторами и очистками как запасным вариантом.
Очистка ресурсов и финализаторы хорошо подходят для управления памятью, такой как память, выделенная внешними средствами, например, из C, или ссылки на отображения
mmap. Память, выделенная с помощью malloc из C, должна быть освобождена с помощью free из C. Финализатор, вызывающийfree, привязанный к обёртке для C-памяти, является разумным способом обеспечить освобождение C-памяти в результате работы сборщика мусора.Однако, не-памятные ресурсы, такие как дескрипторы файлов, подвержены системным ограничениям, о которых среда выполнения Go обычно не имеет информации. Кроме того, время срабатывания сборщика мусора в конкретной программе Go обычно находится вне контроля автора пакета (например, частота запуска GC контролируется GOGC, которое может быть установлено операторами в различные значения на практике). Эти два факта совместно делают очистку и финализаторы непригодными для использования в качестве единственного механизма освобождения не-памятных ресурсов.
Если вы являетесь автором пакета, предоставляющего API, который обёртывает какие-либо не-памятные ресурсы, рассмотрите возможность предоставления явного API для детерминированного освобождения ресурса (через метод
Closeили аналогичный), вместо того чтобы полагаться на сборщик мусора через очистку или финализацию. Вместо этого предпочтительнее использовать очистку и финализацию как механизм "всё возможное" для обработки ошибок программиста, либо очищая ресурс в любом случае, как это делает os.File, либо сообщая об ошибке освобождения ресурса пользователю. -
Предпочтение очистке перед финализаторами.
Исторически, финализаторы были добавлены, чтобы упростить интерфейс между Go-кодом и C-кодом и для очистки не-памятных ресурсов. Предполагаемое использование заключалось в применении их к обёрточным объектам, которые владели C-памятью или каким-либо другим не-памятным ресурсом, чтобы ресурс мог быть освобождён, когда Go-код закончит его использовать. Эти причины, по крайней мере частично, объясняют, почему финализаторы имеют узкий охват, почему у любого объекта может быть только один финализатор, и почему этот финализатор должен быть привязан только к первому байту объекта. Это ограничение уже подавляет некоторые варианты использования. Например, любой пакет, который хочет внутренне кэшировать некоторую информацию об объекте, переданном ему, не может очистить эту информацию после того, как объект исчезнет.
Но хуже того, финализаторы неэффективны и подвержены ошибкам из-за того, что они возрождают объект, к которому они привязаны, чтобы передать его в функцию финализатора (и даже могут продолжать существовать после этого). Этот простой факт означает, что если объект является частью циклической ссылки, он никогда не может быть освобождён, и память, резервирующая объект, не может быть переиспользована до следующего цикла сборки мусора.
Поскольку финализаторы могут воскрешать объекты, они всё же имеют более чётко определённый порядок выполнения, чем очистки. По этой причине финализаторы по-прежнему потенциально (но редко) полезны для очистки структур, требующих сложного порядка уничтожения.
Но для всех остальных случаев использования в Go 1.24 и более поздних версиях мы рекомендуем использовать очистки, поскольку они более гибкие, менее подвержены ошибкам и более эффективны по сравнению с финализаторами.
Частые проблемы с очистками
-
Объекты с привязанными очистками не должны быть достижимы из функции очистки (например, через захваченную локальную переменную). Это предотвратит освобождение объекта и запуск очистки.
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(fd int) {
syscall.Close(f.fd) // Ошибка: мы ссылаемся на f, поэтому эта очистка не запустится!
}, f.fd)
Объекты с привязанными очистками не должны быть достижимы из аргумента функции очистки. Это предотвратит освобождение объекта и запуск очистки.
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(f *myFile) {
syscall.Close(f.fd)
}, f) // Ошибка: мы ссылаемся на f, поэтому эта очистка никогда не запустится. В данном конкретном случае также происходит паника.
Финализаторы имеют чётко определённый порядок выполнения, но очистки — нет. Очистки также могут выполняться параллельно друг с другом.
Длительные очистки должны создавать горутину, чтобы избежать блокировки выполнения других очисток.
runtime.GC не будет ждать завершения выполнения очисток для недоступных объектов, а лишь до момента
их постановки в очередь.
Частые проблемы со слабыми указателями
-
Слабые указатели могут начать возвращать
nilиз их методаValueв неожиданные моменты. Всегда защищайте вызовValueпроверкой наnilи имейте резервный план. -
Когда слабые указатели используются в качестве ключей карты, они не влияют на достижимость значений карты. Следовательно, если ключ карты, являющийся слабым указателем, указывает на объект, который также достижим из значения карты, этот объект по-прежнему будет считаться достижимым.
Частые проблемы с финализаторами
-
Объекты с привязанными финализаторами не должны быть достижимы из самих себя по любому пути (иными словами, они не могут находиться в цикле ссылок). Это предотвратит освобождение объекта и запуск финализатора.
f := new(myCycle)
f.self = f // Ошибка: f достижим из f, поэтому этот финализатор никогда не запустится.
runtime.SetFinalizer(f, func(f *myCycle) {
...
})
Объекты с прикреплёнными финализаторами не должны быть достижимы из функции финализатора (например, через захваченную локальную переменную). Это предотвратит освобождение объекта и запуск финализатора.
f := new(myFile)
f.fd = syscall.Open(...)
runtime.SetFinalizer(f, func(_ *myFile) {
syscall.Close(f.fd) // Ошибка: мы ссылаемся на внешний f, поэтому эта очистка так и не запустится!
})
Цепочки ссылок объектов с прикреплёнными финализаторами (например, в связном списке) требуют, по крайней мере, столь же много циклов сборки мусора, сколько объектов в цепочке, чтобы очистить их всех. Держите финализаторы поверхностными!
// Ошибка: очистка этого связного списка займёт как минимум 10 циклов GC.
node := new(linkedListNode)
for range 10 {
tmp := new(linkedListNode)
tmp.next = node
node = tmp
runtime.SetFinalizer(node, func(node *linkedListNode) {
...
})
}
Избегайте размещения финализаторов на объектах, возвращаемых через границы пакета.
Это позволяет пользователям вашего пакета вызвать runtime.SetFinalizer для изменения финализатора
на возвращаемом объекте, что может привести к неожиданному поведению, на котором пользователи пакета могут
начать полагаться.
Долговременные финализаторы должны создавать новую горутину, чтобы избежать блокировки выполнения других финализаторов.
runtime.GC не будет ожидать завершения выполнения финализаторов для недостижимых объектов, а лишь
дождётся, пока все они будут поставлены в очередь.
Тестирование смерти объекта
При использовании этих возможностей иногда бывает трудно написать тесты для кода, который их использует. Ниже приведены советы по написанию надёжных тестов для кода, использующего эти возможности.
- Избегайте запуска таких тестов параллельно с другими тестами. Это значительно повышает детерминированность и позволяет лучше контролировать состояние системы в любой момент времени.
-
Используйте
runtime.GCдля установления базовой точки при входе в тест. Используйтеruntime.GCдля принудительной установки слабых указателей вnil, а также для постановки в очередь операций очистки и финализаторов. -
runtime.GCне ждёт завершения выполнения очисток и финализаторов, он лишь ставит их в очередь.Чтобы написать максимально надёжные тесты, добавьте способ блокировки на очистке или финализаторе из теста (например, передавайте необязательный канал в очистку и/или финализатор из теста, и записывайте в канал после завершения выполнения). Если это слишком сложно или невозможно, можно использовать альтернативный способ — проверять определённое состояние после очистки. Например, тесты пакета
osвызываютruntime.Goschedв цикле, который проверяет, закрыт ли файл, как только он становится недостижимым. -
Если вы пишете тесты с использованием финализаторов, и у вас есть цепочка объектов, использующих финализаторы, то для того, чтобы гарантировать выполнение всех финализаторов, вам потребуется как минимум количество вызовов
runtime.GC, равное длине самой длинной цепочки, которую может создать тест. -
Выполняйте тестирование в режиме гонок (race mode), чтобы выявить гонки между параллельными процедурами очистки, а также между кодом очистки и финализаторами и остальной частью кодовой базы.
Дополнительные ресурсы
Информация, представленная выше, точна, но она не содержит достаточных деталей для полного понимания затрат и компромиссов в дизайне GC в Go. Для получения дополнительной информации обратитесь к следующим ресурсам.
- The GC Handbook—Отличный общий ресурс и справочник по дизайну сборщика мусора.
- TCMalloc—Документ, описывающий дизайн C/C++ аллокатора памяти TCMalloc, на котором основан аллокатор памяти в Go.
- Объявление Go 1.5 GC—Пост в блоге, объявляющий о параллельном GC в Go 1.5, который подробно описывает алгоритм.
- Getting to Go—Глубокая презентация о развитии дизайна GC в Go до 2018 года.
- Go 1.5 concurrent GC pacing—Документ, описывающий дизайн определения момента запуска фазы параллельного маркирования.
- Smarter scavenging—Документ, описывающий переработку способа, которым среда выполнения Go возвращает память операционной системе.
- Scalable page allocator—Документ, описывающий переработку способа управления памятью, полученной от операционной системы, средой выполнения Go.
- GC pacer redesign (Go 1.18)—Документ, описывающий переработку алгоритма определения момента запуска фазы параллельного маркирования.
- Soft memory limit (Go 1.19)—Документ, описывающий мягкое ограничение памяти.
Примечание о виртуальной памяти
В данном руководстве в основном рассматривается использование физической памяти GC, но часто возникает вопрос: что
именно это означает и как это соотносится с виртуальной памятью (обычно отображается в программах вроде
top как "VSS").
Физическая память — это память, находящаяся на самом деле в физических чипах ОЗУ в большинстве компьютеров. Виртуальная память — это абстракция над физической памятью, предоставляемая операционной системой для изоляции программ друг от друга. Также обычно приемлемо резервировать виртуальное адресное пространство, которое не отображается ни на какие физические адреса.
Поскольку виртуальная память — это просто отображение, поддерживаемое операционной системой, обычно очень дешево делать большие резервы виртуальной памяти, которые не отображаются на физическую память.
Среда выполнения Go в нескольких аспектах полагается на это представление о стоимости виртуальной памяти:
-
Среда выполнения Go никогда не удаляет виртуальную память, которую она отображает. Вместо этого она использует специальные операции, которые предоставляют большинство операционных систем, чтобы явно освободить любые ресурсы физической памяти, связанные с некоторым диапазоном виртуальной памяти.
Этот подход используется явно для управления пределом памяти и возврата памяти в операционную систему, которую среда выполнения Go больше не нуждается. Среда выполнения Go также постоянно освобождает память, которую больше не требует, в фоновом режиме. См. дополнительные ресурсы для получения дополнительной информации.
-
В 32-битных платформах среда выполнения Go заранее резервирует от 128 МиБ до 512 МиБ адресного пространства для кучи, чтобы ограничить проблемы фрагментации.
-
Среда выполнения Go использует большие резервы виртуального адресного пространства в реализации нескольких внутренних структур данных. В 64-битных платформах они обычно имеют минимальный виртуальный размер памяти около 700 МиБ. В 32-битных платформах их размер пренебрежимо мал.
В результате метрики виртуальной памяти, такие как "VSS" в top,
обычно не очень полезны для понимания объема памяти, используемого программой на Go.
Вместо этого следует обращать внимание на "RSS" и аналогичные показатели,
которые более напрямую отражают использование физической памяти.
Руководство по оптимизации
Определение затрат
Прежде чем пытаться оптимизировать взаимодействие вашего приложения на Go с GC, важно сначала выявить, что GC действительно является значительной нагрузкой.
Экосистема Go предоставляет множество инструментов для выявления затрат и оптимизации приложений на Go. Для краткого обзора этих инструментов см. руководство по диагностике. Здесь мы сосредоточимся на подмножестве этих инструментов и разумном порядке их применения для понимания влияния и поведения GC.-
Профилирование CPU
Хорошим началом будет профилирование CPU. Профилирование CPU предоставляет обзор того, где тратится время процессора, однако для неопытного наблюдателя может быть трудно определить масштаб роли GC в конкретном приложении. К счастью, понимание того, как GC участвует, в основном сводится к знанию значений различных функций пакета `runtime`. Ниже приведена полезная часть этих функций для интерпретации профилей CPU.
Обратите внимание, что перечисленные ниже функции не являются листовыми функциями, поэтому они могут не отображаться в стандартном выводе инструмента
pprof, предоставляемого командойtop. Вместо этого используйте командуtop -cumили выполните командуlistнепосредственно для этих функций и сосредоточьтесь на столбце с накопленным процентом.
-
runtime.gcBgMarkWorker: Точка входа в фоновые рабочие горутины маркировки. Время, затраченное здесь, зависит от частоты сборки мусора и сложности и размера графа объектов. Оно представляет собой базовый уровень времени, которое приложение тратит на маркировку и сканирование.Обратите внимание, что внутри этих горутин можно найти вызовы
runtime.gcDrainMarkWorkerDedicated,runtime.gcDrainMarkWorkerFractionalиruntime.gcDrainMarkWorkerIdle, указывающие на тип рабочей горутины. В случае в основном бездействующего приложения на Go сборщик мусора будет использовать дополнительные (простаивающие) ресурсы ЦП для завершения своей работы быстрее, что отражается символомruntime.gcDrainMarkWorkerIdle. В результате время здесь может составлять большую долю от выборки ЦП, которые GC считает свободными. Если приложение станет более активным, время ЦП, затраченное в простаивающих рабочих горутинах, уменьшится. Одной из частых причин этого может быть то, что приложение выполняется полностью в одной горутине, ноGOMAXPROCSбольше 1. -
runtime.mallocgc: Точка входа в аллокатор памяти для кучи. Значительное накопленное время, затраченное здесь (>15%), обычно указывает на большое количество выделяемой памяти. -
runtime.gcAssistAlloc: Функция, в которую входят горутины, чтобы отдать часть своего времени в помощь GC при сканировании и маркировке. Значительное накопленное время, затраченное здесь (>5%), указывает на то, что приложение, вероятно, опережает GC по скорости выделения памяти. Это указывает на высокий уровень влияния GC, а также представляет время, затраченное приложением на маркировку и сканирование. Обратите внимание, что эта функция включена в дерево вызововruntime.mallocgc, поэтому она также увеличивает его.
Трассировки выполнения
Хотя профили CPU отлично подходят для определения, где тратится время в агрегированном виде, они менее полезны для выявления более тонких, редких или связанных с задержкой затрат производительности. Трассировки выполнения, напротив, предоставляют богатый и глубокий обзор короткого окна выполнения программы на Go. Они содержат множество событий, связанных с GC Go, и можно напрямую наблюдать за конкретными путями выполнения, а также за тем, как приложение может взаимодействовать с GC Go. Все отслеживаемые события GC удобно помечены в просмотрщике трассировок.
См. документацию по пакету
runtime/trace о том, как начать работу с трассировками выполнения.
Трассировка GC
Когда всё остальное не помогает, сборщик мусора Go предоставляет несколько различных специфичных трассировок,
которые предоставляют гораздо более глубокое понимание поведения GC. Эти трассировки всегда выводятся
непосредственно в STDERR, по одной строке на цикл GC, и настраиваются через переменную окружения
GODEBUG, которую распознают все программы на Go. Они в основном полезны для отладки самого сборщика
мусора Go, поскольку требуют некоторого понимания особенностей реализации GC, но всё же могут иногда быть
полезны для лучшего понимания поведения GC.
Основная трассировка GC включается установкой GODEBUG=gctrace=1. Вывод, производимый этой
трассировкой, документирован в разделе переменных
окружения документации пакета runtime.
Дополнительная трассировка GC под названием "трассировка пейсера" предоставляет ещё более глубокие сведения и
включается установкой GODEBUG=gcpacertrace=1. Интерпретация этого вывода требует понимания
"пейсера" GC (см. дополнительные ресурсы), которое выходит за рамки данного
руководства.
Устранение выделений в куче
Один из способов снизить нагрузку на GC — это заставить GC управлять меньшим количеством значений с самого начала. Описанные ниже техники могут обеспечить некоторые из самых значительных улучшений производительности, поскольку, как показало раздел GOGC, скорость выделения в программе на Go является ключевым фактором частоты GC, который используется в этом руководстве как основной метрический показатель затрат.
Профилирование кучи
После выявления, что GC является источником значительных затрат, следующим шагом при устранении выделений в куче является выяснение, откуда происходят основные из них. Для этой цели очень полезны профили памяти (на самом деле, это профили памяти кучи). Ознакомьтесь с документацией о том, как начать с ними работать.
Профили памяти описывают, откуда в программе происходят выделения в куче, идентифицируя их по трассировке стека на момент выделения. Каждый профиль памяти может разбивать память четырьмя способами.
inuse_objects—Разбивает количество активных объектов.inuse_space—Разбивает активные объекты по количеству используемой ими памяти в байтах.alloc_objects—Разбивает количество объектов, которые были выделены с момента начала выполнения программы на Go.alloc_space—Разбивает общее количество памяти, выделенной с момента начала выполнения программы на Go.
Переключение между различными представлениями памяти кучи может осуществляться с помощью флага
-sample_index у инструмента pprof или через опцию sample_index при
интерактивном использовании инструмента.
Примечание: профили памяти по умолчанию анализируют только подмножество объектов кучи, поэтому они не будут
содержать информации о каждой отдельной аллокации в куче.
Однако этого достаточно для выявления горячих точек.
Чтобы изменить частоту выборки, см.
runtime.MemProfileRate.
В целях снижения затрат на сборку мусора, alloc_space обычно является
наиболее полезным представлением, поскольку оно напрямую соответствует скорости выделения памяти.
Это представление покажет горячие точки выделения, которые принесут наибольшую пользу.
Анализ утечки (escape analysis)
После того как потенциальные места выделения в куче были определены с помощью профилей кучи, как можно их устранить? Ключ в использовании анализа утечки памяти компилятором Go, который позволяет компилятору найти альтернативное и более эффективное место хранения этой памяти, например, в стеке горутины. К счастью, компилятор Go может описывать, почему он решает переместить значение Go в кучу. Зная это, становится вопросом перестройки исходного кода таким образом, чтобы изменить результат анализа (что часто является самым сложным этапом, но выходит за рамки данного руководства).
Что касается доступа к информации из анализа утечки памяти компилятора Go, самый простой способ — использовать флаг
отладки, поддерживаемый компилятором Go, который описывает все оптимизации, применённые или не применённые к
какому-либо пакету, в текстовом формате.
Это включает информацию о том, уходит ли значение в кучу или нет.
Попробуйте выполнить следующую команду, где [package] — это путь к какому-либо пакету Go.
$ go build -gcflags=-m=3 [package]
Эта информация также может быть визуализирована как оверлей в редакторе с поддержкой LSP; она предоставляется как код-действие. Например, в VS Code вызовите команду "Source Action... > Show compiler optimization details", чтобы включить диагностику для текущего пакета. (Также можно выполнить команду "Go: Toggle compiler optimization details".) Используйте следующую настройку конфигурации для управления отображением аннотаций:
-
Включите оверлей для анализа утечки, установив
ui.diagnostic.annotationsтак, чтобы включитьescape.
Наконец, компилятор Go предоставляет эту информацию в машиночитаемом (JSON) формате, который может быть использован для создания дополнительных пользовательских инструментов. Для получения дополнительной информации см. документацию в исходном коде Go.
Оптимизации, зависящие от реализации
Сборщик мусора (GC) в Go чувствителен к демографии живой памяти, поскольку сложный граф объектов и указателей как ограничивает параллелизм, так и создает дополнительную нагрузку для GC. В результате, GC содержит несколько оптимизаций для специфичных общих структур. Наиболее напрямую полезные для оптимизации производительности перечислены ниже.
Примечание: Применение приведённых ниже оптимизаций может снизить читаемость кода за счёт затруднения понимания намерений и может не сохраняться между релизами Go. Предпочтительно применять такие оптимизации только в тех местах, где они наиболее важны. Такие места могут быть выявлены с помощью инструментов, перечисленных в разделе по выявлению затрат.
-
Значения без указателей выделяются отдельно от других значений.
В результате, может быть выгодно исключить указатели из структур данных, которые не строго требуют их наличия, поскольку это снижает нагрузку на кэш, оказываемую GC на программу. Структуры данных, основанные на индексах вместо указательных значений, хотя и менее типизированы, могут работать быстрее. Это имеет смысл только в том случае, если ясно, что граф объектов сложный, и GC тратит много времени на маркировку и сканирование.
-
GC останавливает сканирование значений на последнем указателе в значении.
В результате, может быть выгодно размещать поля-указатели в структурных значениях в начале значения. Это имеет смысл только в том случае, если ясно, что приложение тратит много времени на маркировку и сканирование. (В теории компилятор может делать это автоматически, но пока это не реализовано, и поля структур располагаются в соответствии с исходным кодом.)
Кроме того, GC должен взаимодействовать почти с каждым указателем, который он видит, поэтому использование индексов в срезе, например, вместо указателей, может помочь снизить затраты на сборку мусора.
Прозрачные огромные страницы в Linux (THP)
Когда программа обращается к памяти, процессору необходимо преобразовать виртуальные адреса, которые она использует, в физические адреса памяти, соответствующие данным, к которым она пытается получить доступ. Для этого процессор обращается к «таблице страниц» — структуре данных, представляющей отображение виртуальной памяти в физическую, управляемую операционной системой. Каждая запись в таблице страниц представляет собой неделимый блок физической памяти, называемый страницей, отсюда и название.
Прозрачные огромные страницы (THP) — это функция Linux, которая прозрачно заменяет страницы физической памяти, используемые для непрерывных виртуальных регионов, более крупными блоками памяти, называемыми огромными страницами. Используя большие блоки, требуется меньше записей в таблице страниц для представления одного и того же региона памяти, что улучшает время поиска в таблице страниц. Однако большие блоки означают большую потерю, если только небольшая часть огромной страницы используется системой.
Когда вы запускаете программы на Go в production-среде, включение прозрачных больших страниц (transparent huge pages, THP) в Linux может повысить пропускную способность и снизить задержки за счёт увеличения использования памяти. Приложения с небольшими кучами (heaps) редко получают выгоду от THP и могут использовать значительное количество дополнительной памяти (до 50%). Однако приложения с большими кучами (1 ГиБ и выше) часто получают существенную выгоду (до 10% в плане пропускной способности) при минимальном увеличении нагрузки на память (1–2% или менее). Знание текущих настроек THP в любом случае может быть полезным, и экспериментирование всегда рекомендуется.
В Linux можно включить или отключить прозрачные большие страницы, изменив файл
/sys/kernel/mm/transparent_hugepage/enabled.
Для получения дополнительной информации см.
официальное руководство администратора
Linux.
Если вы решите включить прозрачные большие страницы в вашей production-среде на Linux, мы рекомендуем следующие
дополнительные настройки для программ на Go.
-
Установите значение
/sys/kernel/mm/transparent_hugepage/defragравнымdeferилиdefer+madvise.
Эта настройка управляет степенью агрессивности ядра Linux при объединении обычных страниц в большие.deferуказывает ядру объединять большие страницы лениво и в фоне. Более агрессивные настройки могут вызывать задержки в системах с ограниченной памятью и часто негативно сказываются на задержках работы приложений.defer+madviseпохож наdefer, но более дружелюбен к другим приложениям в системе, которые явно запрашивают большие страницы и требуют их для повышения производительности. -
Установите значение
/sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_noneравным0.
Эта настройка контролирует, сколько дополнительных страниц может выделить демон ядра Linux при попытке выделить большую страницу. Значение по умолчанию максимально агрессивно и часто отменяет работу, которую делает среда выполнения Go для возврата памяти в ОС. До Go 1.21 среда выполнения Go пыталась смягчить негативные последствия этой настройки по умолчанию, но это сопровождалось дополнительной нагрузкой на процессор. Начиная с Go 1.21+ и Linux 6.2+, среда выполнения Go больше не изменяет состояние больших страниц.
Если после обновления до Go 1.21.1 или новее вы наблюдаете увеличение использования памяти, попробуйте применить эту настройку; вероятно, она решит вашу проблему. В качестве дополнительного обходного пути можно вызвать функциюPrctlс параметромPR_SET_THP_DISABLE, чтобы отключить большие страницы на уровне процесса, или установитьGODEBUG=disablethp=1(будет добавлено в Go 1.21.6 и Go 1.22), чтобы отключить большие страницы для памяти кучи. Обратите внимание, что настройкаGODEBUGможет быть удалена в будущих релизах.
Приложение
Дополнительные замечания о GOGC
Было утверждено, что удвоение значения GOGC приводит к удвоению переполнения памяти кучи и вдвое снижает затраты на CPU при сборке мусора. Чтобы понять, почему это так, рассмотрим это математически.
Во-первых, целевое значение кучи устанавливает цель для общего размера кучи. Однако это значение в основном влияет на новую память кучи, поскольку живая куча является основой приложения.
Целевая память кучи = Живая куча + (Живая куча + GC корни) * GOGC / 100
Общая память кучи = Живая куча + Новая память кучи
⇒
Новая память кучи = (Живая куча + GC корни) * GOGC / 100
Из этого следует, что удвоение значения GOGC также приведёт к удвоению объёма новой памяти кучи, который будет выделяться при каждом цикле сборки мусора, что отражает издержки памяти кучи. Обратите внимание, что Живая куча + GC корни — это приблизительное значение объёма памяти, который GC должен просканировать.
Далее рассмотрим затраты на CPU при сборке мусора. Общие затраты можно разбить на затраты за цикл, умноженные на частоту сборок мусора в течение некоторого времени T.
Общие затраты на CPU GC = (Затраты на CPU за цикл) * (Частота GC) * T
Затраты на CPU GC за цикл можно вывести из модели GC:
Затраты на CPU GC за цикл = (Живая куча + GC корни) * (Затраты за байт) + Фиксированные затраты
Обратите внимание, что затраты на фазу очистки (sweep) здесь игнорируются, поскольку затраты на пометку и сканирование доминируют.
Стационарное состояние определяется постоянной скоростью выделения памяти и постоянными затратами на байт, поэтому в стационарном состоянии мы можем вывести частоту GC из этой новой памяти кучи:
Частота GC = (Скорость выделения) / (Новая память кучи) = (Скорость выделения) / ((Живая куча + GC корни) * GOGC / 100)
Объединяя всё вместе, получаем полную формулу для общих затрат:
Общие затраты на CPU GC = (Скорость выделения) / ((Живая куча + GC корни) * GOGC / 100) * ((Живая куча + GC корни) * (Затраты за байт) + Фиксированные затраты) * T
Для достаточно большой кучи (что соответствует большинству случаев), предельные затраты цикла GC доминируют над фиксированными затратами. Это позволяет значительно упростить формулу общих затрат на CPU GC.
Общие затраты на CPU GC = (Скорость выделения) / (GOGC / 100) * (Затраты за байт) * T
Из этой упрощённой формулы видно, что если удвоить значение GOGC, то общие затраты на CPU GC уменьшатся вдвое. (Обратите внимание, что визуализации в этом руководстве моделируют фиксированные затраты, поэтому затраты на CPU GC, сообщаемые ими, не будут ровно вдвое меньше при удвоении GOGC.) Кроме того, затраты на CPU GC в основном зависят от скорости выделения и стоимости сканирования памяти за байт. Для получения дополнительной информации о том, как уменьшить эти затраты, см. руководство по оптимизации.
Примечание: существует расхождение между размером живой кучи и количеством памяти, которое сборщику мусора действительно необходимо просканировать: одна и та же по размеру живая куча с другой структурой приведёт к разным затратам процессора, но одинаковым затратам памяти, что вызывает разный компромисс. Вот почему структура кучи является частью определения стационарного состояния. Целевое значение кучи, вероятно, должно включать только сканируемую живую кучу, чтобы получить более точное приближение к памяти, которую сборщику мусора нужно просканировать, однако это приводит к дегенеративному поведению, когда сканируемая живая куча очень мала, но сама живая куча в остальном велика.