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

Go GC, Pacer 2.0 и Goroutine Stack: внутренности

Сборщик мусора Go — это concurrent tri-color mark-sweep с hybrid write barrier. STW pauses измеряются десятками микросекунд, mark/sweep работают параллельно с user-кодом. На уровне Middle 2 нужно знать pacer-математику (формулы для решения “когда стартовать GC”), GOMEMLIMIT, mark assist и тонкости stack scanning. На собеседованиях в Авито/Яндекс/Тинькофф 2025-2026 годов вопрос про GC pacer — обязательная программа. Часто просят объяснить, почему GOGC=100 не работает на большом heap, как GOMEMLIMIT спас от OOM, какая математика стоит за heap_target = live_heap × (1 + GOGC/100).

  1. Базовая концепция GC и стека (для разогрева)
  2. Глубокое погружение: tri-color, write barrier, pacer, stack growth
  3. Подводные камни GC и стека
  4. Производительность и реальные кейсы
  5. Вопросы на собесе Middle 2
  6. Practice
  7. Источники

GC: Go использует concurrent mark-sweep. Этапы:

  1. Mark start — STW, инициализация (~10-50 µs).
  2. Concurrent mark — параллельно с user, обходим объекты, помечаем живые.
  3. Mark termination — STW, финализация mark (~10-100 µs).
  4. Concurrent sweep — освобождение мёртвых объектов (lazy, в фоне).

Стек: каждая goroutine имеет собственный стек, начинающийся с 2 KB. Растёт по необходимости (copying stack). При GC стек тоже сканируется (точные указатели через stack maps).

Базовая инвариант GC: трёхцветный алгоритм + hybrid write barrier. Это даёт correctness при concurrent mark.

GC цикл:
─────────────────────────────────────────────────► time
STW │ concurrent mark │ STW │ concurrent sweep │
~50µs ~100ms ~80µs ~50-200ms
mark (с user-code) mark (с user-code)
start term

Три цвета объектов:

  • White — кандидат на удаление (GC ещё не видел).
  • Grey — GC видел, но ещё не обошёл его поля.
  • Black — GC видел и обошёл все поля.

GC начинает с corners (stacks + globals) = grey. Обход:

  1. Достаём grey-объект из worklist.
  2. Сканируем его поля. Все указатели → если они white, делаем их grey.
  3. Сам объект становится black.

В конце mark phase: все живые = black, все мёртвые = white. Sweep удаляет white.

Tri-color invariant: black НЕ должен ссылаться на white напрямую. Иначе после mark white-объект может быть удалён, а black держит ссылку на garbage → use-after-free.

Когда concurrent mark идёт параллельно с user-кодом, программа может изменять указатели:

  • Black object A получает ссылку на white object B (нарушает invariant!).
  • White object B теряет единственную ссылку до того, как mark обошёл его (хотя B живой).

Решение — write barrier: каждое присваивание указателя *p = q проходит через специальную функцию, которая корректирует состояние GC.

Go использует Hybrid Write Barrier (Dijkstra-style + Yuasa-style):

//go:nosplit
func writebarrierptr(dst *uintptr, src uintptr) {
*dst = src
if writeBarrier.enabled {
wbBufFlush(dst, src) // буферизированный barrier
}
}
// упрощённый алгоритм:
// 1. Если *dst (старое значение) был white → пометить серым ("snapshot").
// 2. Если src (новое значение) white и dst в black-объекте → пометить серым ("incremental").

Это hybrid — берёт shadow snapshot стека (один раз при mark start, без stack rescan) + Dijkstra protection при изменении указателя на heap.

В Go 1.8+ это позволяет НЕ делать STW stack rescan, что было главным источником длинных STW пауз до этого.

GC roots:
┌─────────────────┐
│ globals + stacks│ ← начальный набор серых
└────────┬────────┘
│ grey queue
┌──────────┐
│ worker │ ← gcBgMarkWorker'ы (GOMAXPROCS / 4)
│ + │ + mark assist (user G помогают)
│ assist │
└────┬─────┘
│ scan object
for each pointer p in obj:
if p is white:
p → grey (atomic CAS на gcmarkBits)
push to worklist
obj → black

gcBgMarkWorker — это специальные горутины, по одной на P / 4 ядра. Они работают в фоне.

mark assist: если user-горутина аллоцирует быстрее, чем GC mark прогрессирует, мы заставляем её помочь. В mallocgc есть проверка:

assistG := getg().assistBytes // долг текущей G
if assistG > 0 {
gcAssistAlloc(gp)
}

Если assist долг > 0 — горутина сама делает mark, пока не отыграет долг. Это natural backpressure: чем больше ты аллоцируешь, тем больше ты GC-помощник.

Worklist — это набор серых объектов для обработки. Реализация — per-P + global, с work-stealing:

// gcWork: per-P буфер серых указателей
type gcWork struct {
wbuf1, wbuf2 *workbuf // double-buffered queue (256 указателей в каждом)
bytesMarked uint64 // статистика
scanWork int64 // прогресс scan'а
}

При исчерпании local — берётся следующий workbuf из global pool. При переполнении — отдаётся в global pool.

Mark start STW (~10-50 µs):

  1. Все горутины останавливаются (через preempt + sync).
  2. Включается write barrier (writeBarrier.enabled = true).
  3. Сканируются глобалы (data + bss segments).
  4. Стеки помечаются как “to scan” (но не сканируются сейчас — будут в concurrent).
  5. World resumed.

Mark termination STW (~10-100 µs):

  1. World stops.
  2. Финализация worklist (drain последних серых).
  3. Mark bits зафиксированы.
  4. Write barrier выключается.
  5. Подготовка к sweep.
  6. World resumed.

Что важно: STW pauses в Go 1.22+ обычно меньше 100 µs, даже на heap в 100+ GB. Это достижение многолетнего тюнинга concurrent algorithm.

После mark все mark bits знают, что живо. Sweep:

  • Concurrent — фоновая горутина bgsweep обходит spans, освобождает мёртвые слоты.
  • Lazy / opportunistic — при первом обращении к span’у со стороны mcache.refill, span sweep’ается прежде чем выдан.

Sweep ≠ освобождение в ОС. Sweep только переводит “мёртвые слоты” в “свободные”. Возврат в ОС — это scavenger (см. файл 2).

Pacer решает: когда стартовать следующий GC?

Цели:

  1. Heap goal: после GC heap ≤ live_heap × (1 + GOGC/100). Например, GOGC=100, live_heap=100 MB → goal = 200 MB.
  2. CPU goal: GC должен использовать ≤ 25% CPU.

Математика (упрощённо):

GOGC = 100 (default)
live_heap = объём живых объектов после прошлого GC (например, 100 MB)
heap_target = live_heap × (1 + GOGC/100) = 200 MB
trigger_ratio = (heap_at_trigger - live_heap) / live_heap
≈ 0.7 × (GOGC/100) = 0.7 (стартуем mark при heap = 170 MB)
# Pacer 2.0 учитывает прошлый scan rate и mutator allocation rate

В Pacer 2.0 (Austin Clements, design doc) более сложная PID-like формула, учитывающая:

  • Прошлый mark duration.
  • Прошлый allocation rate.
  • Текущее значение GOGC.
  • GOMEMLIMIT (см. ниже).

Цель — закончить mark точно когда heap дорос до heap_target, не раньше (тратим лишний CPU) и не позже (превысим heap_target).

Каждая горутина имеет gp.gcAssistBytes — текущий “долг” в байтах перед GC.

При каждой аллокации:

gp.gcAssistBytes -= size // мы аллоцировали size, должны помочь

Если gcAssistBytes < 0 → мы превысили “квоту” → должны помочь GC mark. gcAssistAlloc обходит worklist’ы, делает scan, отыгрывает долг.

Коэффициент scan-work-per-byte = assistWorkPerByte — рассчитывается pacer’ом на основе прошлых GC. Обычно ~5-10 байт scan на каждый allocated байт.

⚠️ Если GC сильно отстаёт, mark assist может тратить десятки % CPU. В pprof это runtime.gcAssistAlloc. Лечение: снизить allocation rate или увеличить GOGC.

gcTrigger решает, нужно ли стартовать GC. Три типа триггеров:

  • gcTriggerHeap — heap_live достиг heap_trigger (главный путь).
  • gcTriggerTime — прошло > 2 мин с последнего GC (force GC через sysmon).
  • gcTriggerCycle — явный runtime.GC().

Trigger ratio адаптивная — каждый цикл pacer корректирует, чтобы попадать в heap_target.

GOMEMLIMITsoft memory limit для всего Go процесса (heap + metadata + stacks). По умолчанию off (no limit).

Окно терминала
GOMEMLIMIT=4GiB go run main.go
# или в коде:
debug.SetMemoryLimit(4 * 1024 * 1024 * 1024)

Что делает:

  1. Если current memory use приближается к limit — pacer аггрессивно стартует GC.
  2. Если близко к limit — runtime.GC() может быть форсирован.
  3. После GC scavenger пытается вернуть память в ОС.

⚠️ Это soft limit. Go не блокирует аллокации по лимиту — может временно превысить. Но pacer старается удержаться ниже.

GOGC=off + GOMEMLIMIT: классический паттерн для serverless / k8s pods. Отключаем proportional GC, оставляем только лимит. GC будет триггериться только при подходе к лимиту.

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

Best practice: GOMEMLIMIT = 90% от container memory limit.

resources:
limits:
memory: 1Gi
env:
- name: GOMEMLIMIT
value: "900MiB" # 90% от 1Gi

Почему не 100%? 10% запас на:

  • Native stacks (если pod thread-rich).
  • C-allocated memory (cgo).
  • Mmap’нутые регионы (ML inference, embeddings).

С Go 1.21+ есть автоматический режим через automaxprocs-подобный механизм для GOMEMLIMIT (через GOMEMLIMIT=auto — пока экспериментально).

До GOMEMLIMIT (Go 1.16-1.18) — паттерн “ballast”:

ballast := make([]byte, 10<<30) // 10 GB
runtime.KeepAlive(ballast)

Идея: создаём огромный массив (виртуально, физически не использован). live_heap “официально” = 10 GB. GOGC=100 → heap_target = 20 GB. То есть GC реже срабатывает.

В современных Go (1.19+) не используйте memory ballast. Замените на GOMEMLIMIT. Ballast тратит виртуальную память, может вызывать OOM при page fault, и не учитывается scavenger’ом.

Современный API для метрик runtime, заменяет deprecated MemStats:

import "runtime/metrics"
samples := []metrics.Sample{
{Name: "/gc/heap/live:bytes"},
{Name: "/gc/heap/goal:bytes"},
{Name: "/gc/cycles/total:gc-cycles"},
{Name: "/gc/pauses:seconds"},
{Name: "/sched/goroutines:goroutines"},
}
metrics.Read(samples)

Список всех метрик: metrics.All(). Возвращает иерархические имена (/gc/heap/..., /sched/..., /cgo/...).

Преимущества над MemStats:

  • Не STW (старый runtime.ReadMemStats делал STW!).
  • Расширяемое — Go-команда добавляет новые метрики без поломки API.
runtime.GC() // блокирующий полный GC

Полезно:

  • В тестах (forcing stable state).
  • Перед benchmarking (clean slate).
  • В долгоживущих сервисах после массивных деаллокаций (например, после batch processing).

⚠️ Не вызывать в hot path. Полный GC = ~100ms на 1 GB heap.

debug.FreeOSMemory()

Что делает:

  1. Forced GC.
  2. Полный scavenge (возврат всех свободных страниц в ОС через MADV_DONTNEED).
  3. Блокирующий вызов.

Полезно: после массивных временных аллокаций (например, парсинг большого файла) и нужно вернуть память.

0% 25% 50% 100% (heap_target)
│ │ │ │
├─live_heap────────────────────────────────────goal──┤
│ │
│ trigger (gcController.trigger) │
│ ▼ │
│ │ │
────────────────► │ │
allocation │ │
│ concurrent mark │
├──────────────────────────────────┤
│ │
│ user-G aллокируют, иногда help │
│ background workers (P/4) │
│ │
│ STW mark termination ──►─ │
│ │
├──────────────────────────────────┤
│ concurrent sweep │
└──────────────────────────────────┘

Если allocation rate растёт быстрее, чем pacer ожидал, scan не успевает к moment goal. Тогда:

  • Mark assist агрессивнее.
  • Может произойти OOM, если goal превышен и GOMEMLIMIT срабатывает.

При go foo():

  1. Аллоцируется G из gfree pool.
  2. Если у G нет стека — выделяется новый блок 2 KB.
  3. g.stack.lo = начало (низкий адрес), g.stack.hi = конец (высокий).
  4. g.sched.sp устанавливается в g.stack.hi - 8 (стек растёт вниз на x86).

До Go 1.4 — segmented stacks (split stacks). Когда стек кончался, выделялся новый сегмент и связывался с предыдущим. Это вызывало “hot-cold” проблему: при частых вызовах через граница сегмента — постоянные переключения.

С 1.4 — copying stacks. Стек — это непрерывный блок. Когда нужно расти:

  1. Аллоцируется новый блок вдвое большего размера.
  2. Старый стек копируется в новый.
  3. Все указатели внутри стека обновляются (stack pointer adjustment).
  4. Старый блок освобождается.
Старый стек (2 KB): Новый стек (4 KB):
┌────────────┐ ┌────────────┐
│ frame foo │ │ │
│ frame bar │ ── copy ────────► │ frame foo │
│ frame baz │ │ frame bar │
└────────────┘ │ frame baz │
│ │
└────────────┘

⚠️ Указатели на стек!!! Если ты сделал p := &localVar и передал p в горутину или сохранил где-то — это uintptr, и при copy stack адрес изменится. Решение compiler escape’ит локальные переменные, на которые берутся адреса с long lifetime, в heap.

Каждый GC может проверить: используется ли стек < 1/4 текущего размера. Если да — copy в меньший блок.

Это значит, что long-running горутина с волатильной нагрузкой не пухнет навсегда. После пика использования стек ужимается.

При copy stack нужно обновить все pointers, которые указывали в старый стек:

  • Stack frames содержат refs на локальные переменные другого frame’а.
  • В G.sched.sp хранится текущий SP.

Алгоритм:

  1. Зная offset (new - old), пройти по всем stack maps.
  2. Для каждого pointer’а в стек — сдвинуть на offset.

Stack maps генерируются компилятором: для каждой PC он знает, какие байты в frame — указатели. Файл runtime/stackmap.go.

Каждая Go-функция начинается с пролога (если кадр > 96 байт):

TEXT main.foo(SB), $128-0
MOVQ TLS, CX // получаем G
MOVQ 0(CX), CX
CMPQ SP, 16(CX) // SP <= stackguard0?
JLS morestack
SUBQ $128, SP
...

stackguard0 обычно = stack.lo + 1024 байт (small guard). При SP <= guard → morestack → решает: преемпт или рост стека.

Если стек реально вырос до своего предела (maxstacksize, по умолчанию ~1 GB на amd64) — fatal error: stack overflow.

При mark phase нужно прескан стек каждой горутины (найти все указатели на heap):

func scanstack(gp *g, gcw *gcWork) {
// 1. Заморозить G (preempt + wait).
// 2. Для каждого frame'а:
// - найти stack map для PC.
// - найти live pointers в frame.
// - пометить heap-объекты по этим указателям.
// 3. Также scan defer/panic списков, контекста таймеров.
...
}

⚠️ Stack scanning делается concurrent в Go 1.7+ (с hybrid write barrier). До этого был STW stack scan, и это давало 10-100 ms паузы на больших стеках!

Максимальный размер одной goroutine stack. Дефолт:

  • 32-bit: 250 MB.
  • 64-bit: 1 GB.

Изменяется через runtime/debug.SetMaxStack(N). Полезно для embedded или защиты от cycle bugs.

При попытке вырасти выше — runtime: goroutine stack exceeds X-byte limit; fatal error: stack overflow.

buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true) // true = все горутины
os.Stderr.Write(buf[:n])

Это вызывает STW! Все горутины замораживаются, печатается каждый стек. На 10K горутин это десятки ms.

Для debug в production — используйте pprof.Lookup("goroutine").WriteTo(w, 1). Этот endpoint умнее, не делает full STW.


Сервис с 50 GB live heap, GOGC=100 → goal = 100 GB. Если pod limit = 60 GB → OOM до достижения goal.

Решение: GOMEMLIMIT=55GiB + GOGC=off (или GOGC=50 для balance).

В pprof видно runtime.gcAssistAlloc = 15% CPU. Это означает: allocation rate >> mark scan rate. Лечение:

  • Уменьшить allocation (sync.Pool, batching).
  • Увеличить GOGC (реже стартуем GC, но больше memory).
func handler(w, r) {
if expensive {
runtime.GC() // ОЙ
}
}

runtime.GC() блокирующий, 100ms+ на большом heap. Не используй в latency-sensitive code.

Блокирующий full GC + scavenge. На 10 GB heap может занять 1-2 секунды. Используй только в admin-операциях или после batch jobs.

Если G вырастила стек до 16 MB (рекурсия), а потом отпустила — следующий GC должен ужимать. Но если стек “вокруг” используется 25%, не ужмётся. Решение: вынеси рекурсию в новую горутину, чтобы старая закончилась.

3.6. ⚠️ Указатели на стек уходят в heap через escape

Заголовок раздела «3.6. ⚠️ Указатели на стек уходят в heap через escape»
func bad() *int {
x := 42
return &x // x escape'ит в heap
}

Компилятор это знает (escape analysis), аллоцирует в heap. Без этой защиты bad() возвращал бы dangling pointer.

func fib(n int) int {
if n < 2 { return n }
return fib(n-1) + fib(n-2)
}
fib(1000000) // RIP

Достигнет maxstacksize, fatal error. Решение: iterative или явный стек.

STW на дамп. На 10K горутин может быть >50 ms. Используй pprof endpoints.

В k8s pod-е без явного GOMEMLIMIT Go не знает memory limit. Set it = 90% pod limit.

Если у тебя G со стеком 100 MB (рекурсия) — stack scan этой G может занять 10-100 ms. В Go 1.7+ это concurrent, но всё равно дорого. Симптом: высокий gc_scan_stack_time в metrics.

Если goroutine panic’ит и нет defer/recover в этой же горутине — весь процесс умирает. Recover в main не поможет.

runtime.SetFinalizer(obj, func(o *Obj) { o.cleanup() })

Finalizer вызывается, когда GC обнаруживает obj недоступным. Но:

  • Задержка может быть произвольной (до следующего GC, потом ещё цикл).
  • Finalizer запускается в отдельной горутине, не там, где objected’ат.
  • Если finalizer удерживает ссылку — объект “воскрешается”, finalizer больше не вызовется при повторной garbage.

Не используй finalizer для critical cleanup (закрытие conn, файлов). Используй defer.

Если внезапно резко выросла live heap (например, загрузили 10 GB в кэш), pacer не предусмотрел этого. Следующий GC может стартовать поздно, и memory зашкалит.

Решение: GOMEMLIMIT.

На 100K goroutines stack scan суммарно может занять 50+ ms (даже concurrent). Это видно в /gc/scan/stack:bytes.

HeapInuse — что Go считает занятым в heap. RSS (/proc/self/status) включает heap + stacks + globals + arenas (даже unused) + mmap. Может быть в 2-3x больше HeapInuse.


Кейс (Tinkoff fintech): сервис с 8 GB pod limit регулярно OOM’ил. GOGC=100, live heap 5-6 GB, во время пиков GC starting “поздно” → heap дорастал до 9 GB → OOM kill.

Решение: GOMEMLIMIT=7GiB, GOGC=off. После переключения OOM исчезли, P99 latency осталась стабильной (GC всё равно работал реже на низкой нагрузке).

Кейс (Авито recommend service): после Black Friday (+3x нагрузка) сервис ел 80% CPU. Профайл: 30% — gcAssistAlloc.

Причина: горутины аллоцировали миллионы мелких структур. GC не успевал, mark assist жрал CPU.

Решение: внедрили sync.Pool для горячих типов + zap для логов. Allocation rate упал в 4x. gcAssist → 3%.

Кейс: legacy сервис на Go 1.14 (до GOMEMLIMIT). Использовали ballast = 4 GB. После апгрейда на Go 1.22 заменили на GOMEMLIMIT=5GiB. RSS снизился, GC pauses не изменились.

Кейс (Yandex Cloud): сервис с 1M горутин (каждая ждёт на chan от broker’а). GC mark scan stack занимал ~30 ms. На сервисе с p99 latency 50 ms — это критично.

Решение: redesign — вместо 1 горутины на connection вынесли в reactor pattern с epoll и фиксированным числом workers. Голос горутин снизился до 100, mark scan ~1 ms.

Кейс (ETL pipeline): после обработки 10 GB файла RSS оставался 12 GB. Pod в k8s с limit 16 GB не освобождал ресурсы соседям.

Решение: после batch завершения вызывали debug.FreeOSMemory(). RSS падал до 1 GB через ~2 секунды.

Кейс: тест на k8s pod. Без GOMEMLIMIT — частые OOM. С GOMEMLIMIT=90% — стабильно. С expirimental GOMEMLIMIT=auto (Go 1.25 prototype) — тоже стабильно, но pacer выбирал чуть консервативные значения. Будущее, видимо, за auto.

Кейс (наблюдаемость в сервисе): добавили дашборд с /gc/scan/stack:bytes и /gc/cycles/total:gc-cycles. Заметили, что stack scan занимает ~20% mark time. Это нормально для 50K горутин.


Q1. Опиши tri-color invariant.

Объекты бывают white/grey/black. White — кандидат на удаление. Grey — на worklist. Black — обработан. Инвариант: black не должен ссылаться на white (иначе после mark white-объект будет удалён, а black держит на него ссылку).

Q2. Что такое hybrid write barrier?

Write barrier — функция, которая выполняется при каждом изменении указателя. Hybrid (Dijkstra + Yuasa): берёт snapshot стека при mark start (без rescan), плюс при изменении *p = q, если *p был white, помечает его grey (защита от потери ссылки).

Это позволило в Go 1.8 убрать STW stack rescan.

Q3. Какие фазы GC бывают?

  1. Mark start (STW, ~10-50 µs).
  2. Concurrent mark (с user-кодом).
  3. Mark termination (STW, ~10-100 µs).
  4. Concurrent sweep (lazy).

Q4. Что такое mark assist?

Если user-горутина аллоцирует быстрее, чем GC mark progresses, она “должна” GC. В mallocgc если assistBytes < 0, горутина сама делает scan. Natural backpressure.

Q5. Что такое pacer и какова его цель?

Компонент, решающий, когда стартовать следующий GC. Цели: heap_target = live × (1 + GOGC/100), CPU ≤ 25%. Адаптивно считает trigger_ratio на основе прошлых allocation/mark rates.

Q6. Что такое GOGC и как он влияет?

GOGC — процент роста heap до следующего GC. По умолчанию 100. heap_target = live × (1 + GOGC/100). GOGC=200 → реже GC, больше memory. GOGC=off — GC отключен (только GOMEMLIMIT триггерит).

Q7. Что такое GOMEMLIMIT и когда появился?

Go 1.19+. Soft memory limit на весь Go-процесс (heap + meta + stacks). Pacer стремится не превышать. Может временно зайти выше (soft), но GC будет агрессивнее.

Q8. Как настроить GOMEMLIMIT в k8s?

90% от pod memory limit. Например, limit=1Gi → GOMEMLIMIT=900MiB. 10% запас на native stacks, cgo, mmap.

Q9. Чем GOMEMLIMIT отличается от GOGC?

GOGC — proportional (% от live heap). GOMEMLIMIT — абсолютный (в байтах). Можно комбинировать: GOGC=off + GOMEMLIMIT=X — GC только при подходе к лимиту.

Q10. Что такое memory ballast и когда оно нужно?

До GOMEMLIMIT (1.16-1.18): создавали огромный пустой slice, чтобы поднять live_heap и реже GC. Сейчас deprecated — используй GOMEMLIMIT.

Q11. Что такое STW и сколько обычно?

Stop-the-world — пауза всех горутин. В Go 1.22+ две STW в GC: mark start (~10-50 µs) и mark termination (~10-100 µs). Sweep — concurrent.

Q12. Может ли STW pause превысить 1 мс?

Очень редко, обычно нет. Возможные причины: gigantic heap с stack scan (legacy ≤1.7), много finalizers, очень крупные stacks горутин (~MB).

Q13. Что такое runtime/metrics и чем лучше MemStats?

Современный API (Go 1.16+). Иерархические имена (/gc/heap/...), расширяемый, не STWReadMemStats делает STW!).

Q14. Что покажет GODEBUG=gctrace=1?

gc 12 @1.234s 3%: 0.018+5.2+0.025 ms clock, ...

Heap before/after, scan time, STW pauses, CPU%, GOMAXPROCS. Хороший показатель — низкий % CPU GC (≤5%).

Q15. Что такое debug.FreeOSMemory?

Forced GC + полный scavenge (возврат памяти в ОС через MADV_DONTNEED). Блокирующий, дорогой. Использовать после batch.

Q16. Что такое scavenger?

Фоновая работа runtime, которая возвращает свободные страницы в ОС (madvise). С 1.16+ управляется по GOMEMLIMIT и системному memory pressure.

Q17. Откуда у горутины 2 KB стек?

При go foo() аллоцируется блок 2 KB из stackcache. Это минимум — растёт по необходимости (copying stack).

Q18. Как стек горутины растёт?

Copying stack (Go 1.4+): аллоцируется новый блок вдвое больше, старый копируется, указатели обновляются (stack pointer adjustment), старый освобождается.

Q19. Зачем нужна escape analysis?

Чтобы знать, может ли переменная остаться на стеке или должна быть в heap. Если на неё берётся адрес, и адрес “уходит” за пределы функции — escape в heap. Иначе — stack (бесплатно).

Q20. Что произойдёт при stack overflow?

runtime: goroutine stack exceeds X-byte limit; fatal error: stack overflow. Максимум обычно 1 GB на 64-bit. Изменяется через debug.SetMaxStack.

Q21. Может ли стек ужиматься?

Да. На каждом GC проверка: если used < 1/4 size — copy в меньший блок.

Q22. Как GC знает, какие байты на стеке — указатели?

Stack maps: компилятор генерирует для каждой PC bitmap “какие байты frame — pointer”. При scan GC берёт map по PC, итерирует pointer-байты.

Q23. Как вычисляется trigger ratio?

Pacer 2.0 — PID-like. Учитывает scan rate, allocation rate, прошлые ошибки. Стартует mark, когда heap = live × (1 + 0.7 × GOGC/100) (примерно).

Q24. Что произойдёт при runtime.GC() в горячем пути?

Блокирующий full GC. На heap=1GB ~100 ms. P99 latency пропадёт. Не делай в hot path.

Q25. Что такое finalizer и почему опасно?

Функция, вызываемая, когда GC находит объект unreachable. Задержка непредсказуемая, выполняется в отдельной горутине. Не используй для cleanup критичных ресурсов — используй defer/explicit Close.

Q26. Опишите путь scan стека.

При mark phase для каждой G: ставим G на паузу (или ждём safe-point), идём по frame’ам, для каждого PC берём stack map, помечаем pointer-байты, push в gcWork.

Q27. Что такое gcBgMarkWorker?

Background mark workers — отдельные горутины, по одной на P/4 ядра. Они выполняют большую часть mark в фоне, пока user-G работают.

Q28. Чем CONCURRENT sweep отличается от MARK?

Mark должен видеть полный live set, поэтому требует write barrier и инвариант. Sweep просто освобождает мёртвые слоты — концепция “увидел live слот, помечаю как занятый; всё остальное — свободно”. Это локально по span’у, не требует синхронизации с user.

Q29. Что такое lazy sweep?

Sweep делается opportunistic: когда mcache.refill хочет взять span из mcentral, span сначала sweep’ается (если ещё не). Это распределяет sweep по времени и не блокирует ничего.

Q30. Опиши флоу аллокации с точки зрения GC.

  1. mallocgc вызывается.
  2. Проверка gcAssistBytes — есть долг? Помочь GC.
  3. Аллокация из mcache (small) или mheap (large).
  4. Если allocated > trigger → стартуем GC (gcTriggerHeap).
  5. Объект пишется в slot span’а. allocBits[i] = 1.
  6. Если ВО ВРЕМЯ mark phase: новый объект автоматически black (не нужно scan).

Q31. Почему новый объект во время mark — black?

Если объект только что аллоцирован — он недоступен из других объектов через указатели (мы как раз получаем ref на него). Метим black по правилам: если мы (mutator) дальше присвоим его в black-объект, write barrier обработает; если нет — он “вне reach” и будет освобождён в следующем цикле.

Q32. Объясни pacer overshoot.

Если allocation rate вырос быстрее, чем pacer предсказал — mark не успеет к goal, и heap превысит goal. Pacer 2.0 минимизирует это через PID-feedback.

Q33. Что такое heap_target vs heap_goal?

Это одно и то же — целевой размер heap, который GC старается не превысить. = live × (1 + GOGC/100) или GOMEMLIMIT-based.

Q34. Какие метрики runtime/metrics стоит мониторить?

  • /gc/heap/live:bytes — live heap.
  • /gc/heap/goal:bytes — target.
  • /gc/cycles/total:gc-cycles — total GC count.
  • /gc/pauses:seconds — pause histogram.
  • /sched/goroutines:goroutines — goroutine count.
  • /gc/scan/stack:bytes — stack scan size.
  • /cpu/classes/gc/total:cpu-seconds — CPU на GC.

Q35. Объясни, почему defer runtime.GC() после batch JOB — антипаттерн в production.

runtime.GC() блокирующий, и defer его выполнит в конце функции. Если функция возвращает HTTP-ответ — клиент дождётся 100ms+. Лучше: после batch вернуть response, и в горутине вызвать runtime.GC() в фоне (хотя обычно тоже не нужно — pacer сам справится).


Напиши программу, которая аллоцирует 100 MB linked list. Прогон с GOGC=100, GOGC=200, GOGC=off. Сравни heap_target, частоту GC, CPU. Используй GODEBUG=gctrace=1.

Запусти сервис в Docker с --memory=512m. Без GOMEMLIMIT — OOM при загрузке 600 MB. С GOMEMLIMIT=450MiB — GC агрессивный, OOM нет.

Запусти CPU-bound program с большим allocation rate. Сделай профайл, найди runtime.gcAssistAlloc. Измени GOGC, посмотри как меняется доля assist.

Напиши рекурсивную функцию func rec(n int), которая печатает runtime.NumGoroutine и адрес локальной переменной. Покажи, что адрес меняется (стек копируется).

Подключи runtime/metrics к Prometheus. Экспортируй heap, goroutines, GC cycles, pauses. Построй Grafana dashboard.

Используй runtime.SetFinalizer на структуру. Запусти, освободи объект, вызови runtime.GC(). Покажи, когда вызывается finalizer и почему он не подходит для критических ресурсов.

Используй runtime/metrics /gc/pauses:seconds. Создай сервис с разным heap (10 MB, 1 GB, 10 GB). Замерь среднюю и max STW. Объясни, почему STW почти не зависит от heap size.

Создай 1K, 10K, 100K, 1M горутин (все блокированные на chan). Замерь GC cycle через gctrace. Покажи, что mark time растёт линейно с числом горутин.


  1. src/runtime/mgc.go — основной GC код.
  2. src/runtime/mgcpacer.go — Pacer 2.0 implementation.
  3. src/runtime/mgcmark.go — mark phase.
  4. src/runtime/mgcsweep.go — sweep phase.
  5. src/runtime/mwbbuf.go — write barrier buffer.
  6. src/runtime/stack.go — stack growth, copy, shrink.
  7. src/runtime/stackmap.go — stack maps.
  8. Austin Clements, “Go 1.5 concurrent garbage collector pacing” — design doc.
  9. Austin Clements, “Go Pacer Redesign” (Go 1.18 design doc).
  10. Rick Hudson, “Getting to Go: The Journey of Go’s Garbage Collector” — GopherCon 2018 talk.
  11. Madhav Jivrajani, “Go GC paths: A walk through” — Habr / Medium 2024.
  12. “Memory limit (GOMEMLIMIT)” — Go 1.19 release notes.
  13. “Tuning Go GC in production” — Avito Engineering Blog 2025.