Перейти к содержимому

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?». Этот файл даёт всю опору для таких ответов.


  1. Базовая концепция: tri-color, write barrier, фазы GC
  2. Под капотом: pacer, GOGC, GOMEMLIMIT, mark assist
  3. Gotchas: heap vs RSS, ballast, escape pitfalls
  4. Production-практики: тюнинг в k8s, метрики, профилирование
  5. Вопросы на собесе (25–30)
  6. Practice
  7. Источники

Go — managed-язык. Мы не делаем free() явно. Garbage Collector:

  1. Находит «живые» объекты в heap (всё, до чего можно дойти от roots = stacks, globals, регистры).
  2. Освобождает остальное (sweep) — возвращает память в свободные списки.
  3. Иногда отдаёт ОС (через madvise(MADV_DONTNEED) или MADV_FREE).

Go использует concurrent, non-moving, tri-color, mark-and-sweep GC. «Concurrent» = работает в основном параллельно с user-кодом. «Non-moving» = объекты не двигаются (потому safe держать unsafe.Pointer; но компенсируем фрагментацией). «Tri-color» — алгоритм маркировки.

Каждый объект окрашен одним из трёх цветов:

WHITE — кандидат на удаление. Если останется белым в конце маркировки — мусор.
GREY — обнаружен (из root или через другой grey), но потомки ещё не обработаны.
BLACK — обработан полностью; ВСЕ ссылки этого объекта тоже cерые или чёрные.

Алгоритм:

1. Изначально все объекты — WHITE.
2. Roots (stacks, globals) → GREY (помещаем в work queue).
3. Цикл:
взять любой GREY-объект
для каждой ссылки этого объекта:
если WHITE → перекрасить в GREY, добавить в очередь
перекрасить объект в BLACK
4. Когда очередь пуста — все WHITE-объекты мусор.
5. Sweep: освободить WHITE.

Ключевая инвариантность tri-color (Dijkstra): «BLACK-объект не должен указывать на WHITE-объект». Если этот инвариант нарушится во время concurrent GC (потому что user-горутина изменила указатель), GC может «потерять» живой объект и удалить его. Это catastrophe.

Решение — write barrier.

Write barrier — это микро-код, который встраивается компилятором перед каждой записью указателя:

// User-код:
obj.field = newValue
// Реально компилируется в:
if writeBarrier.enabled {
runtime.gcWriteBarrier(&obj.field, newValue)
}
obj.field = newValue

gcWriteBarrier обеспечивает, чтобы инвариант не нарушился. Есть два классических подхода:

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-сканировать стеки целиком.
┌─────────────────────────────────────────────────────────────┐
│ Один 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.

С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).

Технически в Go это не отдельные «множества», а bit-mask на heap arena:

  • Каждый объект имеет 2 mark bits (за GC цикл).
  • Color = (markBit1, markBit2) в текущей epoch.
  • Очередь grey — это per-P gcWork-буфер + central queue.

GC должен решить когда стартовать. Слишком рано — CPU тратится впустую. Слишком поздно — heap раздувается, OOM.

Цель pacer’а: запустить GC так, чтобы к моменту окончания heap не превысил target heap size.

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=2GiB — это soft memory limit. Pacer теперь стремится не превысить эту границу.

target_heap = min(
live_heap * (1 + GOGC/100), // обычный target
GOMEMLIMIT - non_heap_memory // лимит за вычетом stacks, code, runtime
)

Если приближаемся к лимиту:

  1. Pacer уменьшает target → GC чаще.
  2. Mark assist становится агрессивнее (user-горутины принудительно помогают GC).
  3. В 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.

Часто рекомендуют (в Go 1.19+ release notes):

Окно терминала
GOGC=off GOMEMLIMIT=2GiB ./app

Тогда GC контролируется только GOMEMLIMIT — отлично подходит для batch-задач, которые работают близко к лимиту. Опасно: если в коде есть утечка, она поглотит всю память без сопротивления, пока не упрётесь в лимит.

Альтернатива: GOGC=75 GOMEMLIMIT=2GiB — реактивно по live heap И не превышая лимит.

Проблема: 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.

Решение — снизить аллокации (см. ниже).

По умолчанию 25% от GOMAXPROCS идёт на GC worker’ов во время mark phase. Например, на 8 CPU → 2 GC worker’а.

Это вшито: gcGoalUtilization = 0.30 (минус assist 5%).

Менять напрямую нельзя, но можно через GOGC повлиять косвенно: высокий GOGC = реже, но дольше GC.

В современном 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.

var ms runtime.MemStats
runtime.ReadMemStats(&ms)

Поля (полный список):

ПолеСмысл
SysОбщая память, запрошенная у ОС (≈ RSS).
HeapAllocLive heap (объекты, не освобождённые).
HeapSysHeap, выделенный у ОС (incl. unused spans).
HeapInuseHeap, занятый объектами + спанами (≤ HeapSys).
HeapIdleHeap-арены, не используемые сейчас (могут быть отданы ОС).
HeapReleasedСколько уже отдано ОС через madvise.
HeapObjectsКоличество объектов в heap.
StackInuseПамять на стеки горутин.
StackSysЗапрошено у ОС под стеки.
MSpanInuse, MCacheInuse, BuckHashSys, GCSys, OtherSysВнутренние структуры runtime.
NextGCTarget 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.

Минимальный инструмент для проверки 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 clockwall-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.

Что искать:

  • Долгое clock mark (вторая цифра, мс) — большой heap.
  • mark assist (первая цифра внутри slash group) > 30% — user-код заставляет себя помогать.
  • 4->>5->2 — большой рост heap во время mark → горутины много аллоцируют → mark assist viewер.
runtime.GC()
// Запустить полный GC цикл синхронно (блокирует вызывающую горутину).
// Полезно: между тестами, при benchmark setup, для stable measurements.
import "runtime/debug"
debug.FreeOSMemory()
// runtime.GC() + попытка освободить как можно больше памяти ОС.
// Дорого. Использовать только редко (после batch-операций, перед idle period).

В проде не вызывайте в hot path. Иногда runtime.GC() имеет смысл после фазы инициализации (загрузили данные, теперь 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
}
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/sql pool).
  • Особо хорошо для buffers, encoder/decoder state, scratch space.

Gotcha: если положить в pool большой буфер, который не часто нужен, sync.Pool сам его дропнет на GC. Не пытайтесь использовать pool как «cache forever».

// 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 аллокация.
m := make(map[string]int, 1000) // hint capacity
// Bad:
s := ""
for _, w := range words {
s += w + " " // каждый раз новая строка
}
// Good:
var b strings.Builder
b.Grow(estimatedSize) // optional preallocate
for _, w := range words {
b.WriteString(w)
b.WriteByte(' ')
}
s := b.String()
s := "hello"
b := []byte(s) // АЛЛОКАЦИЯ + copy
s2 := 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.

// Bad:
json.Marshal(data) // каждый раз новый buffer
// Good:
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
for _, d := range data {
buf.Reset()
enc.Encode(d) // переиспользуем buffer
// ... use buf ...
}
io.Copy(dst, src) // дефолт 32 KB buffer
// vs
io.CopyBuffer(dst, src, buf) // your own reusable buffer
// Bad (boxing):
m := map[string]any{"x": 42}
// Good (typed):
type Stats struct{ X int }
m := map[string]Stats{"foo": {X: 42}}

Допустим:

  • 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 ошибся в прошлый раз.

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, больше памяти.
  • Без лимита (если памяти много).

Окно терминала
RSS = 800 MB
HeapAlloc = 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 сам по себе растёт.

Историческая хак-практика:

// До 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), но без выделения фейковой памяти и без коммита.

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 = nil
m = make(map[string]int) // новый, маленький

С Go 1.21+ есть clear(m) — очищает все элементы, но память так же не возвращается (просто len == 0).

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 = nil

Нет. Stacks — отдельный регион (см. StackInuse). Но если ваших горутин миллион и каждая держит хотя бы 2 KB стека, это уже 2 GB. После завершения горутины стек возвращается в pool (mcache.stackcache), не сразу ОС.

C-код не знаком Go GC. Указатели в C-коде:

  • Go pointer → C: при cgo вызове Go pinит память до возврата. Долго хранить нельзя.
  • C pointer → Go: GC не видит, не управляет. Освобождать вручную (C.free).
  • runtime.Pinner (Go 1.21+) — явный pin для дольшего срока.

unsafe.Pointer виден GC, если содержит указатель на Go-объект. Но если вы хитро храните uintptr (просто число), GC не пометит → объект может быть удалён → use-after-free.

Не храните указатели как uintptr через GC цикл (например, в long-lived структурах).

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 пропорционально аллокациям.


Минимум (экспонируем в 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/collectorsNewGoCollector(...).
  • В версии 1.16+ есть WithGoCollections(GoRuntimeMetricsCollection), чтобы получить расширенные метрики.

Алерты:

  • go_gc_pause_seconds_count rate > 10/sec — слишком частый GC.
  • go_memstats_heap_alloc_bytes growing trend > 1 hour без сброса — leak.
  • go_goroutines растёт монотонно — leak горутин.
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 — библиотека на эту тему.

Окно терминала
# Live heap profile:
curl http://localhost:6060/debug/pprof/heap > heap.pprof
go 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 — кто вызывает.
Окно терминала
GODEBUG=gctrace=1 ./app 2> gc.log

Парсить можно скриптом или через Grafana. Datadog, Honeycomb, и другие APM умеют это автоматически.

Шаги:

  1. Снять heap profile сразу после warmup:
    Окно терминала
    curl http://app:6060/debug/pprof/heap > before.pprof
  2. Подождать N часов под нагрузкой.
  3. Снять второй:
    Окно терминала
    curl http://app:6060/debug/pprof/heap > after.pprof
  4. Сравнить:
    Окно терминала
    go tool pprof -base before.pprof -http=:8080 after.pprof
  5. Топ-функции с большим diff — кандидаты на leak.

Частые источники leak:

  • Map, в который кладут, но не удаляют (cache без eviction).
  • Slice, который append-ят без shrink.
  • Глобальный sync.Map.
  • Goroutine, удерживающая большой объект через closure.
resources:
limits:
memory: 2Gi
requests:
memory: 2Gi
env:
- name: GOMEMLIMIT
value: "1800MiB" # 90% от 2 GiB
- name: GOGC
value: "75" # чуть агрессивнее дефолта

Без GOMEMLIMIT:

  • Go может вырасти до OOM-killed (heap exceeded container limit).
  • Pacer об этом ничего не знает — он смотрит только на GOGC.

Симптом: p99 latency 200 ms, средняя 5 ms.

Поиск:

  1. GODEBUG=gctrace=1 показывает GC циклы каждые 200 мс с pause 5 ms — это не GC.
  2. go tool trace → видим, что 50% latency spikes коррелируют с тем, что горутина в runtime.gcAssistAlloc1.
  3. Pprof heap: 90% allocations — в одном hot path, marshaling JSON.

Решение:

  1. Заменить encoding/json на goccy/go-json или sonic — снижение allocations 3×.
  2. GOMEMLIMIT=1.8GiB + GOGC=75 — pacer стал ровнее.
  3. Spikes ушли.
// 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()
// ...
}
// 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) { ... }
}

  1. Опиши tri-color mark-and-sweep алгоритм. Что значат три цвета?
  2. Что такое write barrier и зачем он нужен?
  3. Какие фазы у GC цикла в Go? Какие STW, какие concurrent?
  4. Сколько по времени обычно длятся STW pauses в современном Go? (Десятки µs.)
  5. Что такое pacer и как он решает, когда стартовать GC?
  6. Объясни формулу target heap. Что такое GOGC?
  7. Что произойдёт при GOGC=off?
  8. Что такое GOMEMLIMIT, в какой версии Go появился, как использовать?
  9. Чем GOMEMLIMIT отличается от Java -Xmx? (Soft vs hard.)
  10. Как настроить GOMEMLIMIT в Kubernetes?
  11. Что такое mark assist? Как обнаружить в pprof?
  12. Зачем нужен hybrid (Dijkstra + Yuasa) write barrier?
  13. Что такое escape analysis? Как посмотреть escape decisions? (-gcflags='-m'.)
  14. Что НЕ попадает в stack (точно идёт в heap)?
  15. Что делает sync.Pool? Когда очищается?
  16. Как уменьшить allocations? Назови 5 техник.
  17. Что показывает GODEBUG=gctrace=1?
  18. Что такое HeapInuse vs HeapAlloc vs HeapSys?
  19. Почему RSS может быть сильно больше HeapAlloc?
  20. Что произойдёт при delete(m, k)? Шринкается ли map?
  21. Ballast pattern — что это, почему сейчас не нужен?
  22. Какой GOGC бы вы поставили для low-latency сервиса?
  23. Когда стоит вызвать runtime.GC() вручную?
  24. Что такое финализатор? Чем плох?
  25. Чем отличается alloc_space от inuse_space в heap profile?
  26. Как сравнить два heap profile? (pprof -base.)
  27. Сколько GC worker’ов запускает runtime по умолчанию? (25% от GOMAXPROCS.)
  28. Что показывает runtime.MemStats.GCCPUFraction?
  29. Как Go отдаёт память ОС? Через что в Linux? (madvise.)
  30. Чем clear(m) (Go 1.21+) отличается от пересоздания map?

escape.go
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: b
alloc.go
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 s
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/op
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.

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.
// 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/op

  1. Go GC: Pacer Redesign (Go 1.18, Michael Knyszek): https://github.com/golang/proposal/blob/master/design/44167-gc-pacer-redesign.md
  2. Soft memory limit proposal (Go 1.19): https://github.com/golang/proposal/blob/master/design/48409-soft-memory-limit.md
  3. A Guide to the Go Garbage Collector (official Go docs): https://go.dev/doc/gc-guide
  4. 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
  5. GopherCon 2018: Allocator Wrestling (Eben Freeman): https://www.youtube.com/watch?v=ncHmEUmJZf4
  6. Go runtime metrics (runtime/metrics package): https://pkg.go.dev/runtime/metrics
  7. Datadog blog — Go memory ballast/limit: https://www.datadoghq.com/blog/go-memory-metrics/
  8. 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/
  9. Madvise modes in Go runtime (madvdontneed=1): https://pkg.go.dev/runtime#hdr-Environment_Variables
  10. automemlimit library: https://github.com/KimMachineGun/automemlimit
  11. debug package: https://pkg.go.dev/runtime/debug
  12. escape analysis primer (Ardan Labs): https://www.ardanlabs.com/blog/2017/05/language-mechanics-on-escape-analysis.html