Модуль: Runtime и память · Уровень: Senior+
TL;DR#
GOGC (по умолчанию 100) управляет частотой сборки мусора через целевой размер кучи: heap_target = live_heap * (1 + GOGC/100) — то есть при 100 GC запускается, когда куча удваивается относительно живых данных после предыдущей сборки. GOMEMLIMIT (Go 1.19+) задаёт мягкий лимит на общую память рантайма и заставляет GC работать чаще по мере приближения к нему, страхуя от OOM. Канонический паттерн для контейнеров — GOGC=off (или высокий) + GOMEMLIMIT, установленный на ~90-95% от лимита пода, что даёт минимум GC при нормальной нагрузке и автоматическое уплотнение при пиках. Главная опасность — «death spiral», когда лимит занижен и GC начинает крутиться непрерывно, выжигая CPU.
Теория#
Go использует конкурентный mark-and-sweep сборщик с tricolor-маркировкой и write barrier. Сборщик работает почти полностью параллельно с приложением (concurrent), останавливая мир (STW) лишь на короткие фазы (включение/выключение write barrier, обычно суммарно < 1 мс на современных версиях). Ключевой вопрос тюнинга — не как работает GC, а когда он запускается и сколько памяти позволяет накопить между циклами. Это определяет фундаментальный trade-off: CPU против RAM.
GOGC и расчёт целевой кучи#
GOGC — это процент роста кучи, который допускается перед запуском следующей сборки, относительно объёма живых данных после предыдущей.
heap_target = live_heap + live_heap * (GOGC / 100)
= live_heap * (1 + GOGC/100)| GOGC | Множитель target | Поведение |
|---|---|---|
| 50 | 1.5× live | GC чаще, меньше RAM, больше CPU |
| 100 (default) | 2× live | Баланс: куча удваивается между сборками |
| 200 | 3× live | GC реже, больше RAM, меньше CPU |
| 400 | 5× live | Заметная экономия CPU, большой расход RAM |
| off | ∞ | GC по росту кучи отключён полностью |
Пример: если после сборки живых данных (reachable) 100 МБ, то при GOGC=100 следующая сборка стартует при достижении кучей ~200 МБ. При GOGC=200 — при ~300 МБ.
# Установка через переменную окружения
export GOGC=200
# Полное отключение GC по росту кучи (опасно без GOMEMLIMIT!)
export GOGC=offimport "runtime/debug"
// Программно. Возвращает предыдущее значение.
old := debug.SetGCPercent(200)
// Эквивалент GOGC=off
debug.SetGCPercent(-1)Под капотом: pacer#
За решение «когда запускать GC» отвечает GC pacer. Он не ждёт буквального достижения heap_target — он запускает сборку чуть раньше, оценивая скорость аллокаций, чтобы конкурентная маркировка успела завершиться до того, как куча реально дорастёт до цели. Если маркировка не успевает (аллокации идут быстрее, чем mark), pacer включает mark assist: горутины, которые аллоцируют, вынуждаются помогать маркировке пропорционально объёму аллокаций. Mark assist — это и есть тот «налог на CPU», который вы платите при агрессивном GC; в pprof CPU-профиле он виден как runtime.gcAssistAlloc.
GOMEMLIMIT (Go 1.19+)#
GOMEMLIMIT задаёт мягкий (soft) лимит на общий объём памяти, которым управляет рантайм Go. Это не та память, что в heap_target от GOGC — это общая память рантайма.
export GOMEMLIMIT=4GiB # суффиксы: B, KiB, MiB, GiB (степени 2)
export GOMEMLIMIT=4000000 # просто байты
export GOMEMLIMIT=off # снять лимит (значение по умолчанию)import "runtime/debug"
// 4 ГиБ. Возвращает предыдущий лимит.
debug.SetMemoryLimit(4 << 30)
// Снять лимит
debug.SetMemoryLimit(math.MaxInt64)Что входит в лимит: total memory vs heap#
GOMEMLIMIT ограничивает не только кучу живых объектов, а почти всю память, учтённую рантаймом:
| Компонент | Входит в GOMEMLIMIT? |
|---|---|
| Heap (живые + ещё не собранные объекты) | Да |
| Stacks горутин | Да |
Метаданные GC (GCSys), span/heap-метаданные | Да |
Внутренние структуры рантайма (OtherSys, MSpanSys…) | Да |
os/exec, mmap’ы вне рантайма, C-память (cgo) | Нет |
Соответствие полям runtime.MemStats: лимит сравнивается примерно с Sys - HeapReleased (то есть с retained-памятью процесса с точки зрения рантайма). Поэтому при установке GOMEMLIMIT нужно оставлять запас на то, что рантайм не контролирует (cgo, буферы ядра, page cache).
Как GOMEMLIMIT взаимодействует с GOGC#
Ментальная модель: GOGC задаёт обычный темп GC, а GOMEMLIMIT — потолок. GC запускается по тому из двух триггеров, который наступит раньше:
trigger = min(
heap_target_from_GOGC, // live * (1 + GOGC/100)
target_from_GOMEMLIMIT // граница, выводимая из лимита
)- Пока куча мала относительно лимита — работает GOGC (нормальный, нечастый GC).
- По мере приближения к GOMEMLIMIT pacer начинает запускать GC всё чаще, эффективно понижая «виртуальный GOGC», чтобы не пробить лимит.
- При
GOGC=off+GOMEMLIMITGC вообще не запускается по росту кучи — только по приближению к лимиту. Это и есть рекомендованный паттерн: «не трогай GC, пока память не станет дефицитом».
Память
│ ┌──────── GOMEMLIMIT (потолок)
│ │ GC учащается, "виртуальный GOGC" падает
│ ╱╲ ╱╲ ╱│
│ ╱╲ ╱╲ ╱ ╲ ╱ ╲ ╱ │ ← здесь рулит GOMEMLIMIT
│ ╱ ╲╱ ╲ ╲╱
│ ╱ ← здесь рулит GOGC (пилообразный рост до 2× live)
└────────────────────────────────── времяDeath spiral: почему GOMEMLIMIT — soft limit#
GOMEMLIMIT намеренно сделан мягким: рантайм не упадёт и не откажет в аллокации при достижении лимита (в отличие от жёсткого лимита, который вызвал бы OOM/ошибку). Вместо этого он будет всё агрессивнее запускать GC. Опасность: если живых данных (working set) уже почти столько же, сколько лимит, GC не сможет освободить достаточно памяти — и попадёт в death spiral (GC thrashing): сборщик запускается практически непрерывно, приложение бóльшую часть CPU тратит на mark assist, throughput падает почти до нуля, но память так и держится у лимита.
Чтобы этого избежать, рантайм вводит минимальный лимит на долю CPU для GC: GC не позволяется потреблять более ~50% CPU (усреднённо в скользящем окне). Если для удержания лимита нужно больше 50% CPU — рантайм сознательно превышает GOMEMLIMIT, отдавая приоритет прогрессу приложения. Итог: при заниженном лимите вы получите либо death spiral до этого потолка, либо превышение лимита и затем настоящий OOM от cgroup/ядра. Поэтому GOMEMLIMIT не заменяет правильный sizing: working set должен с запасом помещаться под лимит.
Trade-off: CPU против памяти#
Это единственный фундаментальный компромисс тюнинга GC:
| Стратегия | CPU на GC | Использование RAM | Когда |
|---|---|---|---|
| Низкий GOGC (50) | Высокий | Низкое | RAM дефицитна, CPU в избытке |
| Дефолт (GOGC=100) | Средний | Среднее | Общий случай |
| Высокий GOGC (200-400) | Низкий | Высокое | CPU дефицитен, RAM в избытке |
| GOGC=off + GOMEMLIMIT | Минимальный при норме, растёт у лимита | Близко к лимиту | Контейнеры с фиксированным лимитом памяти |
Реже GC → больше «мусора» накапливается между сборками → больше пиковая RAM, но меньше суммарно потрачено CPU на маркировку. Чаще GC → меньше RAM, но больше CPU. GOMEMLIMIT ломает дилемму «либо-либо»: позволяет держать GOGC высоким для экономии CPU при нормальной нагрузке, но автоматически переходить в экономию RAM у потолка.
Ballast trick (исторический, до GOMEMLIMIT)#
До Go 1.19 не было способа сказать «используй до N памяти». Трюк с балластом эмулировал это, искусственно завышая live_heap:
// ИСТОРИЧЕСКИЙ ХАК, НЕ ИСПОЛЬЗОВАТЬ В Go 1.19+
func main() {
// 10 ГБ "балласта". Виртуальная память выделяется, но физическая —
// нет (страницы нулевые, lazy), пока к ним не обращаются.
ballast := make([]byte, 10<<30)
runtime.KeepAlive(ballast) // не дать оптимизатору выкинуть
// Теперь live_heap всегда >= 10 ГБ, поэтому при GOGC=100
// GC сработает только при ~20 ГБ кучи → редкие сборки.
_ = ballast
}Как это работало: make([]byte, N) создаёт огромный срез, который всегда жив. GC считает его частью live heap, поэтому heap_target = (live + ballast) * 2 оказывается большим, и сборки происходят редко. Физическая память почти не расходовалась благодаря демандной подкачке (overcommit + zero pages). Famous: статья Twitch об экономии 30% CPU и 9× меньше GC через балласт.
Сегодня балласт устарел и вреден: GOMEMLIMIT решает ту же задачу честно, не вводя GC в заблуждение, не занимая виртуальное адресное пространство, корректно работая с pacer и не ломая учёт памяти. Если видите ballast в legacy-коде — это кандидат на замену GOMEMLIMIT.
Подводные камни / gotchas#
- GOMEMLIMIT без знания working set = death spiral или OOM. Лимит должен быть выше пикового живого объёма с запасом, иначе GC начнёт крутиться непрерывно (или, упёршись в 50% CPU-cap, всё равно пробьёт лимит → OOM от cgroup).
- GOGC=off без GOMEMLIMIT = гарантированный OOM на любой растущей нагрузке: GC по росту кучи отключён, ничто не остановит рост. Эту пару нельзя разрывать.
- GOMEMLIMIT не покрывает cgo/mmap/ядерные буферы. Если приложение активно использует cgo или большие mmap-файлы, оставляйте под это отдельный запас сверх лимита.
- GOMAXPROCS должен соответствовать CPU-лимиту cgroup. По умолчанию Go (до 1.25) видит все ядра хоста, а не лимит пода → неверный sizing GC и throttling. Используйте
automaxprocsот Uber (Go 1.25 учитывает cgroup CPU-квоту автоматически). Это напрямую влияет на CPU-cap GC и mark assist. - GiB vs GB: суффиксы GOMEMLIMIT — степени двойки (
GiB= 2^30).GOMEMLIMIT=1GB— это ошибка парсинга (нет суффиксаGB); используйтеGiB/MiBили голые байты. - Соотношение лимита пода и GOMEMLIMIT: ставьте GOMEMLIMIT на ~90-95% от
resources.limits.memory, чтобы оставить запас на неучтённую память и избежать срабатывания OOM-killer раньше, чем GC успеет среагировать (он мягкий и реагирует не мгновенно). - Высокий GOGC ≠ всегда хорошо: при бурстовых аллокациях пик RAM может оказаться неприемлемо высоким даже кратковременно — а OOM-killer не прощает даже кратких пиков.
debug.SetGCPercent(-1)≠SetMemoryLimit: отключение процента не отключает GC по лимиту памяти — они независимы.- Балласт ломает метрики:
MemStats.HeapAllocбудет завышен на размер балласта, искажая мониторинг.
Вопросы на собеседовании#
В: Как именно GOGC определяет момент запуска GC?
О: GOGC задаёт допустимый процент роста кучи относительно живых данных после предыдущей сборки: целевой размер кучи heap_target = live_heap * (1 + GOGC/100). При дефолтном GOGC=100 это значит, что GC сработает примерно когда куча удвоится относительно живого объёма. Но фактически запуск происходит чуть раньше: за это отвечает GC pacer, который по скорости аллокаций оценивает, когда нужно стартовать конкурентную маркировку, чтобы она успела завершиться до достижения target. Если не успевает — включается mark assist, заставляющий аллоцирующие горутины помогать маркировке.
В: Чем GOMEMLIMIT отличается от GOGC и как они работают вместе?
О: GOGC задаёт относительный, пилообразный темп GC (доля роста кучи), а GOMEMLIMIT — абсолютный потолок на общую память рантайма. GC запускается по тому из двух триггеров, что наступает раньше: min(heap_target от GOGC, граница от GOMEMLIMIT). Пока память далеко от лимита, рулит GOGC и сборки редкие; по мере приближения к лимиту pacer учащает GC, эффективно понижая «виртуальный GOGC». Канонический паттерн — GOGC=off + GOMEMLIMIT: GC не дёргается по росту кучи вообще и срабатывает только когда память становится дефицитом, что минимизирует CPU при нормальной нагрузке.
В: Что такое GC death spiral и как рантайм от него защищается? О: Death spiral (GC thrashing) — ситуация, когда working set почти равен GOMEMLIMIT: GC запускается всё чаще, пытаясь удержать лимит, но освобождать почти нечего, поэтому он крутится практически непрерывно, а приложение бóльшую часть CPU тратит на mark assist, при этом throughput падает почти до нуля. Защита: рантайм ограничивает долю CPU на GC примерно 50% в скользящем окне. Если для удержания лимита требуется больше — рантайм сознательно превышает GOMEMLIMIT, отдавая приоритет прогрессу приложения. Это делает GOMEMLIMIT мягким: вместо зависания вы получите превышение лимита (и, возможно, OOM от cgroup). Вывод: GOMEMLIMIT не заменяет корректный sizing, working set должен с запасом помещаться под лимит.
В: Почему GOMEMLIMIT называют «мягким» лимитом и почему он не гарантирует от OOM? О: Мягкий — потому что при достижении лимита рантайм не падает и не отказывает в аллокации, а лишь агрессивнее запускает GC. Это лучше жёсткого лимита для пиковых нагрузок: кратковременный всплеск можно пережить за счёт временного учащения GC, а не падения. Но он не гарантирует от OOM по двум причинам: (1) лимит покрывает только память, учтённую рантаймом Go — cgo, mmap, ядерные буферы остаются снаружи; (2) при CPU-cap в 50% рантайм предпочтёт превысить лимит, лишь бы не уморить приложение. Поэтому GOMEMLIMIT ставят с запасом ниже жёсткого лимита cgroup (~90-95%) и оставляют место под неучтённую память.
В: Какую память покрывает GOMEMLIMIT — только heap?
О: Нет, почти всю память, учтённую рантаймом: кучу (живые + ещё не собранные объекты), стеки горутин, метаданные GC и span/heap-метаданные, внутренние структуры рантайма. В терминах MemStats это примерно Sys - HeapReleased. Не покрывает то, что вне рантайма Go: память, выделенную через cgo, mmap-файлы, буферы ядра, page cache. Поэтому при использовании cgo нужен дополнительный запас сверх лимита.
В: Что такое ballast trick и почему он устарел?
О: До Go 1.19 не было способа задать целевой объём памяти. Балласт — это большой неиспользуемый срез (make([]byte, 10<<30)), который держат живым через runtime.KeepAlive. GC считает его частью live heap, поэтому heap_target оказывается большим и сборки происходят редко — экономия CPU. Физическая память почти не тратится благодаря демандной подкачке нулевых страниц. С Go 1.19 балласт устарел и вреден: GOMEMLIMIT решает ту же задачу честно — не обманывает pacer, не занимает виртуальное адресное пространство, корректно учитывает реальную память и не искажает метрики (HeapAlloc от балласта раздувается). Балласт в коде — кандидат на замену GOMEMLIMIT.
В: Опишите CPU/RAM trade-off в тюнинге GC. О: Это базовый компромисс: чем реже GC, тем больше мусора копится между сборками — выше пиковая RAM, но меньше суммарного CPU на маркировку; чем чаще GC, тем ниже RAM, но выше CPU (и больше mark assist, тормозящего аллоцирующие горутины). GOGC прямо двигает эту точку: GOGC=50 — экономим RAM ценой CPU, GOGC=400 — наоборот. GOMEMLIMIT снимает дилемму «либо-либо»: можно держать высокий GOGC (экономия CPU при норме) и при этом не бояться пика, потому что у потолка GC автоматически учащается, переходя в режим экономии RAM.
В: Как настроить GC для Go-сервиса в Kubernetes-поде с лимитом 4Gi памяти и 2 CPU?
О: Ставлю GOMEMLIMIT примерно на 90-95% от лимита пода — около 3600MiB (запас на неучтённую рантаймом память и на то, что GC реагирует не мгновенно). GOGC либо оставляю дефолтным, либо ставлю off, если хочу минимум GC при норме и полностью полагаюсь на GOMEMLIMIT как на единственный триггер. Обязательно выставляю GOMAXPROCS по CPU-лимиту (через automaxprocs или Go 1.25, который читает cgroup-квоту), иначе GC будет неправильно дозировать CPU и под получит throttling. Перед продом проверяю под нагрузкой, что working set с запасом ниже GOMEMLIMIT, чтобы не словить death spiral, и мониторю go_memstats_* + GC CPU fraction.
На что копают на senior+#
- Точная формула
heap_target = live*(1+GOGC/100)и понимание, что pacer стартует раньше target, а не на нём. - Mark assist: умение объяснить, что «дорогой GC» в CPU-профиле — это
gcAssistAlloc, и почему он появляется при отставании маркировки. - Взаимодействие GOGC и GOMEMLIMIT как
min(...)двух триггеров; понимание паттернаGOGC=off + GOMEMLIMITи почему именно так. - Death spiral и 50% CPU-cap: знание, что лимит мягкий и рантайм осознанно его превысит, отдав приоритет приложению.
- Граница покрытия GOMEMLIMIT: что туда входит (heap, stacks, GC-метаданные) и что нет (cgo, mmap, ядро).
- GOMAXPROCS vs cgroup: связь sizing CPU с поведением GC и throttling в k8s; automaxprocs / Go 1.25.
- История ballast: способны ли объяснить механику обмана pacer и почему GOMEMLIMIT сделал его устаревшим.
- Практический sizing: умение назвать конкретные значения для контейнера (90-95% от лимита) и обосновать запас.
- Метрики и валидация: какие поля MemStats и какие Prometheus-метрики смотреть, чтобы подтвердить, что тюнинг работает, а не вызвал thrashing.