# Глава 20. Сборщик мусора
Как правило, разработчику на языке Go не приходится задумываться о том, где и как выделяется память. Однако на практике выделение памяти, ее освобождение и повторное использование — непростой процесс. В конечном итоге нужно задействовать физическую память компьютера. Физическая память — ограниченный ресурс, поэтому так важно распоряжаться им разумно. В Go каждое приложение содержит **библиотеку времени выполнения — runtime.** Эта библиотека включает в себя инструменты по управлению памятью, в том числе **сборщик мусора — garbage collector — GC**. Сборщик мусора освобождает память, которая больше не нужна, и позволяет использовать ее повторно.
## Какую память очищает сборщик мусора
Прежде чем рассматривать сборщик мусора, поговорим о памяти, которую сборщик мусора **не** очищает. Сборщик мусора не очищает значения локальных переменных. Например, значения переменных типа `int` или `float64` внутри функции. Такие переменные хранятся на [стеке — stack](https://ru.wikipedia.org/wiki/%D0%A1%D1%82%D0%B5%D0%BA) горутины. Компилятор Go сам может определить, когда их необходимо очистить. Для этого он генерирует соответствующие машинные инструкции. Обратите внимание, что это не касается указателей.
Есть переменные, время жизни которых компилятор определить не способен. Скажем, возвращаемые из функции переменные: неизвестно, когда их нужно уничтожить. Объемный массив вообще не поместится в стек. Иногда мы и вовсе не знаем о том, сколько памяти нам потребуется. Например, для базового массива среза, начальный размер которого определяется переменной, а не константой. Память в таких ситуациях выделяется динамически. Все эти значения помещаются в [кучу — heap](https://ru.wikipedia.org/wiki/%D0%9A%D1%83%D1%87%D0%B0_(%D0%BF%D0%B0%D0%BC%D1%8F%D1%82%D1%8C)). Именно с ними работает сборщик мусора.
## Трассировка сборщика мусора
Разные сборщики мусора по-разному определяют память, которую необходимо высвободить. Например, часть из них ведут подсчет ссылок. Как только счетчик ссылок на некоторую область памяти становится равным нулю, память освобождается. Так работает, например, сборщик мусора Python.
Сборщик мусора в Go другой. Он относится к **трассирующим сборщикам**. Трассировка — это процесс отслеживания некоторого пути. Сборщик определяет используемые объекты, следуя по указателям. **Объект** в нашей терминологии — это динамически выделенный участок памяти, который содержит одно или несколько значений. **Указатель** — это адрес любого значения внутри объекта. Указатель — это, например, переменные типа `*int` или `*float`. Также это строки, срезы, каналы, хеш-таблицы и значения интерфейсов. Вместе объекты и указатели образуют граф.
Сборщик мусора проходится по этому графу и помечает те объекты, которые программа использует. Процесс обхода графа называется **сканированием**. После сканирования сборщик мусора проходит по всей куче и делает доступной всю не помеченную память.
## Цикл сборки мусора
Сборщик мусора может работать в режиме пометки и режиме очистки памяти. Процессы пометки и очистки не могут быть выполнены параллельно. Пока не поймем, что на данный объект не ссылается ни один указатель, мы не можем очистить память. Кроме того, сборщик мусора бывает вообще не активен, когда для него нет никакой работы. Таким образом, сборщик мусора находится в одном из трех состояний: пометка, очистка и выключение. Непрерывный переход из одного состояние в другое называется **циклом сборки мусора**.
Существуют реализации сборщика мусора, которые, на время своей работы, полностью останавливают программу. Они называются сборщиками типа «stop-the-world — STW». Однако сборщик мусора в Go не останавливает выполнение программы полностью и выполняет большую часть своей работы параллельно с приложением. STW все же имеет место быть, но лишь на доли миллисекунды.
```
Выполняется программа на Go
│ │ │
▼ ▼ ▼
┌───────────────────────┐
│ Создаются переменные │
│ Выделяется память │
└───────────────────────┘
│ │ │
──────┼───────┼───────┼───────── Нужна сборка мусора
│ │ │
▼ ▼ ▼
┌───────────────────────┐
│ STW │ ◄─── Сборщик мусора останавливает
│ │ все ваши функции
└───────────────────────┘
│ │ │
──────┼───────┼───────┼───────── Программа возобновляет работу
│ │ │
▼ ▼ ▼
┌───────────────────────┐
│ Работа программы + │ ◄─── Программа работает,
│ пометка мусора │ сборщик мусора ищет мусор в фоне
└───────────────────────┘
```
## Затраты на сборку мусора
Сборщик мусора использует только два ресурса: физическую память и процессорное время.
Большая часть затрат процессора приходится на пометку и сканирование. Средняя стоимость пометки и сканирования зависит от поведения программы. Например, чем больше указателей, тем больше работы, поскольку необходимо посетить все указатели в программе. Связные списки и деревья также сложно обрабатывать.
Затраченное процессорное время зависит от общего количества циклов сборщика мусора за заданный промежуток времени. Кроме того, чем чаще запускается сборщик мусора, тем меньше памяти он тратит. Однако тем больше мы расходуем процессорного времени. Как найти компромисс между тем и другим? За то, как часто запускать сборщик мусора, отвечает параметр **GOGC**.
## GOGC
`GOGC` — Go Garbage Collector — это главный рычаг управления памятью в программе на Go. От него зависит, как часто будет запускаться сборщик мусора, чтобы очистить все лишнее.
`GOGC` — это глобальная конфигурационная переменная внутри среды времени выполнения. Она хранит целое число — процент. По умолчанию `GOGC` равен `100%`. Разберем на примере, как это работает.
Программа запустилась и оставила в памяти `10 Мб` нужных данных.
Сборщик мусора смотрит на `GOGC=100` и считает: `10 Мб + 100% = 20 Мб.`
Программа работает дальше и копит новый мусор.
Как только общая память дойдет до `20 МБ`, включится сборщик и удалит лишнее. Значение `100%` подходит подавляющему большинству программ. Но в двух случаях может потребоваться изменить его:
* Для того, чтобы выжать максимум скорости, `GOGC` повышается до `200`, `500` или даже `1000`, чтобы сборщик включался реже.
* Для того, чтобы жестко контролировать расход памяти, `GOGC` занижается до `50` или даже `30`. Это нужно, если программа работает в Docker-контейнере с ограниченной памятью или на слабом железе.
## Значения GOGC
Вот какие значения принимает `GOGC`.
* значение `off` — установить значение `GOGC` бесконечным. Эта опция часто используется для полного отключения сборщика мусора.
* `-1` — аналогично значению `off`.
* `0` — сборщик мусора включается почти постоянно.
* `[1; 100)` — более частая сборка, экономнее расходует память, но затрачивает больше процессорного времени.
* `100` — стандартный режим, хорошо подходит для большинства программ.
* `>100` — более редкая сборка, выше производительность, но растет и потребление памяти.
Максимальное значение `GOGC` зависит от конкретных реализаций сборщика мусора и платформы, на которой выполняется программа. На практике гигантские значения `GOGC` не имеют смысла. Для этого есть `off` и `-1`.
## Установка значения GOGC
Есть два способа установить значение `GOGC`:
1. Во время выполнения программы, через функцию `debug.SetGCPercent`:
```go
package main
import "runtime/debug"
func main() {
debug.SetGCPercent(50)
}
```
Функция `debug.SetGCPercent` при установке нового значения возвращает предыдущее. Если программа запущена с настройками по умолчанию, то она вернет значение `100`.
2. Через переменную окружения. Например, в Linux со значением `GOGC=off`:
```bash
GOGC=off go run cmd/main.go
```
Команда `go run` создает временный исполняемый файл. Он будет удален после того, как программа отработает. Мы также могли выставить `GOGC=off` и для постоянного исполняемого файла. Чтобы создать постоянный файл, используют команду `go build`:
```bash
go build -o prog cmd/main.go
```
Чтобы передать программе все тот же параметр, поступают так:
```bash
GOGC=off ./prog
```
Также достаточно просто установить переменную окружения средствами операционной системы.
Рассмотрим пример. Запустим сервер, который отдает некоторые данные. Функция `generateResponse` генерирует эти данные. Функция `runLoadTest` запускает нагрузочный тест со стороны клиента и получает данные с сервера. Чем больше значение `GOGC`, тем больше запросов в единицу времени успевает обработать сервер. Однако тем больше памяти для этого требуется. Обратите внимание, что и количество циклов сборщика уменьшается по мере увеличения `GOGC`.
```go {.example_for_playground}
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"runtime"
"runtime/debug"
"sync"
"sync/atomic"
"time"
)
// Время на нагрузочный тест
const timeToLoadMs = 300
// Ответ сервера. Справа указано, как будет называться
// поле в структуре json.
type response struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Tags []string `json:"tags"`
Metadata map[string]string `json:"metadata"`
Items []item `json:"items"`
}
type item struct {
Index int `json:"index"`
Value float64 `json:"value"`
Active bool `json:"active"`
Data []byte `json:"data"`
}
func generateResponse(id int) *response {
const itemsNumber = 100
items := make([]item, itemsNumber)
for i := range itemsNumber {
items[i] = item{
Index: i, Value: float64(i) * 1.5,
Active: i%2 == 0,
Data: make([]byte, 1024),
}
}
const metadataNumber = 50
metadata := make(map[string]string, metadataNumber)
for i := range metadataNumber {
metadata[fmt.Sprintf("key_%d", i)] = fmt.Sprintf("%d", i)
}
const tagsNumber = 10
tags := make([]string, tagsNumber)
for i := range tagsNumber {
tags[i] = fmt.Sprintf("tag_%d_with_description", i)
}
return &response{
ID: id,
Name: fmt.Sprintf("Object%d", id),
Description: fmt.Sprintf("Description%d", id),
Tags: tags, Metadata: metadata,
Items: items,
}
}
// Нагрузочный тест, клиент
func runLoadTest(url string) {
client := &http.Client{Timeout: timeToLoadMs * time.Millisecond}
stop := time.After(timeToLoadMs * time.Millisecond)
for {
select {
case <-stop:
return
default:
resp, err := client.Get(url)
exitOnErr(err)
resp.Body.Close()
}
}
}
func main() {
testValues := []struct {
gogc int
name string
}{
{50, "Memory-efficient, GOGC=50"},
{100, "Standard GC, GOGC=100"},
{150, "More memory usage, GOGC=150"},
}
for _, test := range testValues {
fmt.Printf("\nTest: %s\n", test.name)
debug.SetGCPercent(test.gogc)
// Количество запросов
var requestCount int64
var peakHeapAlloc uint64
var peakMutex sync.Mutex
apiHandler := func(w http.ResponseWriter, r *http.Request) {
var m runtime.MemStats
// Замеряем статистику по памяти
runtime.ReadMemStats(&m)
peakMutex.Lock()
peakHeapAlloc = max(peakHeapAlloc, m.HeapAlloc)
peakMutex.Unlock()
// Пакет atomic позволяет сделать операцию неделимой,
// это нужно для низкоуровневой синхронизации
// горутин без мьютексов.
// Это значит, что во время изменения переменной
// другая горутина не сможет вмешаться.
count := atomic.AddInt64(&requestCount, 1)
response := generateResponse(int(count))
// Сериализуем в JSON
data, err := json.Marshal(response)
exitOnErr(err)
// Отправляем ответ
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
// Устанавливаем обработчик apiHandler для сервера
server := &http.Server{
Addr: ":8080", Handler: http.HandlerFunc(apiHandler),
}
// Запускаем сервер в фоне
go server.ListenAndServe()
// Запускаем сборщик перед тестом
runtime.GC()
atomic.StoreInt64(&requestCount, 0)
var m1 runtime.MemStats
runtime.ReadMemStats(&m1)
fmt.Printf("Running the load for %d milliseconds...\n", timeToLoadMs)
loadStart := time.Now()
runLoadTest("http://localhost:8080")
loadElapsed := time.Since(loadStart)
var m2 runtime.MemStats
runtime.ReadMemStats(&m2)
fmt.Printf("\nStatistics after the test:\n"+
" Total requests: %d\n"+
" Requests/ms: %.1f\n"+
" Peak memory: %.1f MB\n"+
" Number of GC cycles: %d\n",
atomic.LoadInt64(&requestCount),
float64(atomic.LoadInt64(&requestCount))/
float64(loadElapsed.Milliseconds()),
float64(peakHeapAlloc)/1024/1024,
m2.NumGC-m1.NumGC)
server.Close()
}
}
func exitOnErr(err error) {
if err != nil {
log.Fatal(err)
}
}
```
```
Test: Memory-efficient, GOGC=50
Running the load for 300 milliseconds...
Statistics after the test:
Total requests: 525
Requests/ms: 1.8
Peak memory: 2.2 MB
Number of GC cycles: 417
Test: Standard GC, GOGC=100
Running the load for 300 milliseconds...
Statistics after the test:
Total requests: 710
Requests/ms: 2.4
Peak memory: 3.5 MB
Number of GC cycles: 184
Test: More memory usage, GOGC=150
Running the load for 300 milliseconds...
Statistics after the test:
Total requests: 798
Requests/ms: 2.7
Peak memory: 5.4 MB
Number of GC cycles: 75
```
Вывод будет немного различаться от запуска к запуску.
## Ограничение по памяти MemoryLimit
Хотя `GOGC` определяет компромисс между использованием памяти и процессорного времени, он не учитывает, что доступная память конечна. Рассмотрим пример. Допустим, произошел кратковременный рост размера кучи. Сборщик мусора оперирует общим размером кучи, пропорционально этому кратковременному значению. Памяти для этого пика может не хватить. Таким образом, `GOGC` необходимо настроить с учетом пикового размера кучи. Даже если в других циклах сборщика мусора для большого значения `GOGC` памяти хватает, мы не можем установить это значение. Хотя эти пики по памяти могут быть редкими, большое значение `GOGC` рано или поздно приведет к ситуации нехватки памяти. В итоге программа существенно замедлит свою работу. Чтобы такого не происходило, с версии Go 1.19 было добавлено ограничение по памяти.
Изначально в качестве ограничения по памяти выставлено `math.MaxInt64` байт. Это `9223372036854775807 байт = ~9 эксабайт`. Можно сказать, что по умолчанию никакого ограничения по памяти нет. Для многих программ это нормально. Однако проблемы возникают, например, с программами на Go в контейнере. В этом случае используемую память ограничивают.
## Установка значения MemoryLimit
Есть два способа установить MemoryLimit:
1. Во время выполнения программы, через функцию `debug.SetMemoryLimit`:
```go
package main
import "runtime/debug"
func main() {
debug.SetMemoryLimit(1024)
}
```
Функция `debug.SetMemoryLimit` устанавливает ограничение на использование памяти в байтах. В данном случае мы установили `1 КБ`. При установке нового значения функция возвращает предыдущее. Если программа запущена с настройками по умолчанию, то она вернет значение `9223372036854775807`.
2. Через переменную окружения. Например, в Linux со значением `GOMEMLIMIT=1024`:
```bash
GOMEMLIMIT=1024 go run cmd/main.go
```
Отметим одну важную деталь. Если выставить `GOGC=off` и начать постепенно уменьшать объем доступной памяти, то сборщик мусора будет по-прежнему срабатывать. Он будет включаться по мере достижения размера использованной памяти параметру `GOMEMLIMIT`. Когда объема доступной памяти станет совсем мало, сборщик мусора будет включен почти постоянно. Он будет пытаться поддерживать невыполнимый предел. Такая ситуация называется **«thrashing»**. Она особенно опасна, потому что приводит к зависанию программы.
Чтобы такого не возникало, ограничение по памяти устанавливается «мягко». Это значит, что сборщик мусора попытается не выйти за параметр `GOMEMLIMIT`, но в критической ситуации памяти все же будет израсходовано чуть больше.
Что выведет следующий код? В случае ошибки напишите `error`. {.task_text}
```go {.example_for_playground}
package main
import (
"fmt"
"math"
"runtime"
"runtime/debug"
)
func allocateMemory() [][]byte {
const expNumber = 1000
var mem [][]byte
const mb = 1024 * 1024
for range expNumber {
mem = append(mem, make([]byte, mb))
}
return mem
}
func main() {
debug.SetGCPercent(-1)
debug.SetMemoryLimit(math.MaxInt64)
allocateMemory()
var m runtime.MemStats
runtime.ReadMemStats(&m)
// Количество циклов сборщика
fmt.Println(m.NumGC)
}
```
```consoleoutput {.task_source #golang_chapter_0200_task_0010}
```
Если выставить `GOGC` в значение `-1`, а `GOMEMLIMIT` — в значение `math.MaxInt64`, то это приведет к полному отключению сборщика мусора. {.task_hint}
```{.task_answer}
0
```
## Новый сборщик мусора Green Tea
Начиная с версии языка Go 1.25, его авторы ввели новый сборщик мусора — Green Tea. Один из ведущих разработчиков, Остин Клементс, работал над прототипом этого сборщика в 2024 году. В это время он сидел в Японском кафе и попивал матча. В честь этого сборщик мусора получил название «зеленый чай».
Green Tea не является чем-то принципиально другим. Это модификация существующего сборщика мусора. Благодаря нему часть программ стала быстрее на 10%, а некоторые — даже на 40%. Старые программы на Go часто тратили до 20% процессорного времени на сборку мусора. Разработчики Go решили, что это много. Новый сборщик мусора тратит на 10% меньше. Прежде чем говорить об изменениях в сборщике, коснемся того, на что же уходило время.
## Бутылочное горлышко старого сборщика
Бутылочным горлышком старого сборщика является пометка. По оценкам разработчиков Go, 90% процессорного времени тратится на пометку и только 10% на очистку. Из времени, затраченного на пометку, уходит не менее 35% просто на доступ к памяти из кучи.
Старый сборщик, во время пометки, постоянно перемещается между участками памяти. В результате он выполняет крошечную работу и двигается дальше, часто даже перемещаясь между разными страницами памяти. Современные процессоры используют кеширование. Обращение к кешу до `100` раз быстрее, чем доступ к памяти вне кеша. В кеш попадает та память, к которой недавно произведен доступ. Также там оказываются те участки памяти, которые находятся рядом. Однако нет гарантии, что два объекта, указывающие друг на друга, близко находятся друг к другу в памяти. Процесс сканирования не учитывает этого.
## Идея Green Tea
Идея сборщика мусора Green Tea состоит в том, чтобы отслеживать не объекты, а страницы с объектами. Нужно помечать не все объекты подряд, а те объекты, которые находятся на данной странице, а затем переходить к следующей. Вместо того, чтобы просто идти по указателям на объекты, мы перемещаемся по указателям внутри конкретной страницы. Сами же страницы выстраиваются в очередь. Такой прием обеспечивает хорошую совместимость с архитектурой процессора. Мы сканируем близкие друг к другу объекты с большей вероятностью. Таким образом, мы чаще используем кеш. Подробнее про алгоритм рассказано на [официальном сайте](https://go.dev/blog/greenteagc) языка.
## Резюме
1. Сборщик мусора освобождает память от недоступных объектов. Тем самым мы можем использовать память повторно.
2. Цикл сборки мусора состоит из пометки, очистки и выключения. За время работы программы сборщик постоянно выполняет цикл за циклом.
3. Стоимость на сборку мусора — это затраты физической памяти и процессорного времени.
4. Чем больше памяти мы затрачиваем, тем меньше процессорного времени требуется сборщику, и наоборот. Параметр `GOGC` определяет компромисс между использованием процессорного времени и памяти.
5. `GOGC` — это процент того, насколько куча может временно разрастись за счет мусора.
6. Значение `GOGC` по умолчанию равно `100`. Это значение хорошо подходит для многих программ, однако есть способ его изменить.
7. Изменяют значение `GOGC` либо через функцию `debug.SetGCPercent`, либо через переменную окружения `GOGC`.
8. Существует возможность установить ограничение по памяти для сборщика мусора. Оно выражается в виде абсолютного значения, в байтах.
9. По умолчанию ограничение по памяти отсутствует.
10. Устанавливают ограничение по памяти либо через функцию `debug.SetMemoryLimit`, либо через переменную окружения `GOMEMLIMIT`.
11. Бутылочное горлышко старого сборщика мусора — режим пометки. Этот сборщик не учитывает локальность памяти. Проблему решает новый сборщик мусора Green Tea.
12. Сборщик мусора Green Tea — результат улучшения старого сборщика, а не принципиально новое решение.
13. Green Tea сканирует не все объекты подряд, а делает это постранично.
14. Green Tea увеличивает быстродействие части программ на 10% — 40%.
Следующие главы находятся в разработке
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!