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).
Содержание
Заголовок раздела «Содержание»- Базовая концепция GC и стека (для разогрева)
- Глубокое погружение: tri-color, write barrier, pacer, stack growth
- Подводные камни GC и стека
- Производительность и реальные кейсы
- Вопросы на собесе Middle 2
- Practice
- Источники
1. Базовая концепция (разогрев)
Заголовок раздела «1. Базовая концепция (разогрев)»GC: Go использует concurrent mark-sweep. Этапы:
- Mark start — STW, инициализация (~10-50 µs).
- Concurrent mark — параллельно с user, обходим объекты, помечаем живые.
- Mark termination — STW, финализация mark (~10-100 µs).
- 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 term2. Глубокое погружение
Заголовок раздела «2. Глубокое погружение»2.1. Tri-color invariant
Заголовок раздела «2.1. Tri-color invariant»Три цвета объектов:
- White — кандидат на удаление (GC ещё не видел).
- Grey — GC видел, но ещё не обошёл его поля.
- Black — GC видел и обошёл все поля.
GC начинает с corners (stacks + globals) = grey. Обход:
- Достаём grey-объект из worklist.
- Сканируем его поля. Все указатели → если они white, делаем их grey.
- Сам объект становится black.
В конце mark phase: все живые = black, все мёртвые = white. Sweep удаляет white.
Tri-color invariant: black НЕ должен ссылаться на white напрямую. Иначе после mark white-объект может быть удалён, а black держит ссылку на garbage → use-after-free.
2.2. Hybrid Write Barrier (Go 1.8+)
Заголовок раздела «2.2. Hybrid Write Barrier (Go 1.8+)»Когда 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:nosplitfunc 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 пауз до этого.
2.3. ASCII-схема mark phase
Заголовок раздела «2.3. ASCII-схема mark phase»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 → blackgcBgMarkWorker — это специальные горутины, по одной на P / 4 ядра. Они работают в фоне.
mark assist: если user-горутина аллоцирует быстрее, чем GC mark прогрессирует, мы заставляем её помочь. В mallocgc есть проверка:
assistG := getg().assistBytes // долг текущей Gif assistG > 0 { gcAssistAlloc(gp)}Если assist долг > 0 — горутина сама делает mark, пока не отыграет долг. Это natural backpressure: чем больше ты аллоцируешь, тем больше ты GC-помощник.
2.4. Worklist: lock-free очередь
Заголовок раздела «2.4. Worklist: lock-free очередь»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.
2.5. STW pauses: что происходит
Заголовок раздела «2.5. STW pauses: что происходит»Mark start STW (~10-50 µs):
- Все горутины останавливаются (через preempt + sync).
- Включается write barrier (
writeBarrier.enabled = true). - Сканируются глобалы (data + bss segments).
- Стеки помечаются как “to scan” (но не сканируются сейчас — будут в concurrent).
- World resumed.
Mark termination STW (~10-100 µs):
- World stops.
- Финализация worklist (drain последних серых).
- Mark bits зафиксированы.
- Write barrier выключается.
- Подготовка к sweep.
- World resumed.
Что важно: STW pauses в Go 1.22+ обычно меньше 100 µs, даже на heap в 100+ GB. Это достижение многолетнего тюнинга concurrent algorithm.
2.6. Sweep phase
Заголовок раздела «2.6. Sweep phase»После mark все mark bits знают, что живо. Sweep:
- Concurrent — фоновая горутина
bgsweepобходит spans, освобождает мёртвые слоты. - Lazy / opportunistic — при первом обращении к span’у со стороны mcache.refill, span sweep’ается прежде чем выдан.
Sweep ≠ освобождение в ОС. Sweep только переводит “мёртвые слоты” в “свободные”. Возврат в ОС — это scavenger (см. файл 2).
2.7. Pacer 2.0 (Go 1.18+)
Заголовок раздела «2.7. Pacer 2.0 (Go 1.18+)»Pacer решает: когда стартовать следующий GC?
Цели:
- Heap goal: после GC heap ≤ live_heap × (1 + GOGC/100). Например, GOGC=100, live_heap=100 MB → goal = 200 MB.
- 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).
2.8. Mark assist детали
Заголовок раздела «2.8. Mark assist детали»Каждая горутина имеет 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.
2.9. GC trigger heuristic
Заголовок раздела «2.9. GC trigger heuristic»gcTrigger решает, нужно ли стартовать GC. Три типа триггеров:
- gcTriggerHeap — heap_live достиг heap_trigger (главный путь).
- gcTriggerTime — прошло > 2 мин с последнего GC (force GC через sysmon).
- gcTriggerCycle — явный
runtime.GC().
Trigger ratio адаптивная — каждый цикл pacer корректирует, чтобы попадать в heap_target.
2.10. GOMEMLIMIT (Go 1.19+)
Заголовок раздела «2.10. GOMEMLIMIT (Go 1.19+)»GOMEMLIMIT — soft memory limit для всего Go процесса (heap + metadata + stacks). По умолчанию off (no limit).
GOMEMLIMIT=4GiB go run main.go# или в коде:debug.SetMemoryLimit(4 * 1024 * 1024 * 1024)Что делает:
- Если current memory use приближается к limit — pacer аггрессивно стартует GC.
- Если близко к limit —
runtime.GC()может быть форсирован. - После GC scavenger пытается вернуть память в ОС.
⚠️ Это soft limit. Go не блокирует аллокации по лимиту — может временно превысить. Но pacer старается удержаться ниже.
GOGC=off + GOMEMLIMIT: классический паттерн для serverless / k8s pods. Отключаем proportional GC, оставляем только лимит. GC будет триггериться только при подходе к лимиту.
GOGC=off GOMEMLIMIT=2GiB ./service2.11. GOMEMLIMIT в k8s
Заголовок раздела «2.11. GOMEMLIMIT в k8s»Best practice: GOMEMLIMIT = 90% от container memory limit.
resources: limits: memory: 1Gienv:- 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 — пока экспериментально).
2.12. Memory ballast pattern (исторический)
Заголовок раздела «2.12. Memory ballast pattern (исторический)»До GOMEMLIMIT (Go 1.16-1.18) — паттерн “ballast”:
ballast := make([]byte, 10<<30) // 10 GBruntime.KeepAlive(ballast)Идея: создаём огромный массив (виртуально, физически не использован). live_heap “официально” = 10 GB. GOGC=100 → heap_target = 20 GB. То есть GC реже срабатывает.
В современных Go (1.19+) не используйте memory ballast. Замените на GOMEMLIMIT. Ballast тратит виртуальную память, может вызывать OOM при page fault, и не учитывается scavenger’ом.
2.13. runtime/metrics (Go 1.16+)
Заголовок раздела «2.13. runtime/metrics (Go 1.16+)»Современный 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.
2.14. Forced GC
Заголовок раздела «2.14. Forced GC»runtime.GC() // блокирующий полный GCПолезно:
- В тестах (forcing stable state).
- Перед benchmarking (clean slate).
- В долгоживущих сервисах после массивных деаллокаций (например, после batch processing).
⚠️ Не вызывать в hot path. Полный GC = ~100ms на 1 GB heap.
2.15. debug.FreeOSMemory
Заголовок раздела «2.15. debug.FreeOSMemory»debug.FreeOSMemory()Что делает:
- Forced GC.
- Полный scavenge (возврат всех свободных страниц в ОС через MADV_DONTNEED).
- Блокирующий вызов.
Полезно: после массивных временных аллокаций (например, парсинг большого файла) и нужно вернуть память.
2.16. ASCII-схема pacer-цикла
Заголовок раздела «2.16. ASCII-схема pacer-цикла» 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 срабатывает.
2.17. Goroutine Stack: начало
Заголовок раздела «2.17. Goroutine Stack: начало»При go foo():
- Аллоцируется G из gfree pool.
- Если у G нет стека — выделяется новый блок 2 KB.
g.stack.lo= начало (низкий адрес),g.stack.hi= конец (высокий).g.sched.spустанавливается вg.stack.hi - 8(стек растёт вниз на x86).
2.18. Stack growing: copying stack (Go 1.4+)
Заголовок раздела «2.18. Stack growing: copying stack (Go 1.4+)»До Go 1.4 — segmented stacks (split stacks). Когда стек кончался, выделялся новый сегмент и связывался с предыдущим. Это вызывало “hot-cold” проблему: при частых вызовах через граница сегмента — постоянные переключения.
С 1.4 — copying stacks. Стек — это непрерывный блок. Когда нужно расти:
- Аллоцируется новый блок вдвое большего размера.
- Старый стек копируется в новый.
- Все указатели внутри стека обновляются (stack pointer adjustment).
- Старый блок освобождается.
Старый стек (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.
2.19. Stack shrinking
Заголовок раздела «2.19. Stack shrinking»Каждый GC может проверить: используется ли стек < 1/4 текущего размера. Если да — copy в меньший блок.
Это значит, что long-running горутина с волатильной нагрузкой не пухнет навсегда. После пика использования стек ужимается.
2.20. Stack pointer adjustment
Заголовок раздела «2.20. Stack pointer adjustment»При copy stack нужно обновить все pointers, которые указывали в старый стек:
- Stack frames содержат refs на локальные переменные другого frame’а.
- В G.sched.sp хранится текущий SP.
Алгоритм:
- Зная offset (new - old), пройти по всем stack maps.
- Для каждого pointer’а в стек — сдвинуть на offset.
Stack maps генерируются компилятором: для каждой PC он знает, какие байты в frame — указатели. Файл runtime/stackmap.go.
2.21. Stack guard для overflow detection
Заголовок раздела «2.21. Stack guard для overflow detection»Каждая 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.
2.22. Stack scanning при GC
Заголовок раздела «2.22. Stack scanning при GC»При 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 паузы на больших стеках!
2.23. maxstacksize
Заголовок раздела «2.23. maxstacksize»Максимальный размер одной 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.
2.24. runtime.Stack для дампа
Заголовок раздела «2.24. runtime.Stack для дампа»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.
3. Подводные камни GC и стека
Заголовок раздела «3. Подводные камни GC и стека»3.1. ⚠️ GOGC=100 не работает на больших heap
Заголовок раздела «3.1. ⚠️ GOGC=100 не работает на больших heap»Сервис с 50 GB live heap, GOGC=100 → goal = 100 GB. Если pod limit = 60 GB → OOM до достижения goal.
Решение: GOMEMLIMIT=55GiB + GOGC=off (или GOGC=50 для balance).
3.2. ⚠️ Mark assist жрёт CPU
Заголовок раздела «3.2. ⚠️ Mark assist жрёт CPU»В pprof видно runtime.gcAssistAlloc = 15% CPU. Это означает: allocation rate >> mark scan rate. Лечение:
- Уменьшить allocation (sync.Pool, batching).
- Увеличить GOGC (реже стартуем GC, но больше memory).
3.3. ⚠️ runtime.GC() в http handler
Заголовок раздела «3.3. ⚠️ runtime.GC() в http handler»func handler(w, r) { if expensive { runtime.GC() // ОЙ }}runtime.GC() блокирующий, 100ms+ на большом heap. Не используй в latency-sensitive code.
3.4. ⚠️ debug.FreeOSMemory латентность
Заголовок раздела «3.4. ⚠️ debug.FreeOSMemory латентность»Блокирующий full GC + scavenge. На 10 GB heap может занять 1-2 секунды. Используй только в admin-операциях или после batch jobs.
3.5. ⚠️ Long-running горутина с большим стеком
Заголовок раздела «3.5. ⚠️ Long-running горутина с большим стеком»Если 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.
3.7. ⚠️ Stack overflow через рекурсию
Заголовок раздела «3.7. ⚠️ Stack overflow через рекурсию»func fib(n int) int { if n < 2 { return n } return fib(n-1) + fib(n-2)}fib(1000000) // RIPДостигнет maxstacksize, fatal error. Решение: iterative или явный стек.
3.8. ⚠️ runtime.Stack(buf, true) в production
Заголовок раздела «3.8. ⚠️ runtime.Stack(buf, true) в production»STW на дамп. На 10K горутин может быть >50 ms. Используй pprof endpoints.
3.9. ⚠️ GOMEMLIMIT без cgroup awareness
Заголовок раздела «3.9. ⚠️ GOMEMLIMIT без cgroup awareness»В k8s pod-е без явного GOMEMLIMIT Go не знает memory limit. Set it = 90% pod limit.
3.10. ⚠️ GC pauses при огромных stacks
Заголовок раздела «3.10. ⚠️ GC pauses при огромных stacks»Если у тебя G со стеком 100 MB (рекурсия) — stack scan этой G может занять 10-100 ms. В Go 1.7+ это concurrent, но всё равно дорого. Симптом: высокий gc_scan_stack_time в metrics.
3.11. ⚠️ panic в горутине → recovery не помогает
Заголовок раздела «3.11. ⚠️ panic в горутине → recovery не помогает»Если goroutine panic’ит и нет defer/recover в этой же горутине — весь процесс умирает. Recover в main не поможет.
3.12. ⚠️ Finalizer и GC
Заголовок раздела «3.12. ⚠️ Finalizer и GC»runtime.SetFinalizer(obj, func(o *Obj) { o.cleanup() })Finalizer вызывается, когда GC обнаруживает obj недоступным. Но:
- Задержка может быть произвольной (до следующего GC, потом ещё цикл).
- Finalizer запускается в отдельной горутине, не там, где objected’ат.
- Если finalizer удерживает ссылку — объект “воскрешается”, finalizer больше не вызовется при повторной garbage.
Не используй finalizer для critical cleanup (закрытие conn, файлов). Используй defer.
3.13. ⚠️ GC pacer и spikes
Заголовок раздела «3.13. ⚠️ GC pacer и spikes»Если внезапно резко выросла live heap (например, загрузили 10 GB в кэш), pacer не предусмотрел этого. Следующий GC может стартовать поздно, и memory зашкалит.
Решение: GOMEMLIMIT.
3.14. ⚠️ Stack scan time vs goroutine count
Заголовок раздела «3.14. ⚠️ Stack scan time vs goroutine count»На 100K goroutines stack scan суммарно может занять 50+ ms (даже concurrent). Это видно в /gc/scan/stack:bytes.
3.15. ⚠️ MemStats.HeapInuse и Сon RSS
Заголовок раздела «3.15. ⚠️ MemStats.HeapInuse и Сon RSS»HeapInuse — что Go считает занятым в heap. RSS (/proc/self/status) включает heap + stacks + globals + arenas (даже unused) + mmap. Может быть в 2-3x больше HeapInuse.
4. Производительность и реальные кейсы
Заголовок раздела «4. Производительность и реальные кейсы»4.1. GOMEMLIMIT спас от OOM
Заголовок раздела «4.1. GOMEMLIMIT спас от OOM»Кейс (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 всё равно работал реже на низкой нагрузке).
4.2. Mark assist 30% CPU
Заголовок раздела «4.2. Mark assist 30% CPU»Кейс (Авито recommend service): после Black Friday (+3x нагрузка) сервис ел 80% CPU. Профайл: 30% — gcAssistAlloc.
Причина: горутины аллоцировали миллионы мелких структур. GC не успевал, mark assist жрал CPU.
Решение: внедрили sync.Pool для горячих типов + zap для логов. Allocation rate упал в 4x. gcAssist → 3%.
4.3. Memory ballast в production legacy
Заголовок раздела «4.3. Memory ballast в production legacy»Кейс: legacy сервис на Go 1.14 (до GOMEMLIMIT). Использовали ballast = 4 GB. После апгрейда на Go 1.22 заменили на GOMEMLIMIT=5GiB. RSS снизился, GC pauses не изменились.
4.4. STW pauses при stack scan на 1M горутин
Заголовок раздела «4.4. STW pauses при stack scan на 1M горутин»Кейс (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.
4.5. debug.FreeOSMemory после batch
Заголовок раздела «4.5. debug.FreeOSMemory после batch»Кейс (ETL pipeline): после обработки 10 GB файла RSS оставался 12 GB. Pod в k8s с limit 16 GB не освобождал ресурсы соседям.
Решение: после batch завершения вызывали debug.FreeOSMemory(). RSS падал до 1 GB через ~2 секунды.
4.6. GOMEMLIMIT=auto experiment
Заголовок раздела «4.6. GOMEMLIMIT=auto experiment»Кейс: тест на k8s pod. Без GOMEMLIMIT — частые OOM. С GOMEMLIMIT=90% — стабильно. С expirimental GOMEMLIMIT=auto (Go 1.25 prototype) — тоже стабильно, но pacer выбирал чуть консервативные значения. Будущее, видимо, за auto.
4.7. Stack scan и runtime/metrics
Заголовок раздела «4.7. Stack scan и runtime/metrics»Кейс (наблюдаемость в сервисе): добавили дашборд с /gc/scan/stack:bytes и /gc/cycles/total:gc-cycles. Заметили, что stack scan занимает ~20% mark time. Это нормально для 50K горутин.
5. Вопросы на собесе Middle 2
Заголовок раздела «5. Вопросы на собесе Middle 2»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 бывают?
- Mark start (STW, ~10-50 µs).
- Concurrent mark (с user-кодом).
- Mark termination (STW, ~10-100 µs).
- 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/...), расширяемый, не STW (а ReadMemStats делает 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.
- mallocgc вызывается.
- Проверка
gcAssistBytes— есть долг? Помочь GC. - Аллокация из mcache (small) или mheap (large).
- Если allocated > trigger → стартуем GC (gcTriggerHeap).
- Объект пишется в slot span’а. allocBits[i] = 1.
- Если ВО ВРЕМЯ 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 сам справится).
6. Practice
Заголовок раздела «6. Practice»Задача 1. Pacer behavior demo
Заголовок раздела «Задача 1. Pacer behavior demo»Напиши программу, которая аллоцирует 100 MB linked list. Прогон с GOGC=100, GOGC=200, GOGC=off. Сравни heap_target, частоту GC, CPU. Используй GODEBUG=gctrace=1.
Задача 2. GOMEMLIMIT in action
Заголовок раздела «Задача 2. GOMEMLIMIT in action»Запусти сервис в Docker с --memory=512m. Без GOMEMLIMIT — OOM при загрузке 600 MB. С GOMEMLIMIT=450MiB — GC агрессивный, OOM нет.
Задача 3. Mark assist visualization
Заголовок раздела «Задача 3. Mark assist visualization»Запусти CPU-bound program с большим allocation rate. Сделай профайл, найди runtime.gcAssistAlloc. Измени GOGC, посмотри как меняется доля assist.
Задача 4. Stack growth replication
Заголовок раздела «Задача 4. Stack growth replication»Напиши рекурсивную функцию func rec(n int), которая печатает runtime.NumGoroutine и адрес локальной переменной. Покажи, что адрес меняется (стек копируется).
Задача 5. runtime/metrics dashboard
Заголовок раздела «Задача 5. runtime/metrics dashboard»Подключи runtime/metrics к Prometheus. Экспортируй heap, goroutines, GC cycles, pauses. Построй Grafana dashboard.
Задача 6. Finalizer behavior
Заголовок раздела «Задача 6. Finalizer behavior»Используй runtime.SetFinalizer на структуру. Запусти, освободи объект, вызови runtime.GC(). Покажи, когда вызывается finalizer и почему он не подходит для критических ресурсов.
Задача 7. STW measurement
Заголовок раздела «Задача 7. STW measurement»Используй runtime/metrics /gc/pauses:seconds. Создай сервис с разным heap (10 MB, 1 GB, 10 GB). Замерь среднюю и max STW. Объясни, почему STW почти не зависит от heap size.
Задача 8. Stack scan time vs goroutine count
Заголовок раздела «Задача 8. Stack scan time vs goroutine count»Создай 1K, 10K, 100K, 1M горутин (все блокированные на chan). Замерь GC cycle через gctrace. Покажи, что mark time растёт линейно с числом горутин.
7. Источники
Заголовок раздела «7. Источники»src/runtime/mgc.go— основной GC код.src/runtime/mgcpacer.go— Pacer 2.0 implementation.src/runtime/mgcmark.go— mark phase.src/runtime/mgcsweep.go— sweep phase.src/runtime/mwbbuf.go— write barrier buffer.src/runtime/stack.go— stack growth, copy, shrink.src/runtime/stackmap.go— stack maps.- Austin Clements, “Go 1.5 concurrent garbage collector pacing” — design doc.
- Austin Clements, “Go Pacer Redesign” (Go 1.18 design doc).
- Rick Hudson, “Getting to Go: The Journey of Go’s Garbage Collector” — GopherCon 2018 talk.
- Madhav Jivrajani, “Go GC paths: A walk through” — Habr / Medium 2024.
- “Memory limit (GOMEMLIMIT)” — Go 1.19 release notes.
- “Tuning Go GC in production” — Avito Engineering Blog 2025.