Язык Go — сборщик мусора
Garbage collector в Go — трёхцветная маркировка, write barrier, GOGC, GOMEMLIMIT, паузы, escape analysis и паттерны которые убивают производительность.
Содержание
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