Go’s Garbage Collection: как работает и почему это важно знать
Привет! Меня зовут Дмитрий Королёв, я бэкенд-разработчик в Авито. Я хочу рассказать, как устроен сборщик мусора в Golang и как он работает, чтобы вы могли писать более производительные приложения и лучше понимать внутреннее устройство языка.
За последние 10 лет сборщик мусора в golang ускорился более чем в 400 раз. И это не предел. Расскажу, как разработчики этого добились, — от базовой имплементации до нетривиальных оптимизаций.

Mark and Sweep Garbage Collector — кто такой?
Mark and Sweep — популярный алгоритм для сборщиков мусора. Он или его модификации много лет работают сейчас или работали ранее в Java, Python, Lua и других языках. Его работа состоит из 2 фаз:
- Mark: находим и отмечаем все достижимые объекты из набора (например, в куче).
- Sweep: проходим по всем объектам в куче, затираем недостижимые и возвращаем их в пул свободной памяти.
Если вы не знакомы с тем, как выделяется память в Golang, то рекомендую эту статью.
Набор объектов в наборе можно представить в виде графа, а работа алгоритма похожа на обход в ширину. Рассмотрим на примере:
До начала разметки есть граф неразмеченных объектов произвольного размера:

Разметка — шаг 1. Покрасили объект 1. Пул объектов, которые связаны с объектом 1 — 3 и 4:

Разметка — шаг 2. Покрасили объекты 3 и 4. Пул объектов, связанных с окрашенной частью графа — 6:

Разметка — шаг 3. Покрасили объект 6. Объектов, связанных с окрашенной частью графа, не осталось. Пришло время чистить мусор:

Объекты 2 и 7 недостижимы, их можно переработать:

Память недостижимых объектов отмечается доступной для перезаписи или сразу возвращается системе.
В Golang память отмечается доступной для перезаписи, запускается горутина, которая постепенно возвращает её системе. За этот процесс отвечает Scavenger. Все достижимые объекты продолжают жить нетронутыми:

Чтобы операция прошла корректно, программа должна быть приостановлена во время стадии разметки (Mark). Такая пауза в выполнении называется Stop The World (STW).
STW — очевидное зло с точки зрения перфоманса приложений. Любой язык с механизмом сборки мусора стремится сократить его влияние до минимума.
Алгоритмы для уменьшения STW в Golang
Популярное решение для уменьшения STW — имплементация алгоритма трёхцветной маркировки (Three-color Marker Algorithm). Этот подход работает и в Golang: все объекты в стадии разметки красятся в чёрный, серый или белый цвет:
- белый — потенциальный мусор, ещё не затронутые алгоритмом объекты;
- серый — объекты «на рассмотрении»;
- чёрный — активные объекты.
Изначально все объекты в куче и на стеке окрашены в белый.
В целом, алгоритм можно представить циклом из нескольких шагов:
- Покрасить все корневые объекты (стек и глобальные переменные) в серый.
- Выбрать серый объект из набора серых объектов и пометить его как чёрный.
- Все объекты, на которые указывает чёрный объект, пометить серым. Это гарантирует, что сам объект и объекты, на которые он ссылается, не будут выброшены в мусор.
- Если в графе остались серые объекты, вернуться к шагу 2.




Шаг 5 (уже повторяем с шага 2):


Серые объекты кончились, но белые остались. Вот мы и нашли мусор! Теперь эти участки при необходимости могут быть перезаписаны.
Но есть проблема. Чтобы сборщик мусора работал, мы обязаны делать STW и за один заход проходиться сразу по всем объектам в памяти программы. А вот что будет, если попробовать красить итерационно.
Сейчас граф объектов окрашен таким образом:

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

В текущей реализации стадия покраски требует STW. Этот период в ранних версиях Go достигал сотен миллисекунд, а это довольно значительное время. От этого как-то хочется избавиться.
Что такое Write Barrier и зачем они нам
Кажется, что стоит научиться собирать мусор пошагово, по маленьким кусочкам, то есть амортизировать алгоритм. Тогда не придётся не останавливать выполнение программы на длительный период времени. Сборщик мусора с таким алгоритмом называется Incremental Garbage Collector.
Для пошаговой сборки мусора нужны гарантии, что если во время покраски добавить или удалить связи между объектами, графы останутся правильно окрашенными.
Write Barrier — это фрагмент кода, который выполняется при работе с памятью. Нам он нужен для поддержки инвариантов, которые гарантируют правильное пошаговое выполнение алгоритма. Звучит сложно, но на примерах станет понятнее.
Проведу небольшой экскурс в историю Go, чтобы понять, почему он устроен так, как устроен.
До версии Go 1.8 использовали Dijkstra insertion write barrier. Работал он так:
func writePointer(slot, ptr): shade(ptr) *slot = ptr
slot — это место назначения (например, переменная) в коде Go.
ptr — это значение, которое помещается в слот в коде Go.
shade — превращает белые объекты в серые, серые и чёрные оставляет нетронутыми.
insertion в названии говорит о том, что триггером для его вызова служит создание связи между объектами.
Таким образом, если мы пишем x = y, то y всегда после этого будет серым:


Теперь мы стабильно поддерживаем такой инвариант: чёрные объекты указывают только на серые или другие чёрные объекты, а на белые — не указывают:

Это выглядит просто, работает корректно — что ещё нам надо? Есть подвох. Для объектов на стеке вместо включения Write Barrier был выбран другой подход. Их просто автоматом считали серыми, чтобы не нести потери в перформансе приложений. Но без Write Barrier нет гарантии, что мы не прикрепим белый объект к чёрному на стеке.
Чтобы этого избежать, на каждом шаге покраски стек заново покрасится в серый после выполнения программы. По всем объектам заново пройдётся сборщик мусора. Если обнаружится новая связь с белым объектом, он также будет перекрашен в серый.
Для приложений с большим количеством горутин этот период может достигать до 100 мс. Но считается, что этот подход оптимальнее использования Write Barrier на стеке.
Начиная с Go 1.8 придумали совместить Dijkstra insertion write barrier и Yuasa deletion write barrier. Deletion в названии означает, что триггером для вызова служит удаление связи между объектами.
Псевдокод для описания его работы:
func writePointer(slot, ptr) shade(slot) slot = ptr
Рассмотрим тот же пример, который приводился перед вводом write barriers:

Создание связи между объектами не триггерит Yuassa write barrier:

А вот удаление связей триггерит:

Таким образом мы не теряем белые объекты. Yuasa write barrier даёт такой инвариант: у любого белого объекта, на который указывает чёрный объект, должен быть достижимый путь до серого объекта.
Из этих двух Write barrier получается Hybrid Write Barrier:
func writePointer(slot, ptr): shade(*slot) if current stack is grey: shade(ptr) *slot = ptr
Этот Write Barrier затеняет объект, на который перезаписывается ссылка. Если стек текущей горутины ещё не просканирован, то он затеняет и устанавливаемый объект
С гибридным барьером не нужно повторное сканирование стека. После того, как он был отсканирован и затемнён, он остаётся чёрным.
Этот Write Barrier гарантирует выполнение условий:
- чёрные объекты не указывают на белые объекты, только на серые или другие черные объекты (dijkstra write barrier);
- любой белый объект, на который указывает черный объект, должен иметь достижимый путь до серого объекта (yuasa write barrier)
А эти условия гарантируют корректность пошаговой покраски графа. Больше информации и доказательство корректности можно найти здесь. Все новые объекты, создаваемые в процессе работы алгоритма сразу помечаются чёрными, чтобы точно их не потерять.
Это может быть проблемой для некоторых приложений. Если создать очень много короткоживущих объектов, то все они точно доживут до следующей итерации GC.
Теперь мы знаем, как Golang избавился от долгих пауз в выполнении программы — Garbage Collector стал Incremental. Но стала ощутимее другая проблема: время работы сборщика мусора стало больше. Вот как выглядит процесс его работы:

Немного о Concurrent garbage collector
Очевидный вариант оптимизации — использовать все имеющиеся процессоры. Это кратно ускорит время работы GC:


Тут есть блок, в котором сборщик работает везде и одновременно. Это не случайно, некоторая часть его работы требует STW. Полностью от него избавиться не выйдет.
Стадии работы GC
Текущая стадия работы сборщика мусора хранится в глобальной переменной gcphase.
1. Sweep Termination. Останавливаем мир Это нужно, чтобы все процессоры дошли до точки, когда можно безопасно запустить GC. После этого все блоки памяти, отмеченные как мусор, отправляются «на съедение» Scavenger. Потом он вернёт их ОС. Это позволяет не запрашивать у ОС больше памяти, чем требуется приложению. В обычных обстоятельствах к началу работы GC вся «мусорная» память уже возвращена системе. Необычным обстоятельством может считаться ручной запуск GC.
2. Mark. Глобальная переменная gcphase выставляется в значение _GCmark. Включается Write Barrier. Создаются и ставятся в очередь джобы с покраской корневых вершин. Корневые вершины — это глобальные переменные и всё содержимое стека.
Запускаем мир. Теперь маркировка объектов происходит в специальных воркерах-малярах, запущенных шедулером. Эти воркеры в проходятся по всем объектам в куче и на стеке. Новые аллоцированные объекты сразу красятся в чёрный. Чтобы просканировать стек горутины, её останавливают, прокрашивают стек и снова запускают.

Так продолжается до тех пор, пока не закончатся серые объекты. В этой фазе GC забирает до 25% от CPU, это число зашито внутри runtime go (см. раздел Latency в Gc Guide).
Mark Termination. Останавливаем мир, выставляем gcphase на _GCmarktermination, выключаем воркеров, которые красили объекты в памяти. runtime готовит будущую работу программы. Например, чистит кеш у M.
Sweep. Выставляем gcphase на _GCoff, выключается Write Barrier. Запускаем мир. Теперь новые аллоцированные объекты будут покрашены в белый. Аллокация может происходить поверх блоков памяти, отмеченных как мусор. Кроме того, будет запущена горутина, которая будет постепенно возвращать мусорную память ОС.
Здорово, теперь мы знаем как работает гошный GC! А когда он запускается?
- Превышение динамического лимита «сожранной» приложением памяти, установленного с помощью переменной GOGC. Об этом подробно расписано в Guide to the Go Garbage Collector, поэтому я не буду заострять на этом внимание.
- Прошло 2 минуты без GC. Да, даже если вы ничего не аллоцировали за это время, GC все равно запустится раз в 2 минуты. За это ответственен Sysmon — особый тред приложения. Он отвечает за запуск GC по таймеру, preemption горутин и другие важные функции. Этот способ можно отключить, выставив значение GOGC < 0.
- Ручной запуск с помощью runtime.GC(). Если сделать этот вызов, когда Garbage Collector уже запущен, то по достижении фазы Sweep он запустится заново.
Для чего это всё нужно знать
GC в Golang стал неотъемлемой частью языка. Он позволяет разработчикам сосредоточиться на бизнес-логике и функциональности приложения, не беспокоясь о ручном управлении памятью. Если у вас есть базовое понимание принципов работы сборщика мусора в Golang, то вы сможете оптимизировать ваш код и создавать более эффективные приложения.
Надеюсь эта статья была вам полезна. Успехов!
- garbage collector
- write barriers
- go
Оптимизация памяти и управление сборщиком мусора в Go: GOGC и GOMEMLIMIT 03.07.2023 13:02
Всем привет, меня зовут Нина Пакшина, я работаю Golang разработчиком в Лента Онлайн.
В данной статье я расскажу о том, как управлять сборщиком мусора в Go, как оптимизировать потребление памяти приложением и защититься от ошибки out-of-memory.
Стек и куча в Go
Я не буду подробно рассказывать о том, как работает сборщик мусора, поскольку на эту тему уже существует много статей и есть подробная официальная документация (это и это). Но я хочу упомянуть базовые понятия, которые помогут разобраться в теме моей статьи.
Вероятно, вы уже знаете, что в Go данные могут быть сохранены в двух основных хранилищах памяти: стеке (stack) и куче (heap).

Все изображения сгенерированы с помощью Image Creator from Microsoft Bing
Обычно в стеке хранятся данные, размер и время использования которых компилятор Go может предсказать: это локальные переменные функции, аргументы, передаваемые в функцию, возвращаемые значения и т. д.
Стек управляется автоматически и работает по принципу LIFO (последний вошел — первый вышел). При вызове функции все данные, связанные с ней, помещаются в вершину стека, а при завершении функции эти данные удаляются из стека.
Стек не требует сложного механизма сборки мусора и несет минимальные накладные расходы на управление памятью. Получение и сохранение данных в стеке происходит очень быстро.
Но не все данные программы могут быть сохранены в стеке. Данные, которые изменяются динамически в процессе выполнения или требуют доступа за пределами области видимости функции, не могут быть помещены в стек, так как компилятор не может предсказать их использование.
Такие данные сохраняются в куче.
В отличие от стека, получение данных из кучи и управление ею являются более затратными процессами.
Что идет в стек, а что в кучу?
Как я уже упоминала, в стек помещаются значения, у которых размер и время жизни могут быть предсказаны. Но на деле компилятор Go принимает во внимание множество нюансов при принятии решения о размещении данных в стеке или куче. Например, преаллоцированные срезы размером до 64 КБ будут храниться в стеке, а срезы размером больше 64 КБ — в куче. То же самое относится и к массивам: если массив превышает 10 МБ, то он будет сохранен в куче.

Вы можете использовать escape-анализ для определения, где будет храниться определенная переменная. Например, вы можете проанализировать ваше приложение, запустив его из командной строки с флагом -gcflags=-m :
go build -gcflags=-m main.go
Например, если мы скомпилируем данное приложение main.go с флагом -m
package main func main()
То результатом будет:
# command-line-arguments ./main.go:3:6: can inline main ./main.go:7:6: moved to heap: arrayAfter10Mb ./main.go:10:23: make([]int, 8192) does not escape ./main.go:11:21: make([]int, 8193) escapes to heap
Мы видим, что массив arrayAfter10Mb был перенесен в кучу, так как его размер превышает 10 МБ, в то время как arrayBefore10Mb остался в стеке (для int переменной 10 МБ это 10×1024 * 1024 / 8 = 1310720 элементов).
Также срез sliceBefore64 не был отправлен в кучу, поскольку его размер меньше 64 КБ, в то время как sliceOver64 был сохранен в куче (для int переменной 64 КБ это 64×1024 / 8 = 8192 элементов).
Подробнее о том, где и что аллоцируется в куче, можно изучить здесь.
Таким образом, один из способов борьбы с кучей — избегать ее! Но что делать, если данные уже попали в кучу?
В отличие от стека, размер кучи неограничен и постоянно растет. В куче размещаются динамически создаваемые объекты, такие как структуры, срезы и карты, а также большие блоки памяти, которые не могут быть размещены в стеке из-за его ограничений.
Инструмент, позволяющий переиспользовать память в куче и предотвращать ее полную блокировку, это сборщик мусора.
Немного о работе сборщика мусора
Сборщик мусора, он же GC (Garbage Collector) — это система, специально предназначенная для определения и освобождения динамически выделенной памяти. В Go используется алгоритм сборки мусора на основе трассировки и алгоритма пометок Mark and Sweep.
На этапе маркировки (mark) сборщик мусора помечает данные, которые активно используются приложением, в качестве живых (live heap). Затем на этапе очистки (sweep) GC проходит по всей памяти, которая не была помечена как живая, и переиспользует ее.
Работа сборщика мусора не является бесплатной, поскольку он потребляет два важных ресурса системы: процессорное время и физическую память.
Память в сборщике мусора содержит в себе живую память кучи (память, которая была помечена как живая в предыдущем цикле сборки мусора), новую память кучи (память кучи, которая еще не была проанализирована сборщиком мусора), а также память, используемую для хранения некоторых метаданных, которая обычно незначительна по сравнению с первыми двумя сущностями.
Потребление процессорного времени сборщиком мусора связано с его спецификой работы. Существуют реализации сборщика мусора, называемые «stop-the-world», которые полностью останавливают выполнение программы на время сборки мусора, что приводит к тому, что в какой-то момент все процессорное время расходуется не на полезную работу.
В случае Go сборщик мусора не является полностью «stop-the-world» и выполняет большую часть своей работы, например, такую как разметка кучи (время выполнения которой пропорционально размеру кучи) параллельно с выполнением приложения.
Однако, в Go сборщик мусора все равно работает с некоторыми ограничениями, и несколько раз за цикл сборки мусора он полностью останавливает выполнение рабочего кода. Подробнее об этом можно узнать здесь.

Как управлять сборщиком мусора?
Существует параметр, который позволяет управлять сборщиком мусора в Go — это переменная окружения GOGC или ее функциональный аналог SetGCPercent из пакета runtime/debug .
Параметр GOGC определяет процент новой необработанной памяти кучи от живой памяти, при достижении которого будет запущена сборка мусора. Значение GOGC по умолчанию равно 100, что означает, что сборка мусора будет запущена, когда объем новой памяти достигнет 100% от объема живой памяти кучи.

Когда вызывается сборщик мусора (GC)
Давайте рассмотрим пример программы и отследим изменение размера кучи с помощью инструмента go tool trace . Для запуска программы используем версию Go 1.20.1.
В данном примере, функция performMemoryIntensiveTask использует большое количество памяти размещаемой в куче. Данная функция запускает обработчик с размером очереди NumWorker и количество задач равное NumTasks .
package main import ( "fmt" "os" "runtime/debug" "runtime/trace" "sync" "time" ) const ( NumWorkers = 4 // Количество воркеров. NumTasks = 500 // Количество задач. MemoryIntense = 10000 // Размер память затратной задачи (число элементов). ) func main() < // Запись в trace файл. f, _ := os.Create("trace.out") trace.Start(f) defer trace.Stop() // Установка целевого процента сборщика мусора. По умолчанию 100%. debug.SetGCPercent(100) // Очередь задач и очередь результата. taskQueue := make(chan int, NumTasks) resultQueue := make(chan int, NumTasks) // Запуск воркеров. var wg sync.WaitGroup wg.Add(NumWorkers) for i := 0; i < NumWorkers; i++ < go worker(taskQueue, resultQueue, &wg) >// Отправка задач в очередь. for i := 0; i < NumTasks; i++ < taskQueue close(taskQueue) // Получение результатов из очереди. go func() < wg.Wait() close(resultQueue) >() // Обработка результатов. for result := range resultQueue < fmt.Println("Результат:", result) >fmt.Println("Готово!") > // Функция воркера. func worker(tasks > // performMemoryIntensiveTask функция требующая много памяти. func performMemoryIntensiveTask(task int) int < // Создание среза большого размера. data := make([]int, MemoryIntense) for i := 0; i < MemoryIntense; i++ < data[i] = i + task >// Имитация временной задержки time.Sleep(10 * time.Millisecond) // Вычисление результата. result := 0 for _, value := range data < result += value >return result >
Для трассировки работы программы результат записывается в файл trace.out :
// Запись в trace файл. f, _ := os.Create("trace.out") trace.Start(f) defer trace.Stop()
Используя инструмент go tool trace , мы можем наблюдать за изменениями размера кучи и анализировать поведение сборщика мусора в вашей программе.
Обратите внимание, что точные детали и возможности инструмента go tool trace могут варьироваться в разных версиях Go, поэтому рекомендуется обратиться к официальной документации для получения более подробной информации о его использовании в вашей конкретной версии Go.
GOGC по умолчанию
Параметр GOGC можно установить с помощью функции debug.SetGCPercent(100) из пакета runtime/debug . По умолчанию GOGC равно 100 (процентам).
Давайте запустим выполнение нашей программы с помощью команды:
go run main.go
По завершению выполнения программы будет создан файл trace.out , который мы сможем проанализировать с помощью утилиты go tool . Для этого выполним команду:
go tool trace trace.out
Затем мы можем перейти в веб-версию трассировщика, открыв веб-браузер и перейдя по адресу http://127.0.0.1:54784/trace

Куча при GOGC = 100
Во вкладке STATS мы видим поле «Heap» (куча), которое отображает, как менялся размер кучи при исполнении приложения. Красная область на графике представляет занятую кучей память.
Во вкладке PROCS в поле «GC» (сборщик мусора) отображаются столбцы голубого цвета, которые показывают моменты запуска сборщика мусора.
Как только размер новой кучи достигает 100% от размера живой кучи, запускается сборка мусора. Например, если размер живой кучи составляет 10 Мб, то сборщик мусора запустится, когда размер новой кучи достигнет 10 Мб (а общая память в GC = 20 Мб).
Если в поле GC выделить все вызовы сборщика мусора, можно узнать суммарное время, в течение которого работал сборщик мусора.

Количество вызовов GC при GOGC=100
В нашем случае сборщик мусора вызывался 16 раз с общим временем выполнения 14 мс.
Вызываем GC чаще
Если мы запустим код, предварительно установив debug.SetGCPercent(10) на 10%, то мы увидим, что частота вызова сборщика мусора увеличится: теперь сборщик мусора будет вызываться, когда размер текущей кучи составляет 10% от размера живой кучи.
Другими словами, если размер живой кучи составляет 10 Мб, то сборщик мусора будет запускаться, когда текущая куча достигнет размера 1 Мб (а общий размер = 11 Мб).

Куча при GOGC = 10
В данном случае сборщик мусора вызывался 38 раз, а общее время вызова сборщика мусора составило 28 мс.

Количество вызовов GC при GOGC=10
Мы видим, что установка GOGC в значение меньше 100% может увеличить частоту сборки мусора, что может привести к увеличенному использованию процессорного времени и снижению производительности программы.
Вызываем GC реже
Если мы вызовем ту же программу, но с настройкой debug.SetGCPercent(1000) в 1000%, то получим следующий результат:
Мы видим, что текущая куча растет до тех пор, пока не достигнет размера, равного 1000% от размера живой кучи. Другими словами, если размер живой кучи составляет 10 Мб, то сборщик мусора будет запущен, когда текущий размер кучи достигнет 100 Мб (общий объем кучи = 110 Мб).

Количество вызовов GC при GOGC=1000
В текущем случае сборщик мусора был вызван 1 раз и выполнялся в течение 2 мс.
Отключаем GC
Мы можем также отключить сборщик мусора, установив GOGC=off или используя debug.SetGCPercent(-1) .
Так ведет себя куча при отключенном сборщике мусора без использования GOMEMLIMIT:

Сборщик мусора не вызывается при GOGC=off
Сколько памяти занимает куча?
В реальности выделение памяти для живой кучи обычно не происходит так периодично и предсказуемо, как мы видим на предыдущих графиках.
Размер живой кучи может динамически изменяться с каждым циклом сборки мусора, и в определенных условиях могут возникать резкие скачки ее абсолютного значения.
Например, если при выполнении нескольких параллельных задач размер живой кучи может достигать 800 Мб, то сборщик мусора будет запущен только тогда, когда текущий размер кучи достигнет 1,6 Гб.

Размер кучи в реальной жизни может меняться скачками
В современной разработке большую часть приложений мы запускаем в контейнерах, которые имеют ограничения по использованию памяти. Таким образом, если нашему контейнеру был установлен лимит памяти в 1 Гб, а общий размер кучи в какой-то момент увеличился до 1.6 Гб, то контейнер выйдет из строя из-за ошибки OOM (out-of-memory).
Давайте смоделируем эту ситуацию. Например запустим нашу программу в контейнере с ограничением по памяти 10 Мб (такое значение нереалистично, мы его используем исключительно для тестовых целей):
Описание Dockerfile для контейнера, исполняющего программу на Go:
FROM golang:latest as builder WORKDIR /src COPY . . RUN go env -w GO111MODULE=on RUN go mod vendor RUN CGO_ENABLED=0 GOOS=linux go build -mod=vendor -a -installsuffix cgo -o app ./cmd/ FROM golang:latest WORKDIR /root/ COPY --from=builder /src/app . EXPOSE 8080 CMD ["./app"]
version: '3' services: my-app: build: context: . dockerfile: Dockerfile ports: - 8080:8080 deploy: resources: limits: memory: 10M
Давайте воспользуемся предыдущим вариантом кода, в котором мы установили значение GOGC равным 1000%.
docker-compose build docker-compose up
Через пару секунд наш контейнер упадет с ошибкой, которая соответствует ошибки OOM (out-of-memory):
exited with code 137
Возникает очень неприятная ситуация: получается, параметр GOGC управляет только относительным значением новой кучи, в то время как контейнер имеет абсолютное ограничение по использованию памяти. Это значит, что в любой момент наше приложение может превысить доступный в среде исполнения лимит и упасть.

Как избежать OOM?
Начиная с версии 1.19 в Golang вводится так называемое мягкое управление памятью с помощью переменной окружения GOMEMLIMIT или аналогичной функции из пакета runtime/debug SetMemoryLimit (здесь можно прочитать некоторые интересные детали проектирования данного механизма).
Переменная окружения GOMEMLIMIT устанавливает общий объем памяти, которым может пользоваться среда выполнения Go (Go runtime), например:
GOMEMLIMIT = 8MiB
Для установки значения памяти используется суффикс размерности, например MiB — это Мб.
Запустим контейнер с установленной переменной окружения GOMEMLIMIT = 8MiB . Для этого пропишем в docker-compose переменную окружения enviroment:
version: '3' services: my-app: environment: GOMEMLIMIT: "8MiB" build: context: . dockerfile: Dockerfile ports: - 8080:8080 deploy: resources: limits: memory: 10M
Теперь, при запуске контейнера, программа выполняется полностью без ошибки OOM.
Это происходит потому, что после включения GOMEMLIMIT = 8MiB сборщик мусора вызывается всякий раз, когда общая память приближается к лимиту, и поддерживает размер кучи в заданных GOMEMLIMIT пределах. Это приводит к более частым вызовам сборщика мусора.
Именно для решения этой проблемы был придуман механизм GOMEMLIMIT .

Запуск программы при ограничении GOMEMLIMIT = 8MiB
Спираль смерти
GOMEMLIMIT является мощным и полезным инструментом, который также может выстрелить в ногу. Пример опасного поведения виден на предыдущем графике.
Когда размер общей памяти, вызванный ростом живой кучи или постоянными утечками горутин, приближается к GOMEMLIMIT , сборщик мусора начинает вызываться часто, чтобы уменьшить потребляемую память.
Из-за повторных вызовов сборщика мусора время работы приложения теоретически может неограниченно возрастать, забирая процессорное время у самого приложения. Такое поведение называется спиралью смерти. Это может привести к полной деградации работы приложения, и такое поведение, в отличие от ошибки OOM, очень сложно отследить.
Именно поэтому механизм GOMEMLIMIT работает как мягкое ограничение.
Go не предоставляет 100% гарантий соблюдения ограничения памяти GOMEMLIMIT . Это позволяет избежать ситуации частого вызова сборщика мусора, так как позволяет использовать память сверх лимита.
Для этого установлен предел использования процессорного времени. В настоящее время этот предел установлен на 50% с окном CPU в 2 * GOMAXPROCS секунды.
Также это значит в случае утечек памяти, что мы полностью не сможем избежать ошибки OOM, она просто произойдет значительно позже.
Как применять GOGC и GOMEMLIMIT
Механизм мягкого управления памятью с помощью GOMEMLIMIT и изменение настроек сборщика мусора GOGC может защитить нас от неприятных ситуаций и улучшить эффективность работы приложения.
Приведем примеры случаев, когда использование GOMEMLIMIT и GOGC может быть полезным:
- Приложение, запущенное в контейнере с ограниченным объемом памяти. Хорошей практикой будет настроить GOMEMLIMIT так, чтобы оставалось 5–10% от доступной в контейнере памяти.
- При запуске библиотеки или кода, требующего значительных ресурсов. Здесь можно динамически управлять GOMEMLIMIT для оптимальной работы.
- При запуске приложения в контейнере в качестве скрипта, где приложение выполняет определенную задачу в течение некоторого времени и затем завершается. Для повышения производительности можно отключить сборщик мусора GOGC=off , но установить GOMEMLIMIT , чтобы не превысить доступные ресурсы контейнера по памяти.
Существуют и случаи, когда лучше избегать использования GOMEMLIMIT :
- Не устанавливайте ограничение памяти, если программа уже близка к предельному значению памяти своей среды.
- Не используйте ограничение памяти при развертывании в исполнительной среде, которой вы не управляете, особенно если использование памяти вашей программой пропорционально входным данным. Например, это может быть CLI-инструмент или настольное приложение.
Go’s Garbage Collection: как работает и почему это важно знать
Привет! Меня зовут Дмитрий Королёв, я бэкенд-разработчик в Авито. Я хочу рассказать, как устроен сборщик мусора в Golang и как он работает, чтобы вы могли писать более производительные приложения и лучше понимать внутреннее устройство языка.
За последние 10 лет сборщик мусора в golang ускорился более чем в 400 раз. И это не предел. Расскажу, как разработчики этого добились, — от базовой имплементации до нетривиальных оптимизаций.

Mark and Sweep Garbage Collector — кто такой?
Mark and Sweep — популярный алгоритм для сборщиков мусора. Он или его модификации много лет работают сейчас или работали ранее в Java, Python, Lua и других языках. Его работа состоит из 2 фаз:
- Mark: находим и отмечаем все достижимые объекты из набора (например, в куче).
- Sweep: проходим по всем объектам в куче, затираем недостижимые и возвращаем их в пул свободной памяти.
Если вы не знакомы с тем, как выделяется память в Golang, то рекомендую эту статью.
Набор объектов в наборе можно представить в виде графа, а работа алгоритма похожа на обход в ширину. Рассмотрим на примере:
До начала разметки есть граф неразмеченных объектов произвольного размера:

Разметка — шаг 1. Покрасили объект 1. Пул объектов, которые связаны с объектом 1 — 3 и 4:

Разметка — шаг 2. Покрасили объекты 3 и 4. Пул объектов, связанных с окрашенной частью графа — 6:

Разметка — шаг 3. Покрасили объект 6. Объектов, связанных с окрашенной частью графа, не осталось. Пришло время чистить мусор:

Объекты 2 и 7 недостижимы, их можно переработать:

Память недостижимых объектов отмечается доступной для перезаписи или сразу возвращается системе.
В Golang память отмечается доступной для перезаписи, запускается горутина, которая постепенно возвращает её системе. За этот процесс отвечает Scavenger. Все достижимые объекты продолжают жить нетронутыми:

Чтобы операция прошла корректно, программа должна быть приостановлена во время стадии разметки (Mark). Такая пауза в выполнении называется Stop The World (STW).
STW — очевидное зло с точки зрения перфоманса приложений. Любой язык с механизмом сборки мусора стремится сократить его влияние до минимума.
Алгоритмы для уменьшения STW в Golang
Популярное решение для уменьшения STW — имплементация алгоритма трёхцветной маркировки (Three-color Marker Algorithm). Этот подход работает и в Golang: все объекты в стадии разметки красятся в чёрный, серый или белый цвет:
- белый — потенциальный мусор, ещё не затронутые алгоритмом объекты;
- серый — объекты «на рассмотрении»;
- чёрный — активные объекты.
Изначально все объекты в куче и на стеке окрашены в белый.
В целом, алгоритм можно представить циклом из нескольких шагов:
- Покрасить все корневые объекты (стек и глобальные переменные) в серый.
- Выбрать серый объект из набора серых объектов и пометить его как чёрный.
- Все объекты, на которые указывает чёрный объект, пометить серым. Это гарантирует, что сам объект и объекты, на которые он ссылается, не будут выброшены в мусор.
- Если в графе остались серые объекты, вернуться к шагу 2.




Шаг 5 (уже повторяем с шага 2):


Серые объекты кончились, но белые остались. Вот мы и нашли мусор! Теперь эти участки при необходимости могут быть перезаписаны.
Но есть проблема. Чтобы сборщик мусора работал, мы обязаны делать STW и за один заход проходиться сразу по всем объектам в памяти программы. А вот что будет, если попробовать красить итерационно.
Сейчас граф объектов окрашен таким образом:

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

В текущей реализации стадия покраски требует STW. Этот период в ранних версиях Go достигал сотен миллисекунд, а это довольно значительное время. От этого как-то хочется избавиться.
Что такое Write Barrier и зачем они нам
Кажется, что стоит научиться собирать мусор пошагово, по маленьким кусочкам, то есть амортизировать алгоритм. Тогда не придётся не останавливать выполнение программы на длительный период времени. Сборщик мусора с таким алгоритмом называется Incremental Garbage Collector.
Для пошаговой сборки мусора нужны гарантии, что если во время покраски добавить или удалить связи между объектами, графы останутся правильно окрашенными.
Write Barrier — это фрагмент кода, который выполняется при работе с памятью. Нам он нужен для поддержки инвариантов, которые гарантируют правильное пошаговое выполнение алгоритма. Звучит сложно, но на примерах станет понятнее.
Проведу небольшой экскурс в историю Go, чтобы понять, почему он устроен так, как устроен.
До версии Go 1.8 использовали Dijkstra insertion write barrier. Работал он так:
func writePointer(slot, ptr): shade(ptr) *slot = ptr
slot — это место назначения (например, переменная) в коде Go.
ptr — это значение, которое помещается в слот в коде Go.
shade — превращает белые объекты в серые, серые и чёрные оставляет нетронутыми.
insertion в названии говорит о том, что триггером для его вызова служит создание связи между объектами.
Таким образом, если мы пишем x = y, то y всегда после этого будет серым:


Теперь мы стабильно поддерживаем такой инвариант: чёрные объекты указывают только на серые или другие чёрные объекты, а на белые — не указывают:

Это выглядит просто, работает корректно — что ещё нам надо? Есть подвох. Для объектов на стеке вместо включения Write Barrier был выбран другой подход. Их просто автоматом считали серыми, чтобы не нести потери в перформансе приложений. Но без Write Barrier нет гарантии, что мы не прикрепим белый объект к чёрному на стеке.
Чтобы этого избежать, на каждом шаге покраски стек заново покрасится в серый после выполнения программы. По всем объектам заново пройдётся сборщик мусора. Если обнаружится новая связь с белым объектом, он также будет перекрашен в серый.
Для приложений с большим количеством горутин этот период может достигать до 100 мс. Но считается, что этот подход оптимальнее использования Write Barrier на стеке.
Начиная с Go 1.8 придумали совместить Dijkstra insertion write barrier и Yuasa deletion write barrier. Deletion в названии означает, что триггером для вызова служит удаление связи между объектами.
Псевдокод для описания его работы:
func writePointer(slot, ptr) shade(slot) slot = ptr
Рассмотрим тот же пример, который приводился перед вводом write barriers:

Создание связи между объектами не триггерит Yuassa write barrier:

А вот удаление связей триггерит:

Таким образом мы не теряем белые объекты. Yuasa write barrier даёт такой инвариант: у любого белого объекта, на который указывает чёрный объект, должен быть достижимый путь до серого объекта.
Из этих двух Write barrier получается Hybrid Write Barrier:
func writePointer(slot, ptr): shade(*slot) if current stack is grey: shade(ptr) *slot = ptr
Этот Write Barrier затеняет объект, на который перезаписывается ссылка. Если стек текущей горутины ещё не просканирован, то он затеняет и устанавливаемый объект
С гибридным барьером не нужно повторное сканирование стека. После того, как он был отсканирован и затемнён, он остаётся чёрным.
Этот Write Barrier гарантирует выполнение условий:
- чёрные объекты не указывают на белые объекты, только на серые или другие черные объекты (dijkstra write barrier);
- любой белый объект, на который указывает черный объект, должен иметь достижимый путь до серого объекта (yuasa write barrier)
А эти условия гарантируют корректность пошаговой покраски графа. Больше информации и доказательство корректности можно найти здесь. Все новые объекты, создаваемые в процессе работы алгоритма сразу помечаются чёрными, чтобы точно их не потерять.
Это может быть проблемой для некоторых приложений. Если создать очень много короткоживущих объектов, то все они точно доживут до следующей итерации GC.
Теперь мы знаем, как Golang избавился от долгих пауз в выполнении программы — Garbage Collector стал Incremental. Но стала ощутимее другая проблема: время работы сборщика мусора стало больше. Вот как выглядит процесс его работы:

Немного о Concurrent garbage collector
Очевидный вариант оптимизации — использовать все имеющиеся процессоры. Это кратно ускорит время работы GC:


Тут есть блок, в котором сборщик работает везде и одновременно. Это не случайно, некоторая часть его работы требует STW. Полностью от него избавиться не выйдет.
Стадии работы GC
Текущая стадия работы сборщика мусора хранится в глобальной переменной gcphase.
1. Sweep Termination. Останавливаем мир
GO memory ballast
Балласт памяти Go: как я перестал волноваться и полюбил кучу(heap)
Published in
13 min read
Apr 27, 2019
Я большой поклонник небольших изменений кода, которые могут иметь большое влияние. Это может показаться очевидным, но позвольте мне объяснить:
- Такие изменения часто включают погружение в вещи, с которыми вы не знакомы, и которые вы не понимаете.
- Даже у самого проработанного кода, есть расходы на обслуживание каждой оптимизации, которую вы добавляете, и это обычно (хотя и не всегда) довольно линейно с количеством строк кода, которые вы в конечном итоге добавляете/изменяете.
Недавно мы выпустили небольшое изменение, которое уменьшило загрузку процессора наших интерфейсных серверов API в Twitch на ~30% и уменьшило общую задержку 99-го процентиля API во время пиковой нагрузки на ~45%.
Этот пост об изменении, процессе его поиска и объяснения того, как он работает.
Setting the stage
Создаем условия: У нас есть сервис в Twitch под названием Visage, который функционирует как наш интерфейс API. Visage является центральным шлюзом для всего внешнего исходящего трафика API. Он отвечает за кучу вещей, от авторизации до маршрутизации запросов, до серверного Grapql. Таким образом, он должен масштабироваться, чтобы обрабатывать шаблоны трафика пользователей, которые находятся несколько вне нашего контроля.
В качестве примера мы видим распространенный шаблон трафика “ refresh storm.”Это происходит, когда поток популярного вещателя падает из-за сбоя в их интернет-соединении. В ответ вещатель перезапускает стрим. Это обычно заставляет зрителей многократно обновлять свои страницы, и внезапно у нас появляется намного больше трафика API, с которым нужно иметь дело.
Visage-Это приложение Go (построенное с Go 1.11 во время этого изменения), которое работает на EC2 с балансировщиком нагрузки. Находясь на EC2, он по большей части хорошо масштабируется по горизонтали.
Однако, даже с магией EC2 и групп автоматического масштабирования, у нас все еще есть проблема борьбы с очень большими всплесками трафика. Во время “штормов обновления”, мы часто имеем дело с миллионами запросов в течении нескольких секунд, учитывая нашу нормальную загрузку 20х. Кроме того, мы увидели, что задержка API значительно ухудшается, когда наши frontend серверы находились под большой нагрузкой.
Один из подходов к решению этой проблемы заключается в том, чтобы постоянно держать ваши мощности в чрезмерном масштабе, но это расточительно и дорого. Чтобы снизить эту постоянно растущую стоимость, мы решили потратить некоторое время на поиск некоторых решений, которые улучшили бы пропускную способность хоста, а также обеспечили более надежную обработку запросов, когда хосты были под нагрузкой.
Scouting the deck
Сбор информации: К счастью, мы запускаем pprof в наших производственных средах, поэтому получение профилей реального производственного трафика становится действительно тривиальным. Если вы не используете pprof, я бы настоятельно рекомендовал вам сделать это. Профилировщик, по большей части, имеет очень минимальную нагрузку на процессор. Трассировщик выполнения может иметь небольшие накладные расходы, но все же достаточно маленькие, чтобы мы с удовольствием запускали его в производство в течение нескольких секунд каждый час.
Поэтому, взглянув на профили нашего приложения Go, мы сделали следующие замечания:
- В установившемся состоянии наше приложение запускало ~8–10 циклов сбора мусора (GC) в секунду (400–600 в минуту).
- >30% циклов CPU были потрачены на вызов функций, связанных с GC
- Во время всплесков трафика количество циклов GC увеличится
- Наш размер кучи в среднем был довольно небольшим ()
Улучшения, которые мы сделали, связаны с производительностью сборки мусора в нашем приложении. Прежде чем я перейду к улучшениям, ниже приведен быстрый обзор того, что такое GCs и что они делают. Можете спокойно пропускать этот раздел, если вы хорошо разбираетесь в концепциях.
What is a garbage collector (GC) ?
Что такое GC: В современных приложениях обычно существует два способа выделения памяти: stack и heap. Большинство программистов знакомы со stack с первого времени написания рекурсивной программы, которая бы вызвала переполнение стека . Куча, с другой стороны, представляет собой пул памяти, который может использоваться для динамического выделения.
Распределение стэка отлично в том, что он живет только в течение срока службы функции, частью которой он являются. Однако выделение кучи не будет автоматически освобождено, когда он выйдет за пределы области. Чтобы предотвратить рост кучи без привязки, мы должны либо явно освободить место, либо в случае языков программирования с управлением памятью (например, Go) полагаться на сборщик мусора(GC) для поиска и удаления объектов, на которые больше не ссылаются.
Вообще говоря, на языках с GC, чем больше вы можете хранить в стеке, тем лучше, так как эти выделения никогда даже не замечаются GC. Компиляторы используют метод escape analysis, чтобы определить, может ли что-то быть выделено в стеке или должно быть помещено в кучу.
На практике написание программ, которые заставляют компилятор выделять только в стеке, может быть очень ограничивающим, и поэтому в Go мы используем его замечательный GC, чтобы сделать работу по поддержанию нашей heap чистой.
Go’s GC
Garbege collector от Go:
GCs-это сложные части программного обеспечения, поэтому я сделаю все возможное, чтобы все было понятным.
Начиная с v1.5, Go включали GC mark-and-sweep. Этот тип GC, как следует из названия, имеет две фазы: mark и sweep. Он не останавливает приложение (STW) для всего цикла GC, а скорее работает в основном одновременно с нашим кодом приложения. На этапе пометки среда выполнения обойдет все объекты, на которые приложение имеет ссылки в куче, и пометит их как все еще используемые. Этот набор объектов называется оперативной памятью. После этой фазы все остальное в куче, что не отмечено, считается мусором, и во время фазы sweep, будет перемещено.
Обобщим следующие термины:
Размер кучи-включает в себя все выделения, сделанные в куче; некоторые полезные, некоторые мусор.
Оперативная память-относится ко всем выделениям, на которые в настоящее время ссылается запущенное приложение; не мусор.
Оказывается, что для современных операционных систем подметание (освобождение памяти) является очень быстрой операцией, поэтому во время процесса GC для GC метки и развертки Go в значительной степени доминирует компонент метки, а не время подметания.
Маркировка включает в себя обход всех объектов, на которые в настоящее время указывает приложение, поэтому время пропорционально объему оперативной памяти в системе, независимо от общего размера кучи. Другими словами, наличие дополнительного мусора в куче не увеличит время метки, и, следовательно, не значительно увеличит время вычислений цикла GC.
Исходя из всего вышесказанного, должно казаться разумным, что менее частые чистки означают меньшую маркировку, что означает меньшие затраты ЦП с течением времени, но что такое компромисс? Ну, это память. Чем дольше среда выполнения ожидает перед чисткой, тем больше мусора будет накапливаться в памяти системы.
Как мы уже отмечали ранее, приложение Visage, которое работает на своей собственной виртуальной машине с 64GiB физической памяти, проводило очистку очень часто, используя только ~400MiB физической памяти. Чтобы понять, почему это было так, нам нужно копнуть в том, как Go решает компромисс частоты / памяти GC и обсудить Пейсер.
Pacer
Пейсер: GO GC использует pacer, чтобы определить, когда запускать следующий цикл GC. Pacing моделируется как проблема управления, где он пытается найти правильное время, чтобы вызвать цикл GC, чтобы он достиг цели размера кучи. Pacer Go по умолчанию будет пытаться инициировать цикл GC каждый раз, когда размер кучи удваивается. Это делается путем установки следующего размера триггера кучи во время фазы завершения метки текущего цикла GC. Таким образом, пометив всю оперативную память, он может принять решение о запуске следующего GC, когда общий размер кучи в 2 раза больше, чем текущий набор. Значение 2x происходит от переменной GOGC среда выполнения использует для установки коэффициента триггера.
Pacer в нашем случае превосходно справлялся с сохранением мусора на нашем heap до минимума, но это было ценой ненужной работы, так как мы использовали только ~0.6% памяти нашей системы.
Enter the ballast
Создайте балласт: Балласт в нашем приложении-это большое выделение памяти, которое обеспечивает стабильность кучи.
Мы достигаем этого, выделяя очень большой массив байтов при запуске приложения:
Так что это выглядит хорошо, как насчет загрузки процессора?
Зеленая синусоидальная метрика загрузки CPU обусловлена ежедневными колебаниями нашего трафика. Можно увидеть шаг вниз после изменения.
~30% сокращение CPU в коробке означает, не глядя дальше, мы можем уменьшить колебания на 30%, однако мы также заботимся о задержке API — подробнее об этом позже.
Как упоминалось выше, среда выполнения Go предоставляет переменную среды GOGC , которая позволяет очень грубую настройку GC pacer. Это значение управляет коэффициентом роста heap до запуска GC. Мы решили не использовать это, так как у него есть некоторые очевидные подводные камни:
- Пропорция сама по себе для нас не важна; объем памяти, которую мы используем.
- Мы должны были бы установить значение очень высоко, чтобы получить тот же эффект, что и балласт, делая значение восприимчивым к небольшим изменениям в размере живого heap.
- Рассуждать о живой памяти и скорости ее изменения нелегко; думать об общей используемой памяти просто.
Для тех, кто заинтересован, есть предложение добавить флаг целевого размера кучи к GC, который, как мы надеемся, скоро появится в среде выполнения Go.
Теперь на 2. Не будет ли это использовать до 10Gib моей драгоценной оперативной памяти? Я успокою тебя. Ответ: Нет, не будет, если вы не сделаете это намеренно. Память в системах nix (и даже Windows) фактически адресована и сопоставлена через таблицы страниц ОС. При выполнении вышеуказанного кода массив, на который указывает балластный срез, будет выделен в виртуальном адресном пространстве программы. Только если мы попытаемся прочитать или записать в срез, произойдет ошибка страницы, из-за которой физическая оперативная память, поддерживающая виртуальные адреса, будет выделена.
Мы можем легко подтвердить это с помощью следующей тривиальной программы:
Трассировка показывает, какие goroutines работают на каком процессоре. Все, что помечено app-code -это goroutine, выполняющий полезный код для нашего приложения (например, логика для обслуживания запроса API). Обратите внимание, как помимо четырех выделенных процессоров, выполняющих код GC, наши другие goroutines задерживаются и вынуждены делать MARK ASSIST (т. е. runtime.gcAssistAlloc ) работу.
Сравните это с этим профилем из того же запущенного приложения не во время цикла GC. Здесь наши goroutines тратят большую часть своего времени на запуск нашего кода приложения, как и ожидалось.
Таким образом, просто уменьшив частоту GC, мы увидели близкое к падению ~99% в работе mark assist, что привело к улучшению~45% в задержке 99-го процентиля API при пиковом трафике.
Вам может быть интересно, почему Go выбрал бы такой странный дизайн (используя голевые передачи) для своего GC, но на самом деле это имеет большой смысл. Основная функция GC заключается в том, чтобы гарантировать, что мы сохраняем кучу до разумного размера и не позволяем ей расти без мусора. Это достаточно легко в GC stop-the-world (STW), но в параллельном GC нам нужен механизм, гарантирующий, что распределения, происходящие в течение цикла GC, не растут без ограничений. На мой взгляд, каждый goroutine платит налог на распределение пропорционально тому, что он хочет выделить в цикле GC-довольно элегантный дизайн.
Для действительно всестороннего написания этого выбора дизайна см. Этот документ Google .
In (sweeping) summary
- Мы заметили, что наши приложения делают много работы GC
- Мы развернули балласт памяти
- Оно уменьшил циклы GC путем позволения куче вырасти более большой
- Задержка API улучшена, так как Go GC меньше задерживает нашу работу с голевыми передачами
- Распределение балласта в основном бесплатно, потому что оно находится в виртуальной памяти
- О балластах проще рассуждать, чем настраивать GOGC значение
- Начните с малого балласта и увеличьте с испытанием
Some final thoughts
Некоторые последние мысли: Go делает большую работу абстрагирования от многих конкретных деталей среды выполнения от программиста. Это здорово и, вероятно, работает очень хорошо для большинства программистов и приложений.
Когда вы начинаете раздвигать границы вашей прикладной среды, будь то вычисления, память или ввод-вывод, нет никакой замены для поднятия капота, осматриваясь и выясняя, почему двигатель не работает эффективно.
Когда вам нужно сделать это, и вы, вероятно, будете, это, конечно, помогает иметь набор инструментов, таких как те, что предоставляет Go, которые позволяют обнаружить узкое место быстро.
Выражение благодарности
Я хотел бы поблагодарить Хилтнера за его неоценимую помощь в расследовании и копании во многих тонкостях Go runtime и GC. Также Спасибо Жако Ле Ру, Даниэлю Бауману, Спенсеру Нельсону и рису за помощь в редактировании и корректуре этого поста.
Ссылки на литературу
Hudson — The Journey of Go’s Garbage Collector
Mark Pusher-GOLANG’s Real-time GC в теории и практике
Austin Clements-Go 1.5 параллельный сборщик мусора