· 18 мин 👁 1.5k Средний

Язык Go — сборщик мусора

Garbage collector в Go — трёхцветная маркировка, write barrier, GOGC, GOMEMLIMIT, паузы, escape analysis и паттерны которые убивают производительность.

GCgarbage collectorпамятьпроизводительностьescape analysisGOGC
Содержание

Go управляет памятью автоматически — программист не вызывает free. Но «автоматически» не значит «бесплатно»: сборщик мусора работает конкурентно с программой и влияет на латентность. Понимание того, как он устроен, помогает писать код который не создаёт лишней работы.

Как устроен GC в Go: три цвета

Go использует алгоритм tri-color concurrent mark-and-sweep в основе которого окрашивание цветами при обходе объектов в куче:

Белый  — объект ещё не посещён. В конце сборки все белые объекты — мусор.
Серый  — объект найден, но его ссылки ещё не проверены. Рабочая очередь GC.
Чёрный — объект проверен полностью. Гарантированно жив, ссылки обработаны.

Алгоритм по шагам:

1. STW (stop-the-world) — пауза ~0.1-0.5 мс
   — все горутины остановлены
   — GC находит корни: глобальные переменные, стеки горутин
   — корни помечаются серым

2. Конкурентная маркировка (работает параллельно с программой)
   — серые объекты обрабатываются: их ссылки помечаются серыми
   — обработанный серый становится чёрным
   — повторяется пока серых не останется

3. STW — пауза ~0.1-0.5 мс
   — завершение маркировки (finalization)
   — write barrier отключается

4. Конкурентная зачистка (работает параллельно с программой)
   — белые объекты освобождаются
   — память возвращается в пул

Большую часть работы GC делает не останавливая программу. Паузы STW в современном Go (1.14+) обычно меньше миллисекунды.

Write barrier: защита от гонки маркировки

Пока GC маркирует объекты, программа продолжает работать и может изменять ссылки. Без защиты объект может стать недостижимым уже после того как GC его обработал — и выжить, или наоборот, быть пропущен.

Решение — write barrier: специальный код, который автоматически вставляется компилятором перед каждой записью указателя:

// исходный код:
p.next = q

// что делает компилятор (упрощённо):
writeBarrier(p, &p.next, q)
// — записывает q в p.next
// — сообщает GC: «объект q ещё используется»

Write barrier активен только во время фазы маркировки. Он добавляет небольшой overhead на каждую запись указателя — поэтому интенсивная работа с указателями во время GC немного дороже.

GOGC: порог срабатывания

GC не запускается по расписанию. Он запускается когда объём живой кучи вырастает на определённый процент относительно объёма после предыдущей сборки:

GOGC=100 (по умолчанию):
  после сборки живо 10 МБ → следующая сборка при 20 МБ (рост на 100%)
  после сборки живо 50 МБ → следующая сборка при 100 МБ
  после сборки живо 500 МБ → следующая сборка при 1000 МБ

Управление через переменную окружения или код:

import "runtime/debug"

// узнать текущее значение
current := debug.SetGCPercent(-1) // -1 возвращает текущее без изменения
fmt.Println("GOGC:", current)     // 100

// уменьшить частоту GC (меньше пауз, больше памяти)
debug.SetGCPercent(200) // сборка при росте на 200%

// увеличить частоту GC (меньше памяти, больше пауз)
debug.SetGCPercent(50)

// отключить GC (только для бенчмарков!)
debug.SetGCPercent(-1)
GOGC=50  → GC срабатывает чаще → меньше памяти, больше пауз
GOGC=100 → по умолчанию, баланс
GOGC=200 → GC срабатывает реже → больше памяти, меньше пауз
GOGC=off → GC отключён (только для тестов и бенчмарков)

GOMEMLIMIT: ограничение памяти (Go 1.19+)

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

GOMEMLIMIT задаёт жёсткий предел памяти:

import "runtime/debug"

// задать через код (в байтах)
debug.SetMemoryLimit(512 << 20) // 512 МБ

// или через переменную окружения
// GOMEMLIMIT=512MiB ./myapp

Когда heap приближается к лимиту, GC начинает работать агрессивнее — вплоть до непрерывной сборки. Это лучше, чем OOM-kill от ядра.

Рекомендуемый паттерн для контейнеров:

func init() {
    // оставляем 10% на overhead рантайма и стеки горутин
    limit := int64(float64(containerMemoryLimit()) * 0.9)
    debug.SetMemoryLimit(limit)
    // с GOMEMLIMIT можно поднять GOGC — GC будет сдерживаться лимитом
    debug.SetGCPercent(200)
}

Escape analysis: стек или куча?

Самый важный момент для производительности: переменная живёт на стеке (быстро, бесплатно) или на куче (медленно, нагружает GC)?

Компилятор решает это через escape analysis. Переменная уходит на кучу если:

// 1. её адрес передаётся за пределы функции
func newInt() *int {
    x := 42
    return &x // x уходит на кучу — переживёт стекфрейм функции
}

// 2. размер неизвестен на этапе компиляции
func makeSlice(n int) []int {
    return make([]int, n) // n неизвестен — уходит на кучу
}

// 3. присваивается в интерфейс
func printVal(v interface{}) { fmt.Println(v) }

x := 42
printVal(x) // x копируется в interface{} — аллокация на куче

// 4. захватывается замыканием
func counter() func() int {
    n := 0        // n уходит на кучу — замыкание переживёт стек
    return func() int {
        n++
        return n
    }
}

Посмотреть решения компилятора:

go build -gcflags='-m' ./...
# или подробнее:
go build -gcflags='-m=2' ./...

Вывод показывает что и почему уходит на кучу:

./main.go:5:2: x escapes to heap
./main.go:9:14: make([]int, n) escapes to heap
./main.go:15:10: x escapes to heap (interface conversion)

Паттерны которые нагружают GC

Лишние аллокации в горячем пути

// ПЛОХО: каждый запрос выделяет буфер
func handleRequest(data []byte) {
    buf := make([]byte, 4096) // аллокация на каждый запрос
    copy(buf, data)
    process(buf)
}

// ХОРОШО: пул буферов
var bufPool = sync.Pool{
    New: func() interface{} { return make([]byte, 4096) },
}

func handleRequest(data []byte) {
    buf := bufPool.Get().([]byte)
    defer bufPool.Put(buf)
    copy(buf, data)
    process(buf)
}

Строки и конкатенация в цикле

// ПЛОХО: каждая конкатенация создаёт новую строку на куче
result := ""
for _, s := range items {
    result += s // O(n²) аллокаций
}

// ХОРОШО: strings.Builder переиспользует буфер
var b strings.Builder
for _, s := range items {
    b.WriteString(s)
}
result := b.String() // одна аллокация в конце

fmt.Sprintf для простых случаев

// ПЛОХО: fmt.Sprintf всегда аллоцирует
key := fmt.Sprintf("user:%d", id) // аллокация

// ХОРОШО: strconv для простых преобразований
key := "user:" + strconv.Itoa(id) // нет аллокации (в некоторых случаях)

// или заранее выделить буфер
var buf [32]byte
key := strconv.AppendInt(buf[:0], int64(id), 10)

Интерфейсы и boxing

// ПЛОХО: передача конкретного типа через interface{}
func process(v interface{}) { ... }

nums := []int{1, 2, 3}
for _, n := range nums {
    process(n) // каждое число оборачивается в interface{} — аллокация
}

// ХОРОШО: типизированная функция или generics
func processInt(v int) { ... }

// или с generics (Go 1.18+)
func process[T any](v T) { ... }

Срезы с заранее известным размером

// ПЛОХО: append постоянно перевыделяет память
var result []int
for i := 0; i < 10000; i++ {
    result = append(result, i) // множество перевыделений
}

// ХОРОШО: выделяем нужный размер сразу
result := make([]int, 0, 10000) // capacity известна заранее
for i := 0; i < 10000; i++ {
    result = append(result, i) // ни одного перевыделения
}

Большое живое множество: скрытая проблема

GC должен обойти все живые объекты при маркировке. Чем больше живых объектов — тем дольше маркировка, даже если мусора мало.

Распространённая ловушка — кэш в памяти:

// миллион записей в кэше = миллион объектов для обхода GC
var cache = map[string]*CacheEntry{}

// GC обходит все ключи и значения при каждой сборке
// при большом кэше это занимает сотни миллисекунд

Решения:

// 1. хранить значения (не указатели) напрямую
var cache = map[string]CacheEntry{} // нет указателей внутри — GC быстрее

// 2. использовать []byte вместо строк-ключей где возможно
// строки в Go содержат указатель на данные — GC должен их обойти

// 3. offheap-кэш: данные вне кучи Go (например, через mmap или внешнее хранилище)
// GC не видит такую память вообще

// 4. ограничить размер кэша
// меньше живых объектов = быстрее маркировка

Финализаторы: использовать с осторожностью

runtime.SetFinalizer позволяет задать функцию которая вызывается перед уничтожением объекта:

type Resource struct {
    handle uintptr
}

func NewResource() *Resource {
    r := &Resource{handle: openHandle()}
    runtime.SetFinalizer(r, func(r *Resource) {
        closeHandle(r.handle) // вызовется перед GC
    })
    return r
}

Проблемы с финализаторами:

  • не детерминированы: GC может не запуститься долго или вообще не запуститься до выхода программы
  • задерживают сборку: объект с финализатором переживает минимум один дополнительный цикл GC
  • не вызываются при панике: если программа падает — финализаторы не успевают

Правило: используйте явный Close() или defer вместо финализаторов. Финализатор — страховка, а не основной механизм.

Как измерить давление на GC

Стандартные инструменты:

// счётчики в runtime
var stats runtime.MemStats
runtime.ReadMemStats(&stats)

fmt.Printf("аллокаций всего: %d\n", stats.Mallocs)
fmt.Printf("освобождений всего: %d\n", stats.Frees)
fmt.Printf("живая куча: %d МБ\n", stats.HeapInuse/1024/1024)
fmt.Printf("циклов GC: %d\n", stats.NumGC)
fmt.Printf("пауза последнего GC: %v\n", time.Duration(stats.PauseNs[(stats.NumGC+255)%256]))

Через переменную окружения — трассировка GC при запуске:

GODEBUG=gctrace=1 ./myapp
# gc 1 @0.012s 2%: 0.026+1.1+0.008 ms clock, ...
#    ^  ^       ^   ^               ^
#    N  время   %   STW             конкурентная часть

Профилирование аллокаций через pprof:

go test -memprofile=mem.prof -bench=.
go tool pprof mem.prof
# (pprof) top10       — топ по аллокациям
# (pprof) list myFunc — аллокации построчно

Практические советы

Уменьшить количество аллокаций:

  • sync.Pool для часто создаваемых объектов одинакового размера
  • предвыделять слайсы через make([]T, 0, capacity) когда размер известен
  • strings.Builder и bytes.Buffer вместо конкатенации
  • передавать массивы по значению если они небольшие — они живут на стеке

Уменьшить размер живого множества:

  • хранить значения вместо указателей в map и slice где возможно
  • ограничивать размер in-memory кэшей
  • использовать []byte вместо string для ключей map если нужна производительность

Настроить поведение GC:

  • в контейнерах всегда устанавливать GOMEMLIMIT
  • для latency-sensitive сервисов попробовать GOGC=200 с GOMEMLIMIT — реже запускать GC, сдерживая рост памяти лимитом
  • для batch-задач с коротким временем жизни — GOGC=off и runtime.GC() вручную в конце

Проверять escape analysis:

  • go build -gcflags='-m' регулярно для горячих путей
  • неожиданный escape на кучу — повод задуматься о рефакторинге

Итоги

  • Go GC — трёхцветный concurrent mark-and-sweep: большую часть работы делает параллельно с программой, паузы STW < 1 мс
  • Write barrier защищает от гонки во время маркировки — небольшой overhead на каждую запись указателя
  • GOGC=100 по умолчанию: GC при росте кучи на 100%. Увеличить для меньшего числа пауз, уменьшить для экономии памяти
  • GOMEMLIMIT (Go 1.19+) — жёсткий предел памяти: критически важен для контейнерных окружений
  • Escape analysis: переменная уходит на кучу если адрес передаётся наружу, размер неизвестен, или присваивается в интерфейс
  • Большое живое множество замедляет маркировку — кэши с миллионом указателей это дорого
  • sync.Pool, предвыделение, strings.Builder — три главных инструмента снижения давления на GC
  • Финализаторы недетерминированы — используйте defer и явный Close()
  • Измеряйте: GODEBUG=gctrace=1, runtime.ReadMemStats, pprof -memprofile