Команда Go for Devs подготовила перевод статьи о том, как работает сборщик мусора в Go. Автор подробно объясняет семантику алгоритма трёхцветной маркировки и очистки, механизмы Stop The World, пейсинг и источники задержек. Главное — не бороться со сборщиком, а работать с ним в унисон: устранять лишние выделения и снижать нагрузку на кучу.
Эта статья была впервые опубликована в 2018 году, но её ключевые идеи о модели сборки мусора в Go до сих пор остаются актуальными для разработчиков. Хотя некоторые детали реализации рантайма Go со временем изменились, базовые концепции, рассмотренные здесь — такие как семантика алгоритма трёхцветной маркировки и очистки, события Stop The World (STW) и интерпретация трассировок GC — по-прежнему необходимы для понимания того, как Go управляет памятью. Независимо от того, оптимизируете ли вы производительность или хотите глубже разобраться во внутреннем устройстве Go, этот пост по-прежнему остаётся ясным и практичным руководством о том, как работать вместе со сборщиком мусора, а не против него.
Прелюдия
Это первая статьи из серии, в которой мы разберём механику и семантику работы сборщика мусора в Go. Сегодня наше внимание внимание будет сосредоточено на базовых принципах и семантике работы сборщика мусора.
Введение
Сборщик мусора отвечает за отслеживание выделений памяти в куче, освобождение тех участков, которые больше не нужны, и сохранение тех, что всё ещё используются. То, как именно язык реализует это поведение, довольно сложно, но при этом разработчикам приложений не обязательно знать все детали, чтобы создавать софт. Кроме того, с новыми версиями виртуальной машины или рантайма язык постоянно меняется, а вместе с ним и такие системы. Для разработчиков важно иметь рабочую модель того, как ведёт себя сборщик мусора в их языке, и уметь работать в согласии с этим поведением, не вдаваясь в детали реализации.
Начиная с версии 1.12, язык Go использует нетрадационный конкурентный сборщик мусора с трёхцветной маркировкой и очисткой. Если хотите визуально понять, как работает такой сборщик, Кен Фокс написал отличную статью с анимацией. Реализация сборщика в Go менялась и совершенствовалась с каждым релизом. Поэтому любые публикации о деталях реализации быстро теряют актуальность, как только выходит новая версия языка.
С учётом этого в этой статье мы не будем углубляться в детали реализации. Мы сосредоточимся на том поведении, с которым вы столкнётесь на практике и которое можно ожидать в будущем. Я покажу, как именно работает сборщик мусора и как писать код так, чтобы учитывать его поведение — независимо от того, каким образом он реализован сейчас или каким станет позже. Это сделает вас более сильным Go-разработчиком.
Примечание: вот ещё немного полезных материалов для чтения о сборщиках мусора и непосредственно о том, как устроен сборщик в Go.
Куча — это не контейнер
Я никогда не буду называть кучу контейнером, в который можно складывать или из которого можно забирать значения. Важно понимать, что не существует какой-то линейной структуры памяти, которая определяет «кучу». Считайте, что любая память, зарезервированная для использования приложением в пространстве процесса, доступна для размещения объектов в куче. То, где именно — в виртуальной или физической памяти — окажется то или иное выделение, не имеет значения для нашей модели. Это понимание поможет лучше разобраться, как работает сборщик мусора.
Поведение сборщика
Когда начинается сборка мусора, сборщик проходит через три фазы работы. Две из этих фаз вызывают паузы Stop The World (STW), а одна снижает пропускную способность приложения. Эти три фазы:
Подготовка к маркировке — STW
Маркировка — конкурентная
Завершение маркировки — STW
Разберём каждую фазу подробнее.
Подготовка к маркировке — STW
Когда начинается сборка мусора, первым делом необходимо включить Write Barrier. Write Barrier нужен для того, чтобы сборщик мог поддерживать целостность данных в куче во время сборки, так как и сборщик, и горутины приложения будут работать одновременно.
Чтобы включить Write Barrier, нужно остановить все работающие горутины приложения. Обычно это происходит очень быстро — в среднем за 10–30 микросекунд. Разумеется, если горутины приложения ведут себя корректно.

На рисунке 1 показаны 4 горутины приложения, работающие до начала сборки. Каждую из этих горутин нужно остановить. Единственный способ сделать это — заставить сборщик ждать, пока каждая горутина не выполнит вызов функции. Вызовы функций гарантируют, что горутина находится в безопасной точке, где её можно остановить. Но что будет, если одна из горутин не сделает вызов функции, а остальные уже сделают?

На рисунке 2 показана реальная проблема. Сборка не может начаться, пока не будет остановлена горутина, работающая на P4, а этого не происходит, так как она застряла в бесконечном цикле вычислений.
//Листинг 1
func add(numbers []int) int {
var v int
for _, n := range numbers {
v += n
}
return v
}
В листинге 1 показан код, который выполняет горутина на P4. В зависимости от размера slice горутина может работать неоправданно долго, не предоставляя возможности её остановить. Именно такой код способен заблокировать запуск сборки. Хуже того, остальные P в это время не могут обслуживать другие горутины, пока сборщик ждёт. Поэтому крайне важно, чтобы горутины выполняли вызовы функций в разумные промежутки времени.
Примечание: команда разработчиков языка планировала исправить эту проблему в версии 1.14, добавив в планировщик приём предвыборочного прерывания (preemptive techniques).
Маркировка — конкурентная
После включения Write Barrier сборщик переходит к фазе маркировки. Первое, что он делает, — забирает себе 25% доступной вычислительной мощности процессора. Для работы сборщик использует горутины, которым нужны те же P и M, что и горутинам приложения. Это значит, что в нашей четырёхпоточной программе на Go целый один P будет полностью занят работой сборщика.

На рисунке 3 показано, как сборщик занял себе P1 во время работы. Теперь он может начать фазу маркировки. Эта фаза состоит в том, чтобы пометить значения в куче, которые всё ещё используются. Работа начинается с анализа стеков всех существующих горутин, чтобы найти корневые указатели на объекты в куче. Затем сборщик проходит по графу памяти кучи, начиная от этих корневых указателей. Пока маркировка выполняется на P1, приложение продолжает работать параллельно на P2, P3 и P4. Это означает, что влияние сборщика ограничивается лишь 25% текущей вычислительной мощности процессора.
Хотелось бы, чтобы на этом всё заканчивалось, но это не так. Что если во время сборки окажется, что горутина GC на P1 не успеет завершить маркировку до того, как используемая память в куче достигнет предела? А что если виновником окажется только одна из трёх горутин, выполняющих работу приложения? В этом случае новые выделения памяти придётся замедлить — и именно для этой горутины.

На рисунке 4 показано, как горутина приложения, работающая на P3, теперь выполняет Mark Assist и помогает сборщику. В идеале остальные горутины приложения не должны подключаться. Но в приложениях с интенсивным выделением памяти большинство работающих горутин может быть вынуждено выполнять небольшие объёмы Mark Assist во время сборки.
Одна из целей сборщика — минимизировать необходимость в Mark Assist. Если во время конкретной сборки приходится слишком часто привлекать горутины к этой работе, сборщик может запустить следующую сборку мусора раньше. Это делается для того, чтобы сократить количество Mark Assist при последующей сборке.
Завершение маркировки — STW
Когда работа по маркировке завершается, начинается фаза завершения маркировки. В этот момент Write Barrier отключается, выполняются различные операции по очистке и рассчитывается цель для следующей сборки. Горутины, застрявшие в плотном цикле во время фазы маркировки, могут также привести к увеличению задержек STW на этом этапе.
Если сборщик решает, что необходимо замедлить выделения в памяти, он привлекает горутины приложения к помощи в маркировке. Этот процесс называется Mark Assist. Время, которое горутина проведёт в Mark Assist, пропорционально объёму данных, которые она добавляет в кучу. Один положительный побочный эффект Mark Assist состоит в том, что он помогает быстрее завершить сборку.

На рисунке 5 показано, что все горутины останавливаются на время завершения фазы маркировки. Обычно это занимает в среднем 60–90 микросекунд. Теоретически эту фазу можно было бы выполнить и без STW, но использование STW упрощает код, а дополнительная сложность не стоит небольшой выгоды.
Когда сборка завершается, все P снова могут использоваться горутинами приложения, и оно возвращается к работе на полную мощность.

На рисунке 6 показано, что все доступные P снова заняты обработкой работы приложения после завершения сборки. Программа продолжает выполняться так же активно, как и до запуска сборщика.
Очистка (Sweeping) — конкурентная
После завершения сборки есть ещё одна стадия, называемая очисткой (Sweeping). Очистка — это процесс возврата памяти, связанной со значениями в куче, которые не были помечены как используемые. Эта работа выполняется, когда горутины приложения пытаются выделить новые значения в куче. Задержка от очистки добавляется к стоимости операции выделения памяти в куче и никак не связана с задержками самой сборки мусора.
Ниже приведён пример трассировки на моей машине с 12 аппаратными потоками, доступными для выполнения горутин.

На рисунке 7 показан видно, что во время сборки (область под синей линией GC сверху) три из двенадцати P заняты работой сборщика. Также видно, что горутины 2450, 1978 и 2696 в этот момент выполняют небольшие объёмы работы Mark Assist, а не своё приложение. В самом конце сборки только один P остаётся закреплён за GC и выполняет работу STW (завершение маркировки).
После завершения сборки приложение снова работает на полную мощность. Но теперь можно заметить множество розовых линий под этими горутинами.

На рисунке 8 показано, что розовые линии представляют моменты, когда горутина выполняет работу по очистке (Sweeping), а не свою основную задачу. Такие моменты возникают, когда горутина пытается выделить новые значения в куче.

На рисунке 9 показан конец стека вызовов одной из горутин в процессе очистки. Вызов runtime.mallocgc
соответствует выделению нового значения в куче. Вызов runtime.(*mcache).nextFree
инициирует работу по очистке. Когда в куче больше не остаётся памяти для освобождения, вызовы nextFree
прекращаются.
Описанное выше поведение сборщика происходит только тогда, когда сборка уже запущена и выполняется. Большую роль в том, когда начинается сборка, играет параметр конфигурации GC Percentage.
GC Percentage
В рантайме есть конфигурационный параметр GC Percentage, по умолчанию установленный в значение 100. Этот параметр задаёт соотношение, определяющее, сколько новой памяти в куче можно выделить до того, как придётся запускать следующую сборку. Если GC Percentage установлен в 100, это означает, что после завершения сборки, когда известно количество используемой памяти, новая сборка начнётся при добавлении в кучу не более чем ещё 100% от этого объёма.
Например, представим, что после завершения сборки в куче остаётся 2 МБ используемой памяти.
Примечание: диаграммы памяти кучи в этой статье не отражают реального профиля работы Go. На практике куча в Go часто бывает фрагментированной и неаккуратной, без чётких границ, показанных на рисунках. Эти диаграммы нужны лишь для того, чтобы проще визуализировать память кучи и лучше понять поведение, с которым вы столкнётесь.

На рисунке 10 показаны 2 МБ памяти в куче, которые остались заняты после завершения последней сборки. Так как GC Percentage установлен в 100%, следующая сборка должна начаться при добавлении в кучу ещё 2 МБ памяти или раньше.

На рисунке 11 видно, что в куче уже занято ещё 2 МБ памяти. Это приведёт к запуску сборки. Один из способов увидеть всё это в действии — сгенерировать трассировку GC для каждой происходящей сборки.
GC Trace
Трассировку GC можно получить, установив переменную окружения GODEBUG
с параметром gctrace=1
при запуске любого Go-приложения. Каждый раз, когда выполняется сборка, рантайм будет записывать информацию о ней в stderr
.
//Листинг 2
GODEBUG=gctrace=1 ./app
gc 1405 @6.068s 11%: 0.058+1.2+0.083 ms clock, 0.70+2.5/1.5/0+0.99 ms cpu, 7->11->6 MB, 10 MB goal, 12 P
gc 1406 @6.070s 11%: 0.051+1.8+0.076 ms clock, 0.61+2.0/2.5/0+0.91 ms cpu, 8->11->6 MB, 13 MB goal, 12 P
gc 1407 @6.073s 11%: 0.052+1.8+0.20 ms clock, 0.62+1.5/2.2/0+2.4 ms cpu, 8->14->8 MB, 13 MB goal, 12 P
В листинге 2 показано, как использовать переменную GODEBUG
для генерации трассировок GC. Также приведены 3 примера трассировок, полученных во время выполнения Go-приложения.
Далее разберём, что означает каждое значение в строке трассировки GC, на примере первой строки.
//Листинг 3
gc 1405 @6.068s 11%: 0.058+1.2+0.083 ms clock, 0.70+2.5/1.5/0+0.99 ms cpu, 7->11->6 MB, 10 MB goal, 12 P
// Общие данные
gc 1404 : 1404-я сборка с момента запуска программы
@6.068s : прошло 6 секунд с момента запуска программы
11% : 11% доступного CPU времени с момента запуска было потрачено на GC
// Время по часам (Wall-Clock)
0.058ms : STW : начало маркировки — включение Write Barrier
1.2ms : Concurrent : маркировка
0.083ms : STW : завершение маркировки — отключение Write Barrier и очистка
// Время CPU
0.70ms : STW : начало маркировки
2.5ms : Concurrent : Mark Assist (GC выполняется вместе с выделением памяти)
1.5ms : Concurrent : работа фонового GC во время маркировки
0ms : Concurrent : простаивающий GC во время маркировки
0.99ms : STW : завершение маркировки
// Память
7MB : объём памяти в куче, используемой до начала маркировки
11MB : объём памяти в куче, используемой после завершения маркировки
6MB : объём памяти в куче, помеченной как «живая» после маркировки
10MB : целевой объём памяти в куче после завершения маркировки
// Потоки
12P : количество логических процессоров или потоков, используемых для выполнения горутин
Листинг 3 показывает реальные данные из первой строки трассировки GC с расшифровкой значений. Позже мы разберём большинство этих чисел, но пока сосредоточимся на секции, связанной с памятью, для трассы 1405.

//Листинг 4
// Память
7MB : объём памяти в куче, используемой до начала маркировки
11MB : объём памяти в куче, используемой после завершения маркировки
6MB : объём памяти в куче, помеченной как «живая» после маркировки
10MB : целевой объём памяти в куче после завершения маркировки
Эта строка трассировки GC говорит нам следующее: до начала маркировки в куче использовалось 7 МБ памяти. Когда маркировка завершилась, используемый объём увеличился до 11 МБ. Это значит, что во время сборки произошло ещё 4 МБ выделений. После завершения маркировки сборщик определил, что «живой» остаётся 6 МБ памяти. Следовательно, приложение может увеличить использование кучи до 12 МБ (100% от живого размера в 6 МБ) до следующей сборки.
Можно заметить, что сборщик немного не уложился в цель: после маркировки было занято 11 МБ вместо 10 МБ. Это нормально, потому что цель рассчитывается исходя из текущего объёма используемой памяти, размера «живой» памяти и предположений о дополнительных выделениях, которые произойдут во время работы сборки. В данном случае приложение сделало что-то, что потребовало большего объёма памяти, чем ожидалось.
Если взглянуть на следующую картинку и листинг, видно, как всё изменилось всего за 2 миллисекунды.

//Листинг 5
gc 1406 @6.070s 11%: 0.051+1.8+0.076 ms clock, 0.61+2.0/2.5/0+0.91 ms cpu, 8->11->6 MB, 13 MB goal, 12 P
// Память
8MB : объём памяти в куче, используемой до начала маркировки
11MB : объём памяти в куче, используемой после завершения маркировки
6MB : объём памяти в куче, помеченной как «живая» после маркировки
13MB : целевой объём памяти в куче после завершения маркировки
В листинге 5 показано, что эта сборка стартовала через 2 мс после начала предыдущей (6.068 s против 6.070 s), хотя объём используемой памяти в куче достиг лишь 8 МБ из разрешённых 12 МБ. Важно понимать: если сборщик решает, что лучше запустить сборку раньше, он так и сделает. В данном случае он, вероятно, стартовал раньше, потому что приложение активно выделяет память, и сборщик хотел уменьшить задержки из-за Mark Assist в ходе этой сборки.
Ещё два момента. На этот раз сборщик уложился в цель: после завершения маркировки было занято 11 МБ, а не 13 МБ — на 2 МБ меньше. Объём «живой» памяти после маркировки остался тем же — 6 МБ.
К слову, можно получить больше деталей из трассировки GC, добавив флаг gcpacertrace=1
. Тогда сборщик будет выводить информацию о внутреннем состоянии «конкурентного pacer-а».
//Листинг 6
$ export GODEBUG=gctrace=1,gcpacertrace=1 ./app
Пример вывода:
gc 5 @0.071s 0%: 0.018+0.46+0.071 ms clock, 0.14+0/0.38/0.14+0.56 ms cpu, 29->29->29 MB, 30 MB goal, 8 P
pacer: sweep done at heap size 29MB; allocated 0MB of spans; swept 3752 pages at +6.183550e-004 pages/byte
pacer: assist ratio=+1.232155e+000 (scan 1 MB in 70->71 MB) workers=2+0
pacer: H_m_prev=30488736 h_t=+2.334071e-001 H_T=37605024 h_a=+1.409842e+000 H_a=73473040 h_g=+1.000000e+000 H_g=60977472 u_a=+2.500000e-001 u_g=+2.500000e-001 W_a=308200 goalΔ=+7.665929e-001 actualΔ=+1.176435e+000 u_a/u_g=+1.000000e+000
Запуск трассировки GC может многое рассказать о «здоровье» приложения и темпе работы сборщика. То, с какой скоростью работает сборщик, играет важную роль в процессе сборки.
Пейсинг
У сборщика есть алгоритм пейсинга, который определяет, когда нужно запускать сборку. Этот алгоритм основан на цикле обратной связи: сборщик собирает данные о работе приложения и о том, насколько сильно оно нагружает кучу. Под нагрузкой здесь понимается скорость, с которой приложение выделяет память в куче за определённый промежуток времени. Именно эта нагрузка определяет, с какой скоростью должен работать сборщик.
Прежде чем запустить сборку, сборщик оценивает, сколько времени ему понадобится на её завершение. Но как только сборка запускается, она начинает вносить задержки в работу приложения, замедляя выполнение. Каждая сборка добавляет задержку в общее время работы приложения.
Существует распространённое заблуждение: будто замедление пейсинга сборщика улучшает производительность. Логика такая — если отложить начало следующей сборки, то отложишь и её задержки. Но «работать в согласии со сборщиком» вовсе не означает замедлять его.
Можно увеличить значение GC Percentage выше 100. Это позволит выделить больше памяти в куче до запуска следующей сборки. В итоге темп сборки может замедлиться. Но так делать не стоит.

На рисунке 14 показано, как изменение значения GC Percentage меняет объём памяти в куче, который можно выделить перед началом новой сборки. Видно, что сборщик можно замедлить, заставив его ждать, пока в куче не накопится больше памяти.
Однако попытки напрямую повлиять на темп работы сборщика никак не связаны с «согласованной работой» с ним. Настоящая цель — сделать больше полезной работы между сборками или прямо во время сборки. Добиться этого можно, уменьшая объём или количество выделений, которые каждая часть кода добавляет в кучу.
Примечание: идея также состоит в том, чтобы достигать нужной пропускной способности с минимально возможным размером кучи. Помните, что экономия ресурсов — таких как память кучи — особенно важна при работе в облачных средах.

На рисунке 15 показана статистика работы Go-приложения, которая будет использоваться в следующей части серии. Версия, выделенная синим, отображает показатели приложения без оптимизаций при обработке 10 тысяч запросов. Версия, выделенная зелёным, показывает статистику после того, как было обнаружено и устранено 4,48 ГБ ненужных выделений памяти для тех же 10 тысяч запросов.
Обратите внимание на средний темп сборки для обеих версий (2,08 мс против 1,96 мс). Он практически одинаковый — около 2,0 мс. Что принципиально изменилось между этими версиями — это объём полезной работы между сборками. Приложение перешло с 3,98 до 7,13 обработанных запросов за одну сборку. Это рост на 79,1% в количестве выполняемой работы при том же темпе. Как видно, сама сборка не замедлилась после уменьшения выделений, её длительность осталась прежней. Выигрыш пришёл за счёт того, что между сборками удалось сделать больше работы.
Настраивать темп сборки, чтобы отсрочить задержки, — не способ повысить производительность приложения. Речь идёт о том, чтобы сократить время, необходимое сборщику для работы. А это, в свою очередь, снижает стоимость задержек. Эти задержки уже были описаны ранее, но давайте ещё раз подытожим для ясности.
Задержки, накладываемые сборщиком
Каждая сборка вносит в работу приложения два вида задержек. Первая задержка — отбор CPU-ресурсов. Эффект этого заключается в том, что приложение не работает на полную мощность во время сборки. Горутины приложения начинают делить P с горутинами сборщика или участвуют в сборке напрямую (Mark Assist).

На рисунке 16 показано, что приложение использует только 75% мощности CPU для своей работы. Это связано с тем, что сборщик занял себе P1. Такое состояние характерно для большей части времени сборки.

На рисунке 17 видно, что в этот момент (обычно всего несколько микросекунд) приложение использует лишь половину CPU-мощности для своей работы. Это происходит потому, что горутина на P3 выполняет Mark Assist, а P1 полностью занят сборщиком.
Примечание: маркировка обычно требует около 4 CPU-миллисекунд на каждый мегабайт «живой» памяти (например, чтобы оценить длительность фазы маркировки, возьмите размер «живой» кучи в мегабайтах и разделите на 0.25 * количество CPU). На практике маркировка обрабатывает примерно 1 МБ за 1 мс, но при этом использует только четверть доступных CPU.
Вторая задержка — это время STW, возникающее во время сборки. STW означает, что ни одна горутина приложения не выполняет свою работу: приложение полностью останавливается.

На рисунке 18 показана задержка STW, когда все горутины остановлены. Это происходит дважды при каждой сборке. Если ваше приложение работает стабильно, сборщик должен удерживать общее время STW на уровне 100 микросекунд или меньше в большинстве случаев.
Теперь вы знаете о разных фазах работы сборщика, о том, как рассчитывается размер памяти, как работает пейсинг и какие задержки сборщик накладывает на приложение. С этими знаниями можно наконец ответить на вопрос: как же «работать в согласии со сборщиком»?
Работать в согласии со сборщиком мусора
Работа в согласии со сборщиком — это снижение нагрузки на кучу. Напомним, нагрузка определяется скоростью, с которой приложение выделяет память в куче за определённое время. Чем меньше нагрузка, тем ниже задержки, которые вносит сборщик. Именно задержки GC замедляют ваше приложение.
Снизить задержки GC можно, выявив и устранив ненужные выделения памяти. Это поможет сборщику сразу по нескольким направлениям:
Поддерживать минимально возможный размер кучи.
Находить оптимальный и стабильный темп работы.
Укладываться в цель каждой сборки.
Минимизировать длительность каждой сборки, включая STW и Mark Assist.
Все эти факторы уменьшают объём задержек, которые сборщик накладывает на приложение. А значит, повышают производительность и пропускную способность. Сам темп сборки здесь ни при чём. Есть и другие инженерные приёмы, которые помогут принимать более грамотные решения и снижать нагрузку на кучу.
Русскоязычное Go сообщество

Друзья! Эту статью перевела команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!
Заключение
Если вы уделяете внимание снижению числа выделений памяти, значит вы, как Go-разработчик, делаете всё возможное, чтобы работать в согласии со сборщиком мусора. Написать приложение с нулевыми выделениями невозможно, поэтому важно уметь различать продуктивные выделения в памяти (которые помогают приложению) и непродуктивные (которые ему мешают). А дальше доверьтесь сборщику мусора: он позаботится о куче и обеспечит стабильную работу приложения.
Сборщик мусора — это разумный компромисс. Я готов заплатить стоимость его работы, лишь бы не нести на себе всю тяжесть ручного управления памятью. Go создан для того, чтобы разработчик мог оставаться продуктивным и при этом писать достаточно быстрые приложения. Сборщик мусора — ключевая часть того, что делает это возможным.
В следующей статье я покажу пример веб-приложения и расскажу, как использовать инструменты, чтобы увидеть всё это в действии.