GC: tri-color, pacer, GOMEMLIMIT и тюнинг
Зачем знать на Middle 1. Junior знает «GC concurrent и tri-color». Middle 1 должен объяснить: как pacer выбирает момент GC, что такое write barrier и зачем гибрид Дейкстры–Юасы, как настроить
GOGCиGOMEMLIMIT, почему ballast больше не нужен, что показываетgctrace=1, как поймать allocation-hot path через pprof иgo test -benchmem. На проде вы будете отвечать на вопросы «у нас latency spikes по 200 ms — это GC?», «куда уходит heap, мы же ничего не аллоцируем?», «почему OOM-killed на 2 GB, если в Heap у нас 1 GB?». Этот файл даёт всю опору для таких ответов.
Содержание
Заголовок раздела «Содержание»- Базовая концепция: tri-color, write barrier, фазы GC
- Под капотом: pacer, GOGC, GOMEMLIMIT, mark assist
- Gotchas: heap vs RSS, ballast, escape pitfalls
- Production-практики: тюнинг в k8s, метрики, профилирование
- Вопросы на собесе (25–30)
- Practice
- Источники
1. Базовая концепция: tri-color, write barrier, фазы GC
Заголовок раздела «1. Базовая концепция: tri-color, write barrier, фазы GC»1.1. Зачем GC
Заголовок раздела «1.1. Зачем GC»Go — managed-язык. Мы не делаем free() явно. Garbage Collector:
- Находит «живые» объекты в heap (всё, до чего можно дойти от roots = stacks, globals, регистры).
- Освобождает остальное (sweep) — возвращает память в свободные списки.
- Иногда отдаёт ОС (через
madvise(MADV_DONTNEED)илиMADV_FREE).
Go использует concurrent, non-moving, tri-color, mark-and-sweep GC. «Concurrent» = работает в основном параллельно с user-кодом. «Non-moving» = объекты не двигаются (потому safe держать unsafe.Pointer; но компенсируем фрагментацией). «Tri-color» — алгоритм маркировки.
1.2. Tri-color алгоритм (Dijkstra, 1978)
Заголовок раздела «1.2. Tri-color алгоритм (Dijkstra, 1978)»Каждый объект окрашен одним из трёх цветов:
WHITE — кандидат на удаление. Если останется белым в конце маркировки — мусор.GREY — обнаружен (из root или через другой grey), но потомки ещё не обработаны.BLACK — обработан полностью; ВСЕ ссылки этого объекта тоже cерые или чёрные.Алгоритм:
1. Изначально все объекты — WHITE.2. Roots (stacks, globals) → GREY (помещаем в work queue).3. Цикл: взять любой GREY-объект для каждой ссылки этого объекта: если WHITE → перекрасить в GREY, добавить в очередь перекрасить объект в BLACK4. Когда очередь пуста — все WHITE-объекты мусор.5. Sweep: освободить WHITE.Ключевая инвариантность tri-color (Dijkstra): «BLACK-объект не должен указывать на WHITE-объект». Если этот инвариант нарушится во время concurrent GC (потому что user-горутина изменила указатель), GC может «потерять» живой объект и удалить его. Это catastrophe.
Решение — write barrier.
1.3. Write barrier
Заголовок раздела «1.3. Write barrier»Write barrier — это микро-код, который встраивается компилятором перед каждой записью указателя:
// User-код:obj.field = newValue
// Реально компилируется в:if writeBarrier.enabled { runtime.gcWriteBarrier(&obj.field, newValue)}obj.field = newValuegcWriteBarrier обеспечивает, чтобы инвариант не нарушился. Есть два классических подхода:
Dijkstra (insertion barrier):
- При записи указателя на объект — пометить новый указатель как GREY.
- Гарантирует: ни одна запись в BLACK не приведёт к недостижимому объекту.
Yuasa (deletion barrier):
- При замене указателя — пометить старое значение как GREY.
- Гарантирует: всё, что было видно roots на момент старта GC, останется видимым.
Go использует hybrid (с Go 1.8):
- Yuasa-style на heap-указатели (для корректности при concurrent stack scan).
- Dijkstra-style на stack-указатели.
- Это позволило перестать STW-сканировать стеки целиком.
1.4. Фазы GC цикла
Заголовок раздела «1.4. Фазы GC цикла»┌─────────────────────────────────────────────────────────────┐│ Один GC цикл │└─────────────────────────────────────────────────────────────┘
STW Concurrent Mark STW Concurrent Sweep setup (user runs) mark (user runs, lazy) pause term ~10µs ~ms-s ~10µs ~ms ║ ║║║ ║ ║║║ ▼ ▼▼▼ ▼ ▼▼▼───┼──────────┼─────────────────────┼──────────────┼─────────► │ │ │ │ time
Steps:1. STW setup (Sweep Termination): - Завершить предыдущий sweep. - Включить write barrier. - Pause: десятки µs.
2. Concurrent Mark: - Запустить 25% от GOMAXPROCS как GC worker goroutines. - Маркировать tri-color. - Параллельно user-код продолжает (с write barrier). - Если user-горутина аллоцирует "слишком быстро" — mark assist.
3. STW Mark Termination: - Доделать оставшиеся серые (обычно очень мало). - Выключить write barrier. - Pause: десятки µs.
4. Concurrent Sweep: - Lazy: при следующей аллокации span-а его очищают (sweep on demand). - Также background sweepers подметают.STW pause-ы в современном Go очень короткие (десятки µs). Большую часть GC выполняется concurrently. Это достижение Go 1.5–1.8.
1.5. Sweep phase
Заголовок раздела «1.5. Sweep phase»Сweep — отдельная фаза, но в Go она lazy + concurrent:
- В фоне работают background sweepers (отдельные G).
- При аллокации новой памяти, если span не очищен, сначала его подметают (это «sweep on demand»).
- Когда span полностью пуст — возвращается в
mcentral/mheap, потенциально отдаётся ОС.
После Go 1.12 memory release делается через MADV_FREE на Linux (быстро, но RSS не уменьшается до memory pressure). После Go 1.16 — MADV_DONTNEED снова стало дефолтом (медленнее, но RSS падает; см. GODEBUG=madvdontneed=1).
1.6. Sets: white, grey, black
Заголовок раздела «1.6. Sets: white, grey, black»Технически в Go это не отдельные «множества», а bit-mask на heap arena:
- Каждый объект имеет 2 mark bits (за GC цикл).
- Color = (markBit1, markBit2) в текущей epoch.
- Очередь grey — это per-P
gcWork-буфер + central queue.
2. Под капотом: pacer, GOGC, GOMEMLIMIT, mark assist
Заголовок раздела «2. Под капотом: pacer, GOGC, GOMEMLIMIT, mark assist»2.1. Когда стартует GC: pacer
Заголовок раздела «2.1. Когда стартует GC: pacer»GC должен решить когда стартовать. Слишком рано — CPU тратится впустую. Слишком поздно — heap раздувается, OOM.
Цель pacer’а: запустить GC так, чтобы к моменту окончания heap не превысил target heap size.
Default: GOGC
Заголовок раздела «Default: GOGC»target_heap = live_heap * (1 + GOGC/100)GOGC=100(default) → target = 2× от live heap.GOGC=50→ target = 1.5× (агрессивнее GC, меньше памяти, больше CPU).GOGC=200→ target = 3× (реже GC, больше памяти).GOGC=off→ выключить GC (только для бенчмарков).
Пример:
- После GC live = 100 MB.
- Target = 200 MB.
- Когда heap достиг ~180 MB (с маржой), стартует следующий GC.
- К концу маркировки heap, скорее всего, будет 200 MB (за время mark успели наллоцировать).
GOMEMLIMIT (Go 1.19+)
Заголовок раздела «GOMEMLIMIT (Go 1.19+)»GOMEMLIMIT=2GiB — это soft memory limit. Pacer теперь стремится не превысить эту границу.
target_heap = min( live_heap * (1 + GOGC/100), // обычный target GOMEMLIMIT - non_heap_memory // лимит за вычетом stacks, code, runtime)Если приближаемся к лимиту:
- Pacer уменьшает target → GC чаще.
- Mark assist становится агрессивнее (user-горутины принудительно помогают GC).
- В extreme случае GC может идти постоянно → up to 50% CPU на GC.
Это soft limit, не hard:
- Go не упадёт, если превысит лимит (в отличие от Java
-Xmx). - Просто будет всё больше CPU тратить на GC.
Идиома для k8s: GOMEMLIMIT = container_limit * 0.9 (10% запас на runtime memory).
import "runtime/debug"
func init() { if limitBytes := readCgroupMemoryLimit(); limitBytes > 0 { debug.SetMemoryLimit(int64(float64(limitBytes) * 0.9)) }}Или через env: GOMEMLIMIT=1800MiB.
Disable GOGC при использовании GOMEMLIMIT
Заголовок раздела «Disable GOGC при использовании GOMEMLIMIT»Часто рекомендуют (в Go 1.19+ release notes):
GOGC=off GOMEMLIMIT=2GiB ./appТогда GC контролируется только GOMEMLIMIT — отлично подходит для batch-задач, которые работают близко к лимиту. Опасно: если в коде есть утечка, она поглотит всю память без сопротивления, пока не упрётесь в лимит.
Альтернатива: GOGC=75 GOMEMLIMIT=2GiB — реактивно по live heap И не превышая лимит.
2.2. Mark assist
Заголовок раздела «2.2. Mark assist»Проблема: GC concurrent, но user-код может аллоцировать быстрее, чем GC помечает. Тогда heap растёт во время GC и target overshoots.
Mark assist — это «налог» на аллокации во время GC:
- Каждый раз, когда user-горутина аллоцирует X байт, она обязана помочь GC маркировать Y объектов (пропорционально).
- Если у горутины нет «assist credit» — она блокируется в
gcAssistAllocи работает GC worker’ом.
Симптом проблем с assist в trace:
- В
go tool traceвидна «MARK ASSIST» полоска для горутины → значит, она простаивает на assist вместо своей работы. - В pprof: много времени в
runtime.gcAssistAlloc1.
Решение — снизить аллокации (см. ниже).
2.3. GC worker fraction
Заголовок раздела «2.3. GC worker fraction»По умолчанию 25% от GOMAXPROCS идёт на GC worker’ов во время mark phase. Например, на 8 CPU → 2 GC worker’а.
Это вшито: gcGoalUtilization = 0.30 (минус assist 5%).
Менять напрямую нельзя, но можно через GOGC повлиять косвенно: высокий GOGC = реже, но дольше GC.
2.4. STW pauses в реальности
Заголовок раздела «2.4. STW pauses в реальности»В современном Go (1.20+) STW обычно:
- Sweep termination: 10–30 µs.
- Mark termination: 30–100 µs (если heap большой).
Это намного меньше Java G1/CMS (могут быть 10–100 ms).
Однако если у вас:
- Огромные стеки (мегабайты на горутину) — stack scan дольше.
- Много горутин (миллионы) — суммарное время mark прекоксы дольше.
- Огромный live heap — больше объектов для маркировки.
…то даже Go может дать STW 5–20 ms.
2.5. runtime.MemStats — что смотреть
Заголовок раздела «2.5. runtime.MemStats — что смотреть»var ms runtime.MemStatsruntime.ReadMemStats(&ms)Поля (полный список):
| Поле | Смысл |
|---|---|
Sys | Общая память, запрошенная у ОС (≈ RSS). |
HeapAlloc | Live heap (объекты, не освобождённые). |
HeapSys | Heap, выделенный у ОС (incl. unused spans). |
HeapInuse | Heap, занятый объектами + спанами (≤ HeapSys). |
HeapIdle | Heap-арены, не используемые сейчас (могут быть отданы ОС). |
HeapReleased | Сколько уже отдано ОС через madvise. |
HeapObjects | Количество объектов в heap. |
StackInuse | Память на стеки горутин. |
StackSys | Запрошено у ОС под стеки. |
MSpanInuse, MCacheInuse, BuckHashSys, GCSys, OtherSys | Внутренние структуры runtime. |
NextGC | Target heap для следующего GC (байты). |
LastGC | Время последнего GC (Unix nanos). |
PauseTotalNs | Кумулятивно — все STW pauses. |
PauseNs[256] | Кольцо последних 256 pause durations. |
NumGC | Сколько GC циклов было. |
GCCPUFraction | Доля CPU, потраченная GC за всё время (0.05 = 5%). |
Что важно мониторить:
- GCCPUFraction > 5–10% — что-то не так (либо много аллокаций, либо тесный GOMEMLIMIT).
- HeapAlloc сильно скачет — не утечка.
- HeapAlloc монотонно растёт между GC циклами — leak.
- PauseNs последние > 10 ms — investigate.
2.6. GODEBUG=gctrace=1
Заголовок раздела «2.6. GODEBUG=gctrace=1»Минимальный инструмент для проверки GC:
GODEBUG=gctrace=1 ./myappКаждый GC цикл печатает строчку:
gc 12 @0.234s 4%: 0.012+1.5+0.008 ms clock, 0.097+1.2/3.0/0.5+0.064 ms cpu, 4->5->2 MB, 5 MB goal, 0 MB stacks, 0 MB globals, 8 PРасшифровка:
gc 12— 12-й GC цикл.@0.234s— время от старта программы.4%— суммарная доля CPU на GC.0.012+1.5+0.008 ms clock— wall-clock время: STW1 + concurrent + STW2.0.097+1.2/3.0/0.5+0.064 ms cpu— CPU time: STW1 + (mark assist / dedicated mark / idle mark) + STW2.4->5->2 MB— heap до GC → max во время GC → после GC.5 MB goal— target heap (pacer).0 MB stacks, 0 MB globals— что просканировано.8 P— GOMAXPROCS.
Что искать:
- Долгое
clockmark (вторая цифра, мс) — большой heap. mark assist(первая цифра внутри slash group) > 30% — user-код заставляет себя помогать.4->>5->2— большой рост heap во время mark → горутины много аллоцируют → mark assist viewер.
2.7. runtime.GC() и debug.FreeOSMemory()
Заголовок раздела «2.7. runtime.GC() и debug.FreeOSMemory()»runtime.GC()// Запустить полный GC цикл синхронно (блокирует вызывающую горутину).// Полезно: между тестами, при benchmark setup, для stable measurements.import "runtime/debug"
debug.FreeOSMemory()// runtime.GC() + попытка освободить как можно больше памяти ОС.// Дорого. Использовать только редко (после batch-операций, перед idle period).В проде не вызывайте в hot path. Иногда runtime.GC() имеет смысл после фазы инициализации (загрузили данные, теперь heap «устаканится»).
2.8. Аллокации: stack vs heap
Заголовок раздела «2.8. Аллокации: stack vs heap»Объект попадает в stack, если компилятор может доказать, что он не «утечёт» за пределы функции. Это называется escape analysis.
Stack-аллокация — почти бесплатна (movq, ничего больше). Heap-аллокация = round-trip в mcache/mcentral/mheap, плюс будущая работа GC.
Что гарантированно уходит в heap:
- Возврат указателя из функции.
- Захват переменной в closure, который пережил функцию.
- Send в channel.
- Сохранение в глобальную переменную или интерфейс с big payload.
- Slice/map, размер которого нельзя определить статически.
Что обычно остаётся в stack:
- Локальные переменные, не уходящие наружу.
- Маленькие фиксированные слайсы/массивы.
- struct’ы и числа.
Проверить:
go build -gcflags='-m=2' ./...# Выведет escape decisions:# ./main.go:10:6: moved to heap: x# ./main.go:15:13: y escapes to heapПример:
// Heap (escape):func makeBuf() []byte { return make([]byte, 1024) // escapes to heap, возвращается}
// Stack (no escape):func sum() int { arr := [1024]int{} // массив — фиксированный размер, на стеке s := 0 for _, v := range arr { s += v } return s}
// Heap (interface boxing):func log(x int) { fmt.Println(x) // x boxed в interface{} → heap}2.9. sync.Pool — переиспользование
Заголовок раздела «2.9. sync.Pool — переиспользование»var bufPool = sync.Pool{ New: func() any { return make([]byte, 4096) },}
func handler() { buf := bufPool.Get().([]byte) defer bufPool.Put(buf) // ... используем buf ...}Особенности:
- Per-P кэш: Get/Put обычно lock-free.
- Cleared on GC: после каждого GC цикла pool очищается. Это не «вечный» кэш, а смягчение пиков.
- Не подходит для долгоживущих ресурсов (соединения с БД → используйте
database/sqlpool). - Особо хорошо для buffers, encoder/decoder state, scratch space.
Gotcha: если положить в pool большой буфер, который не часто нужен, sync.Pool сам его дропнет на GC. Не пытайтесь использовать pool как «cache forever».
2.10. Allocation reduction techniques
Заголовок раздела «2.10. Allocation reduction techniques»Preallocate slices
Заголовок раздела «Preallocate slices»// Bad:xs := []int{}for i := 0; i < 10000; i++ { xs = append(xs, i)}// Каждый append → growslice → новая аллокация когда cap исчерпан.// Итого ~14 аллокаций (2× growing).
// Good:xs := make([]int, 0, 10000)for i := 0; i < 10000; i++ { xs = append(xs, i)}// 1 аллокация.Preallocate maps
Заголовок раздела «Preallocate maps»m := make(map[string]int, 1000) // hint capacitystrings.Builder вместо + concat
Заголовок раздела «strings.Builder вместо + concat»// Bad:s := ""for _, w := range words { s += w + " " // каждый раз новая строка}
// Good:var b strings.Builderb.Grow(estimatedSize) // optional preallocatefor _, w := range words { b.WriteString(w) b.WriteByte(' ')}s := b.String()Avoid string ↔ []byte conversion
Заголовок раздела «Avoid string ↔ []byte conversion»s := "hello"b := []byte(s) // АЛЛОКАЦИЯ + copys2 := string(b) // ещё одна
// В hot path можно через unsafe (если уверены, что не модифицируете):// b := unsafe.Slice(unsafe.StringData(s), len(s))// (но обычно лучше пересмотреть API)В Go 1.20+ unsafe.StringData, unsafe.SliceData, unsafe.String, unsafe.Slice — официальные. До этого приходилось хакать через reflect.SliceHeader.
Reuse buffers in JSON
Заголовок раздела «Reuse buffers in JSON»// Bad:json.Marshal(data) // каждый раз новый buffer
// Good:var buf bytes.Bufferenc := json.NewEncoder(&buf)for _, d := range data { buf.Reset() enc.Encode(d) // переиспользуем buffer // ... use buf ...}io.Copy without bufio в подходящих случаях
Заголовок раздела «io.Copy without bufio в подходящих случаях»io.Copy(dst, src) // дефолт 32 KB buffer// vsio.CopyBuffer(dst, src, buf) // your own reusable bufferReduce interface boxing
Заголовок раздела «Reduce interface boxing»// Bad (boxing):m := map[string]any{"x": 42}
// Good (typed):type Stats struct{ X int }m := map[string]Stats{"foo": {X: 42}}2.11. Pacing math пример
Заголовок раздела «2.11. Pacing math пример»Допустим:
- After GC live = 100 MB.
- GOGC = 100 → target = 200 MB.
- Allocation rate = 50 MB/sec.
- GC mark phase занимает 1 секунду.
Pacer хочет, чтобы GC закончил в момент target. Значит, стартовать надо в момент, когда heap = target − (allocation_during_mark) = 200 − 50 = 150 MB.
В реальности pacer использует “trigger ratio” (gcController.triggerRatio), который динамически корректируется на основе того, насколько pacer ошибся в прошлый раз.
2.12. Tuning стратегии
Заголовок раздела «2.12. Tuning стратегии»Low-latency сервис (API, RPC):
GOMEMLIMIT≈ 80% контейнерного лимита.GOGC=50или ниже — чаще, но короче GC.- Снизить аллокации (sync.Pool, preallocate).
- Использовать
runtime.GC()после warmup.
Batch / ETL job:
GOGC=off+GOMEMLIMIT=hard limit.- Минимум аллокаций в hot loop.
runtime.GC()после фазы (например, после загрузки данных).
Memory-constrained (embedded, sidecar):
GOMEMLIMITмаленький (например, 64 MiB).GOGC=20–50.- Готовы к up to 30% CPU на GC.
Throughput (compiler, build tool):
GOGC=200–500— реже GC, больше памяти.- Без лимита (если памяти много).
3. Gotchas
Заголовок раздела «3. Gotchas»3.1. RSS > HeapAlloc — это нормально
Заголовок раздела «3.1. RSS > HeapAlloc — это нормально»RSS = 800 MBHeapAlloc = 200 MBКуда делись 600 MB?
HeapIdle(свободные spans, ещё не отданы ОС).HeapReleased(отданы черезMADV_FREE, но RSS не уменьшился, пока ОС не давит).StackInuse(стеки горутин).MSpan/MCache/BuckHash/GC/Other Sys(служебная).- Go binary itself (.text, .rodata).
- mmap-ed file regions, если используете.
Не паникуйте, если RSS > HeapAlloc — это не утечка. Утечка = HeapAlloc сам по себе растёт.
3.2. Ballast pattern — устарел
Заголовок раздела «3.2. Ballast pattern — устарел»Историческая хак-практика:
// До Go 1.19:func main() { ballast := make([]byte, 10<<30) // 10 GB ballast _ = ballast // ...}Идея: pacer видит «live heap = 10 GB» → target = 20 GB → GC реже стартует → CPU экономится.
С Go 1.19+ используйте GOMEMLIMIT:
GOMEMLIMIT=20GiB ./appЭто даёт тот же эффект (реже GC), но без выделения фейковой памяти и без коммита.
3.3. Map не отдаёт память
Заголовок раздела «3.3. Map не отдаёт память»m := make(map[string]int)for i := 0; i < 1e7; i++ { m[strconv.Itoa(i)] = i}for k := range m { delete(m, k)}// len(m) == 0, но память map'а не возвращена!Go runtime не shrink-ит map’ы. Единственный способ:
m = nilm = make(map[string]int) // новый, маленькийС Go 1.21+ есть clear(m) — очищает все элементы, но память так же не возвращается (просто len == 0).
3.4. Slice удерживает underlying array
Заголовок раздела «3.4. Slice удерживает underlying array»big := make([]byte, 1<<30) // 1 GB// ... читаем заголовок ...header := big[:100]big = nil // не освободит!// Пока header жив, underlying array из 1 GB остаётся.
// Правильно:header := make([]byte, 100)copy(header, big[:100])big = nil3.5. Goroutine stacks тоже занимают heap?
Заголовок раздела «3.5. Goroutine stacks тоже занимают heap?»Нет. Stacks — отдельный регион (см. StackInuse). Но если ваших горутин миллион и каждая держит хотя бы 2 KB стека, это уже 2 GB. После завершения горутины стек возвращается в pool (mcache.stackcache), не сразу ОС.
3.6. cgo и GC
Заголовок раздела «3.6. cgo и GC»C-код не знаком Go GC. Указатели в C-коде:
- Go pointer → C: при cgo вызове Go pinит память до возврата. Долго хранить нельзя.
- C pointer → Go: GC не видит, не управляет. Освобождать вручную (
C.free). runtime.Pinner(Go 1.21+) — явный pin для дольшего срока.
3.7. unsafe.Pointer и GC
Заголовок раздела «3.7. unsafe.Pointer и GC»unsafe.Pointer виден GC, если содержит указатель на Go-объект. Но если вы хитро храните uintptr (просто число), GC не пометит → объект может быть удалён → use-after-free.
Не храните указатели как uintptr через GC цикл (например, в long-lived структурах).
3.8. Finalizers — медленные и ненадёжные
Заголовок раздела «3.8. Finalizers — медленные и ненадёжные»runtime.SetFinalizer(obj, func(o *T) { o.Close()})Проблемы:
- Финализатор может не запуститься, если объект просочился в global pool.
- Запускается в отдельной горутине, после того как GC решил, что объект мёртв (1 GC цикл delay).
- Цепочка финализаторов растягивает время GC.
Используйте defer obj.Close() или explicit cleanup. Финализаторы только для крайних случаев (resource leak safety net).
3.9. Mark assist может сделать low-priority горутину медленной
Заголовок раздела «3.9. Mark assist может сделать low-priority горутину медленной»Если у вас один сервис аллоцирует много, а другой нет, ОС-level fair scheduling никак не влияет на mark assist. Все горутины «платят» налог assist пропорционально аллокациям.
4. Production-практики
Заголовок раздела «4. Production-практики»4.1. Метрики для мониторинга GC
Заголовок раздела «4.1. Метрики для мониторинга GC»Минимум (экспонируем в Prometheus):
// Через runtime/metrics (Go 1.16+, рекомендуется):import "runtime/metrics"
samples := []metrics.Sample{ {Name: "/gc/heap/live:bytes"}, {Name: "/gc/heap/objects:objects"}, {Name: "/gc/pauses:seconds"}, // histogram {Name: "/gc/cycles/total:gc-cycles"}, {Name: "/sched/goroutines:goroutines"}, {Name: "/memory/classes/heap/free:bytes"}, {Name: "/memory/classes/total:bytes"},}metrics.Read(samples)Альтернатива (старее, проще): runtime.MemStats.
Готовые экспортёры:
prometheus.com/client_golang/prometheus/collectors—NewGoCollector(...).- В версии 1.16+ есть
WithGoCollections(GoRuntimeMetricsCollection), чтобы получить расширенные метрики.
Алерты:
go_gc_pause_seconds_countrate > 10/sec — слишком частый GC.go_memstats_heap_alloc_bytesgrowing trend > 1 hour без сброса — leak.go_goroutinesрастёт монотонно — leak горутин.
4.2. Установка GOMEMLIMIT из cgroup в коде
Заголовок раздела «4.2. Установка GOMEMLIMIT из cgroup в коде»package main
import ( "fmt" "os" "runtime/debug" "strconv" "strings")
func init() { if env := os.Getenv("GOMEMLIMIT"); env != "" { return // уже задан } if limit := readCgroupMemoryLimit(); limit > 0 { // 90% от лимита, оставляем margin target := int64(float64(limit) * 0.9) debug.SetMemoryLimit(target) fmt.Fprintf(os.Stderr, "GOMEMLIMIT auto-set to %d bytes\n", target) }}
func readCgroupMemoryLimit() int64 { // cgroup v2: if b, err := os.ReadFile("/sys/fs/cgroup/memory.max"); err == nil { s := strings.TrimSpace(string(b)) if s != "max" { if v, err := strconv.ParseInt(s, 10, 64); err == nil { return v } } } // cgroup v1: if b, err := os.ReadFile("/sys/fs/cgroup/memory/memory.limit_in_bytes"); err == nil { s := strings.TrimSpace(string(b)) if v, err := strconv.ParseInt(s, 10, 64); err == nil { // Огромное значение (~9.2 EB) = no limit if v < 1<<62 { return v } } } return 0}Альтернатива: KimMachineGun/automemlimit — библиотека на эту тему.
4.3. Профилирование heap
Заголовок раздела «4.3. Профилирование heap»# Live heap profile:curl http://localhost:6060/debug/pprof/heap > heap.pprofgo tool pprof -http=:8080 heap.pprof
# Сравнить heap в два момента:go tool pprof -base heap.0.pprof heap.1.pprof# Покажет, что выросло.
# alloc_space — где аллокации (даже если уже освобождены):go tool pprof -alloc_space heap.pprofВ UI ищите:
- В flame graph — самые широкие функции.
- В list
— построчная разбивка. - В peek
— кто вызывает.
4.4. Tracing GC events
Заголовок раздела «4.4. Tracing GC events»GODEBUG=gctrace=1 ./app 2> gc.logПарсить можно скриптом или через Grafana. Datadog, Honeycomb, и другие APM умеют это автоматически.
4.5. Поиск memory leak
Заголовок раздела «4.5. Поиск memory leak»Шаги:
- Снять heap profile сразу после warmup:
Окно терминала curl http://app:6060/debug/pprof/heap > before.pprof - Подождать N часов под нагрузкой.
- Снять второй:
Окно терминала curl http://app:6060/debug/pprof/heap > after.pprof - Сравнить:
Окно терминала go tool pprof -base before.pprof -http=:8080 after.pprof - Топ-функции с большим diff — кандидаты на leak.
Частые источники leak:
- Map, в который кладут, но не удаляют (cache без eviction).
- Slice, который append-ят без shrink.
- Глобальный
sync.Map. - Goroutine, удерживающая большой объект через closure.
4.6. GC и k8s memory limits
Заголовок раздела «4.6. GC и k8s memory limits»resources: limits: memory: 2Gi requests: memory: 2Gienv: - name: GOMEMLIMIT value: "1800MiB" # 90% от 2 GiB - name: GOGC value: "75" # чуть агрессивнее дефолтаБез GOMEMLIMIT:
- Go может вырасти до OOM-killed (heap exceeded container limit).
- Pacer об этом ничего не знает — он смотрит только на GOGC.
4.7. Реальный кейс: 200 ms latency spikes
Заголовок раздела «4.7. Реальный кейс: 200 ms latency spikes»Симптом: p99 latency 200 ms, средняя 5 ms.
Поиск:
GODEBUG=gctrace=1показывает GC циклы каждые 200 мс с pause 5 ms — это не GC.go tool trace→ видим, что 50% latency spikes коррелируют с тем, что горутина вruntime.gcAssistAlloc1.- Pprof heap: 90% allocations — в одном hot path, marshaling JSON.
Решение:
- Заменить
encoding/jsonнаgoccy/go-jsonилиsonic— снижение allocations 3×. GOMEMLIMIT=1.8GiB+GOGC=75— pacer стал ровнее.- Spikes ушли.
4.8. Avoid finalizers, use Close()
Заголовок раздела «4.8. Avoid finalizers, use Close()»// Bad:runtime.SetFinalizer(conn, func(c *Conn) { c.Close() })
// Good:func (s *Server) Handle(req *Request) error { conn, err := s.dial() if err != nil { return err } defer conn.Close() // ...}4.9. Reduce string conversion in hot path
Заголовок раздела «4.9. Reduce string conversion in hot path»// Bad (lots of allocations in hot path):for _, b := range bytes { if string(b) == "foo" { ... }}
// Good:fooBytes := []byte("foo")for _, b := range bytes { if bytes.Equal(b, fooBytes) { ... }}5. Вопросы на собесе
Заголовок раздела «5. Вопросы на собесе»- Опиши tri-color mark-and-sweep алгоритм. Что значат три цвета?
- Что такое write barrier и зачем он нужен?
- Какие фазы у GC цикла в Go? Какие STW, какие concurrent?
- Сколько по времени обычно длятся STW pauses в современном Go? (Десятки µs.)
- Что такое pacer и как он решает, когда стартовать GC?
- Объясни формулу target heap. Что такое GOGC?
- Что произойдёт при GOGC=off?
- Что такое GOMEMLIMIT, в какой версии Go появился, как использовать?
- Чем GOMEMLIMIT отличается от Java -Xmx? (Soft vs hard.)
- Как настроить GOMEMLIMIT в Kubernetes?
- Что такое mark assist? Как обнаружить в pprof?
- Зачем нужен hybrid (Dijkstra + Yuasa) write barrier?
- Что такое escape analysis? Как посмотреть escape decisions? (
-gcflags='-m'.) - Что НЕ попадает в stack (точно идёт в heap)?
- Что делает sync.Pool? Когда очищается?
- Как уменьшить allocations? Назови 5 техник.
- Что показывает GODEBUG=gctrace=1?
- Что такое HeapInuse vs HeapAlloc vs HeapSys?
- Почему RSS может быть сильно больше HeapAlloc?
- Что произойдёт при
delete(m, k)? Шринкается ли map? - Ballast pattern — что это, почему сейчас не нужен?
- Какой GOGC бы вы поставили для low-latency сервиса?
- Когда стоит вызвать runtime.GC() вручную?
- Что такое финализатор? Чем плох?
- Чем отличается alloc_space от inuse_space в heap profile?
- Как сравнить два heap profile? (
pprof -base.) - Сколько GC worker’ов запускает runtime по умолчанию? (25% от GOMAXPROCS.)
- Что показывает runtime.MemStats.GCCPUFraction?
- Как Go отдаёт память ОС? Через что в Linux? (
madvise.) - Чем
clear(m)(Go 1.21+) отличается от пересоздания map?
6. Practice
Заголовок раздела «6. Practice»6.1. Escape analysis
Заголовок раздела «6.1. Escape analysis»package main
import "fmt"
type Big struct{ a [1024]byte }
func mkLocal() Big { var b Big return b}
func mkHeap() *Big { var b Big return &b // escape — adresse возвращается}
func main() { a := mkLocal() b := mkHeap() fmt.Println(a.a[0], b.a[0])}go build -gcflags='-m=2' escape.go 2>&1 | grep -E "(escape|moved)"# ./escape.go:11:6: moved to heap: b6.2. gctrace в действии
Заголовок раздела «6.2. gctrace в действии»package main
func main() { for i := 0; i < 1000; i++ { _ = make([]byte, 1<<20) // 1 MB allocations }}GODEBUG=gctrace=1 go run alloc.go# Увидите серию gc N @t s6.3. sync.Pool benchmark
Заголовок раздела «6.3. sync.Pool benchmark»package pool_test
import ( "sync" "testing")
const bufSize = 4096
func BenchmarkNoPool(b *testing.B) { for i := 0; i < b.N; i++ { buf := make([]byte, bufSize) _ = buf }}
var pool = sync.Pool{ New: func() any { return make([]byte, bufSize) },}
func BenchmarkWithPool(b *testing.B) { for i := 0; i < b.N; i++ { buf := pool.Get().([]byte) pool.Put(buf) }}go test -bench=. -benchmem# BenchmarkNoPool-8 500000 2300 ns/op 4096 B/op 1 allocs/op# BenchmarkWithPool-8 20000000 65 ns/op 0 B/op 0 allocs/op6.4. GOMEMLIMIT in action
Заголовок раздела «6.4. GOMEMLIMIT in action»package main
import ( "fmt" "runtime" "runtime/debug")
func main() { debug.SetMemoryLimit(100 << 20) // 100 MB
var ms runtime.MemStats var allocs []*[1 << 20]byte for i := 0; ; i++ { allocs = append(allocs, new([1 << 20]byte)) if i%10 == 0 { runtime.ReadMemStats(&ms) fmt.Printf("HeapAlloc=%dMB NumGC=%d GCFraction=%.2f%%\n", ms.HeapAlloc>>20, ms.NumGC, ms.GCCPUFraction*100) } if i > 200 { break } }}При приближении к лимиту увидите agressive GC.
6.5. Heap profile сравнение
Заголовок раздела «6.5. Heap profile сравнение»package main
import ( "net/http" _ "net/http/pprof")
var leak [][]byte
func main() { go http.ListenAndServe(":6060", nil) for i := 0; ; i++ { leak = append(leak, make([]byte, 1024)) if i > 1e6 { break } }}# В одном терминале — запустите программу.# В другом — через 5 сек:curl http://localhost:6060/debug/pprof/heap > t1.pprof# Через 30 сек:curl http://localhost:6060/debug/pprof/heap > t2.pprof
go tool pprof -base t1.pprof -http=:8080 t2.pprof# Покажет, что main.main выросла на ~N MB.6.6. Allocations elimination
Заголовок раздела «6.6. Allocations elimination»// before:func parseStrings(lines []string) []int { result := []int{} for _, l := range lines { n, _ := strconv.Atoi(l) result = append(result, n) } return result}
// after:func parseStrings(lines []string) []int { result := make([]int, 0, len(lines)) // preallocate for _, l := range lines { n, _ := strconv.Atoi(l) result = append(result, n) } return result}# benchmark:go test -bench=. -benchmem# before: 500 ns/op 528 B/op 6 allocs/op# after: 320 ns/op 80 B/op 1 allocs/op7. Источники
Заголовок раздела «7. Источники»- Go GC: Pacer Redesign (Go 1.18, Michael Knyszek): https://github.com/golang/proposal/blob/master/design/44167-gc-pacer-redesign.md
- Soft memory limit proposal (Go 1.19): https://github.com/golang/proposal/blob/master/design/48409-soft-memory-limit.md
- A Guide to the Go Garbage Collector (official Go docs): https://go.dev/doc/gc-guide
- Go: How Does the Garbage Collector Mark the Memory? (Vincent Blanchon): https://medium.com/a-journey-with-go/go-how-does-the-garbage-collector-mark-the-memory-72cfc12c6976
- GopherCon 2018: Allocator Wrestling (Eben Freeman): https://www.youtube.com/watch?v=ncHmEUmJZf4
- Go runtime metrics (runtime/metrics package): https://pkg.go.dev/runtime/metrics
- Datadog blog — Go memory ballast/limit: https://www.datadoghq.com/blog/go-memory-metrics/
- Twitch — Go GC tuning (Rick Branson): https://blog.twitch.tv/en/2019/04/10/go-memory-ballast-how-i-learnt-to-stop-worrying-and-love-the-heap/
- Madvise modes in Go runtime (madvdontneed=1): https://pkg.go.dev/runtime#hdr-Environment_Variables
- automemlimit library: https://github.com/KimMachineGun/automemlimit
- debug package: https://pkg.go.dev/runtime/debug
- escape analysis primer (Ardan Labs): https://www.ardanlabs.com/blog/2017/05/language-mechanics-on-escape-analysis.html