Модуль: Runtime и память · Уровень: Senior+
TL;DR#
GC в Go — это конкурентный, неперемещающий (non-moving), tri-color mark-sweep сборщик с очень короткими STW-паузами (обычно sub-millisecond). Конкурентность корректна благодаря hybrid write barrier (Dijkstra insertion + Yuasa deletion), появившемуся в Go 1.8, который позволил убрать повторное STW-сканирование стеков. Темп сборки регулирует GC pacer, балансируя момент старта цикла так, чтобы heap не превысил цель GOGC (или мягкий лимит GOMEMLIMIT с 1.19); при отставании mark-фазы горутины-мутаторы платят mark assist. Аллокация идёт через многоуровневую иерархию mcache → mcentral → mheap с разбиением по size classes.
Теория#
Зачем вообще конкурентный GC и почему non-moving#
Цель дизайна Go GC — не максимальный throughput, а минимальная латентность пауз. Это фундаментальный выбор: команда Go сознательно жертвует частью CPU-throughput ради того, чтобы паузы оставались в районе долей миллисекунды и не росли с размером кучи.
GC в Go non-moving (неперемещающий, non-compacting). Объекты не двигаются по куче во время сборки. Причины:
- Совместимость с C через cgo: указатель, переданный в C-код, не должен внезапно изменить значение.
- Простота interior pointers: в Go легально держать указатель внутрь объекта (на поле структуры, на элемент массива). Перемещающий коллектор должен был бы корректно обновлять такие указатели.
- unsafe.Pointer и аппаратные предположения: код полагается на стабильность адресов.
Цена non-moving — фрагментация кучи. Go борется с ней через size classes (см. ниже): объекты группируются по классам размеров, что резко снижает внешнюю фрагментацию, оставляя ограниченную внутреннюю.
Tri-color abstraction (трёхцветная маркировка)#
Алгоритм оперирует тремя логическими множествами объектов:
| Цвет | Смысл |
|---|---|
| Белый (white) | Кандидат на удаление. В начале цикла все объекты белые. В конце mark-фазы оставшиеся белые — мусор. |
| Серый (grey) | Достижим (помечен живым), но его исходящие указатели ещё не просканированы. Лежит в work queue. |
| Чёрный (black) | Достижим и полностью просканирован — все его дети уже серые или чёрные. |
Процесс: корни (стеки горутин, глобальные переменные) делаются серыми. Дальше воркеры берут серый объект, сканируют его указатели (делая указуемых серыми), и красят сам объект в чёрный. Когда серых не осталось — всё чёрное живо, всё белое — мусор.
В реальности «цвета» не хранятся в самих объектах как поле. Реализованы они так:
- Mark bits — битовая карта в метаданных span/heapArena (чёрный vs белый).
- Серость = присутствие в work queue (gcWork: локальные буферы per-P + глобальный пул). Объект серый ⇔ помечен И ещё в очереди на сканирование.
Tri-color invariant: strong и weak#
Корректность конкурентного mark держится на инвариантах. Опасность одна: мутатор может спрятать белый объект от сборщика, перевесив единственный указатель на него под чёрный объект и удалив исходный путь.
- Strong tri-color invariant: ни один чёрный объект не указывает на белый. Если поддерживать строго — белый недостижим из уже «закрытых» чёрных, и его не пропустят.
- Weak tri-color invariant: чёрный может указывать на белый, но тогда у этого белого должен существовать серый объект, ведущий к нему (по цепочке белых). То есть белый ещё достижим через незавершённую работу.
Write barrier нужен именно чтобы при записи указателя сохранить один из этих инвариантов.
Write barrier: Dijkstra insertion + Yuasa deletion = hybrid (Go 1.8)#
Write barrier — это маленький фрагмент кода, вставляемый компилятором перед записью указателя в куче (*slot = ptr).
Dijkstra-style insertion barrier поддерживает strong invariant. При записи *slot = ptr он красит записываемый объект ptr в серый (shade). Проблема: insertion barrier на стеках слишком дорог (стеки горячие, барьер на каждую запись локального указателя убил бы производительность). Поэтому в Go ≤1.7 стеки оставляли без барьера, а в конце требовалось STW-перепроверка (re-scan) всех стеков, что давало паузы, растущие с числом/размером горутин.
Yuasa-style deletion barrier поддерживает weak invariant. При перезаписи указателя он красит старое значение слота (то, что вот-вот будет затёрто) — снимок «как было». Это сохраняет достижимость объектов, на которые ссылались на момент старта.
Hybrid write barrier (Go 1.8, Austin Clements):
// Псевдокод того, что вставляет компилятор перед *slot = ptr
writePointer(slot, ptr):
shade(*slot) // Yuasa: затеняем СТАРОЕ значение
if current_stack is grey:
shade(ptr) // Dijkstra: затеняем НОВОЕ значение
*slot = ptrКлючевой выигрыш: hybrid barrier обеспечивает инвариант «объект, на который указывает чёрный стек (уже просканированный), и все объекты, на которые он указывал на момент сканирования, считаются достижимыми». Это означает, что стек, будучи просканированным один раз, становится чёрным навсегда — re-scan стеков с STW больше не нужен. Именно это сократило паузы mark termination на порядок (с потенциально миллисекунд+ до <100 мкс).
Тонкость, которую любят на интервью: барьер срабатывает только на записи указателей в кучу/в видимые сборщику места. Записи в локальные переменные на стеке барьером не покрываются — корректность обеспечивается тем, что стек либо ещё не сканировался (серый, будет просканирован целиком), либо уже чёрный (и тогда новые указатели приходят из уже учтённых объектов или защищены Dijkstra-частью).
Фазы GC-цикла#
... выполнение программы ...
│
├─[STW] Sweep termination — добиваем недосвипанные span'ы предыдущего цикла
│ Включаем write barrier, переводим мир в режим маркировки
│
├─ Mark setup (часть под STW) — подготовка корней, включение барьера
│
├─ Concurrent Mark — параллельно с мутаторами:
│ - dedicated mark workers (≈25% GOMAXPROCS)
│ - mark assist на горутинах-аллокаторах
│ - сканирование стеков, глобалов, обход графа объектов
│
├─[STW] Mark termination — финализация: drain остатков work queue,
│ выключение write barrier, подсчёт, подготовка к sweep
│
└─ Concurrent Sweep — освобождение белых span'ов лениво,
параллельно с работой программы (по требованию аллокатора)Две STW-паузы за цикл — sweep termination и mark termination. Обе спроектированы как очень короткие (десятки микросекунд в типичном случае). Между ними mark идёт конкурентно. Sweep также конкурентен и в значительной степени ленив: span чистится в момент, когда из него хотят аллоцировать.
# Увидеть фазы и паузы в реальном времени:
GODEBUG=gctrace=1 ./app
# Пример строки:
# gc 14 @2.123s 1%: 0.018+1.2+0.025 ms clock, 0.14+0.30/1.1/0+0.20 ms cpu, 4->4->2 MB, 5 MB goal, 8 P
# ^STW ^conc ^STW ^heap_start->peak->live ^goalРазбор 0.018+1.2+0.025 ms clock: первая цифра — STW sweep termination, средняя — конкурентный mark, последняя — STW mark termination.
GC Pacer — когда запускать цикл#
Pacer решает в какой момент стартовать конкурентный mark, чтобы успеть закончить до того, как куча перерастёт цель. Если стартовать слишком поздно — мутаторы наплодят аллокаций быстрее, чем GC помечает, и куча превысит goal. Слишком рано — лишний CPU и частые циклы.
Цель кучи (heap goal) по умолчанию:
heap_goal = live_heap_after_last_mark * (1 + GOGC/100)При GOGC=100 (дефолт) цикл нацелен на то, чтобы новая куча выросла вдвое относительно живого объёма прошлого цикла. С 1.19 цель учитывает ещё и GOMEMLIMIT:
heap_goal = min( GOGC-based_goal, memory_limit_based_goal )Pacer — это контроллер с обратной связью (PI-controller в редизайне Go 1.18), который оценивает «scan work» и подбирает момент старта и долю assist так, чтобы mark финишировал примерно при достижении 95% пути к goal.
Mutator assist (помощь от горутин)#
Если конкурентный mark отстаёт от темпа аллокаций, горутина, которая аллоцирует, обязана «отработать долг»: перед получением памяти она выполняет порцию mark-работы пропорционально объёму запрашиваемой аллокации. Это mark assist.
// Логика на уровне runtime (упрощённо):
// при аллокации g накапливает assist debt;
// если debt > 0 — горутина сама сканирует объекты, прежде чем продолжить.Assist — это механизм обратного давления: чем агрессивнее программа аллоцирует во время GC, тем больше CPU у неё забирают на помощь сборщику, не давая куче «убежать». Высокий assist в gctrace — сигнал, что аллокаций слишком много / GOGC слишком низкий.
Иерархия аллокатора: mcache → mcentral → mheap#
Аллокатор Go основан на идеях TCMalloc.
| Уровень | Что это | Локальность | Блокировка |
|---|---|---|---|
| mcache | Кэш свободных объектов на каждый P (логический процессор) | per-P | без блокировок (P принадлежит одной M в момент времени) |
| mcentral | Центральный список span’ов одного size class на всю программу | глобально на класс | mutex на класс |
| mheap | Глобальная куча, управляет всеми span’ами и виртуальной памятью | глобально | глобальный lock |
Путь аллокации мелкого объекта: горутина → mcache своего P (lock-free) → если пусто, берём span из mcentral нужного класса → если и там пусто, mheap нарезает новый span (при необходимости запросив страницы у ОС через mmap).
- Tiny allocator (объекты <16 байт без указателей, напр. маленькие строки): субаллоцируются в один 16-байтный блок, чтобы не плодить накладные расходы.
- Small (≤32 KB): через size classes.
- Large (>32 KB): аллоцируются напрямую из mheap отдельными span’ами.
Span и size classes#
Span (mspan) — непрерывный участок страниц (кратный 8 KB), нарезанный на объекты одного size class. Это базовая единица управления памятью и метаданных GC (mark bits, alloc bits хранятся на уровне span).
Size classes — фиксированный набор «округлений» размеров (~70 классов). Любой запрос округляется вверх до ближайшего класса. Это устраняет внешнюю фрагментацию ценой ограниченной внутренней (waste).
# Полная таблица генерируется здесь:
# $GOROOT/src/runtime/sizeclasses.go// фрагмент таблицы (class, размер объекта, размер span, объектов в span, max waste %)
// class bytes/obj bytes/span objects tail waste max waste
// 1 8 8192 1024 0 87.50%
// 2 16 8192 512 0 43.75%
// 5 48 8192 170 32 31.52%
// 32 512 8192 16 0 1.99%
// 67 32768 32768 1 0 0.00%Запрос на 33 байта попадёт в класс «48» → 15 байт внутренней фрагментации. Понимание этого критично для оптимизации: укладка структур в границы size class экономит память на больших слайсах объектов.
Эволюция GC по версиям#
| Версия | Изменение |
|---|---|
| Go 1.3 | Полностью precise (точный) mark-sweep, но ещё STW. |
| Go 1.5 | Конкурентный mark-sweep. Цель «GC pause <10ms». Появился GC pacer. |
| Go 1.6 | Снижение пауз, лучшая работа с большими кучами. |
| Go 1.7 | Дальнейшая оптимизация mark termination. |
| Go 1.8 | Hybrid write barrier (Dijkstra + Yuasa). Убрали STW re-scan стеков → паузы стабильно <1 ms, часто <100 мкс. |
| Go 1.9–1.11 | Полировка, sweep/scan оптимизации. |
| Go 1.12 | Улучшение возврата памяти ОС, переработка mark termination, более ровные паузы. |
| Go 1.13 | MADV_FREE по умолчанию на Linux (позже частично откатили в 1.16 на MADV_DONTNEED из-за непонятных метрик RSS). |
| Go 1.14 | Asynchronous preemption (вытеснение по сигналам) — горутины в плотных циклах без вызовов функций теперь могут быть остановлены для STW; раньше такой цикл мог затянуть паузу. |
| Go 1.18 | Переработанный PI-controller pacer (Michael Knyszek). |
| Go 1.19 | Soft memory limit GOMEMLIMIT — мягкий лимит на total memory, GC учитывает его в цели кучи. |
Asynchronous preemption (почему важно для пауз)#
До Go 1.14 вытеснение горутин было кооперативным — точка вытеснения вставлялась только при вызове функций (проверка stack preempt). Горутина с тугим циклом без вызовов (for { i++ }) не могла быть остановлена, и STW-фаза ждала её, раздувая паузу. С 1.14 рантайм шлёт горутине сигнал (SIGURG на Unix), обработчик сохраняет состояние и вытесняет в произвольной (безопасной для precise GC) точке. Это делает STW-паузы предсказуемыми независимо от поведения кода.
Настройка: GOGC и GOMEMLIMIT (кратко)#
# GOGC: процент роста кучи между циклами. Дефолт 100.
GOGC=200 ./app # реже GC, больше RAM, выше throughput
GOGC=50 ./app # чаще GC, меньше RAM, больше CPU на сборку
GOGC=off ./app # отключить GC (только для коротких batch-задач!)
# GOMEMLIMIT (>=1.19): мягкий потолок суммарной памяти рантайма.
GOMEMLIMIT=4GiB ./appimport "runtime/debug"
debug.SetGCPercent(50) // = GOGC
debug.SetMemoryLimit(4 << 30) // = GOMEMLIMIT, 4 GiBПодробный разбор GOMEMLIMIT/GOGC-стратегий — в отдельной заметке по тюнингу памяти; здесь важно: GOMEMLIMIT — soft limit, GC будет работать всё агрессивнее по мере приближения к нему вплоть до почти-непрерывного GC, но не вызовет OOM-kill сам и не падает с ошибкой при превышении (в отличие от hard limit). Анти-паттерн: ставить GOMEMLIMIT равным физической памяти контейнера без запаса — рискуете death-spiral, когда GC жрёт 100% CPU, пытаясь удержать лимит.
Подводные камни / gotchas#
- GOGC=off в долгоживущем сервисе → неконтролируемый рост RSS и OOM. Допустимо только для коротких CLI/batch.
runtime.GC()вручную почти всегда ошибка: это блокирующий, полный, частично-STW цикл. Использовать лишь в бенчмарках/тестах.- GC death spiral с GOMEMLIMIT: лимит слишком близок к фактической рабочей памяти → GC работает непрерывно, CPU 100%, прогресса нет. Лечится поднятием лимита или снижением живой кучи.
- Высокий mark assist в gctrace — не «GC медленный», а «программа аллоцирует быстрее, чем GC успевает»; смотреть на allocation rate и escape analysis, а не на сам GC.
- Финализаторы (
runtime.SetFinalizer) задерживают освобождение объекта минимум на один цикл (объект с финализатором не освобождается в том же цикле, в котором стал недостижим) и ломают порядок освобождения циклов. Не использовать для управления критичными ресурсами — толькоdefer/Close. - Память не сразу возвращается ОС: RSS может оставаться высоким после падения живой кучи (
MADV_FREE/scavenger). RSS != live heap. Меритьruntime.ReadMemStats(HeapInuse/HeapReleased), а не толькоtop. - Фрагментация из-за size classes: массив структур размером «чуть больше класса» тратит память впустую. Профилируйте размер через
unsafe.Sizeofи таблицу sizeclasses. - Указатели удерживают целые span’ы: один живой объект в большом span не даёт освободить span. Утечка через срез, удерживающий backing array большого слайса — классика (
s = bigSlice[:1]держит весь backing).
Вопросы на собеседовании#
В: Почему в Go GC неперемещающий, и какова цена этого решения? О: Non-moving выбран ради совместимости с cgo (указатели, переданные в C, должны быть стабильны), поддержки interior pointers и unsafe-кода, а также простоты реализации write barrier без необходимости обновлять все указатели при перемещении. Цена — фрагментация кучи, которую Go компенсирует размещением по size classes (резко снижает внешнюю фрагментацию). Также non-moving означает, что аллокатор не может делать дешёвый bump-pointer allocation на всю кучу, поэтому используется segregated-fit по классам через mcache/mcentral/mheap.
В: Что такое hybrid write barrier и какую проблему он решил в Go 1.8? О: Это комбинация Dijkstra insertion barrier (затеняет новый записываемый указатель, поддерживая strong invariant) и Yuasa deletion barrier (затеняет старое, перезаписываемое значение, поддерживая weak invariant). Главная проблема, которую он решил: до 1.8 стеки не имели барьера (он слишком дорог для горячих стеков), поэтому в конце mark требовался STW-перескан всех стеков, и пауза росла с числом/размером горутин. Hybrid barrier обеспечивает инвариант, при котором просканированный стек можно считать «чёрным навсегда», устранив повторный STW-скан и сократив паузы mark termination на порядок.
В: Объясни strong и weak tri-color invariants. О: Strong invariant: ни один чёрный объект не ссылается на белый — гарантирует, что белый (мусор-кандидат) недостижим из «закрытых» чёрных. Weak invariant ослабляет: чёрный может ссылаться на белый, но при условии, что к этому белому существует путь через серый объект (т.е. он всё ещё в очереди на обработку и не будет потерян). Write barrier поддерживает один из инвариантов, чтобы мутатор не смог «спрятать» живой белый объект, перевесив единственную ссылку на него под уже просканированный чёрный объект.
В: Сколько STW-пауз в одном GC-цикле и где они?
О: Две: sweep termination (в начале, добивает sweep предыдущего цикла и включает барьер/режим маркировки) и mark termination (в конце mark, drain остатков очереди, выключение барьера). Между ними mark идёт конкурентно, sweep после — конкурентно и лениво. Обе паузы спроектированы как очень короткие, обычно десятки микросекунд; их можно увидеть в GODEBUG=gctrace=1 как первую и третью цифры в X+Y+Z ms clock.
В: Что делает GC pacer и что такое mark assist?
О: Pacer решает, в какой момент стартовать конкурентный mark, чтобы успеть завершить его до того, как куча достигнет heap goal (по умолчанию live*(1+GOGC/100), с 1.19 — min с лимитом по GOMEMLIMIT). Это контроллер с обратной связью (PI-controller с 1.18). Mark assist — механизм обратного давления: если mark отстаёт от темпа аллокаций, горутина, которая аллоцирует, обязана сама выполнить порцию mark-работы пропорционально размеру аллокации, прежде чем получить память. Это не даёт куче «убежать» за goal под нагрузкой.
В: Как объекты размещаются в памяти — расскажи про mcache/mcentral/mheap и size classes. О: Трёхуровневая иерархия в стиле TCMalloc. mcache — per-P кэш свободных объектов, аллокация из него идёт без блокировок (P в момент времени принадлежит одной M). Если в mcache пусто — берётся span из mcentral (центральный список span’ов нужного size class, под мьютексом класса). Если пусто и там — mheap нарезает новый span, при необходимости запросив страницы у ОС. Size classes — ~70 фиксированных размеров, любой запрос округляется вверх до ближайшего; это устраняет внешнюю фрагментацию ценой ограниченной внутренней. Span — непрерывный набор страниц, нарезанный на объекты одного класса, и единица хранения mark/alloc bits. Объекты >32 KB идут напрямую из mheap.
В: Почему high allocation rate важнее для производительности, чем «скорость GC»?
О: GC-нагрузка пропорциональна количеству и размеру живых указуемых объектов и темпу аллокаций. Большинство «GC-проблем» на самом деле — проблемы аллокаций: лишние escape на кучу, отсутствие переиспользования (sync.Pool), копирование. Высокий mark assist и частые циклы — следствие, а не причина. Поэтому senior сначала смотрит на escape analysis (go build -gcflags=-m), pprof alloc-профиль и снижает аллокации, и только потом крутит GOGC/GOMEMLIMIT.
В: Что изменила asynchronous preemption в 1.14 и при чём тут GC? О: До 1.14 вытеснение было кооперативным — только в точках вызова функций. Горутина с тугим циклом без вызовов не могла быть остановлена, и STW-фаза GC (или планировщик) ждала её, раздувая паузу до неопределённого времени. С 1.14 рантайм посылает сигнал (SIGURG на Unix), и горутина вытесняется в безопасной для precise GC точке. Это сделало STW-паузы предсказуемыми независимо от структуры пользовательского кода.
В: Почему RSS процесса не падает сразу после того, как живая куча уменьшилась?
О: Освобождённая память не возвращается ОС немедленно. Sweep делает span доступным для повторного использования внутри процесса, а фактический возврат страниц делает фоновый scavenger через madvise (MADV_FREE или MADV_DONTNEED). При MADV_FREE страницы помечаются как освобождаемые, но физически остаются за процессом, пока ОС не понадобится память — поэтому RSS в top может казаться завышенным. Правильная метрика живой памяти — runtime.ReadMemStats (HeapAlloc/HeapInuse/HeapReleased), а не RSS.
На что копают на senior+#
- Точная механика hybrid barrier: какой именно из двух «shade» поддерживает какой инвариант, и почему именно комбинация позволяет НЕ перескан стеков. Умение нарисовать сценарий потери объекта без барьера (мутатор перевешивает указатель с белого пути на чёрный объект) и показать, как каждая часть барьера его ловит.
- Pacer как контроллер: понимание, что это feedback loop, оценивающий scan work, и что переход на PI-controller в 1.18 решал колебания/overshoot старого proportional-пейсера.
- Связь GOMEMLIMIT и death spiral: умение объяснить, когда мягкий лимит приводит к 100% CPU на GC, и как это диагностировать/чинить (вместе с
GOGC=off+GOMEMLIMIT как осознанный паттерн «лимит-главный» в контейнерах). - Где живут «цвета»: что серость — это присутствие в work queue (gcWork, per-P буферы + глобальный пул), а чёрный/белый — mark bits в метаданных span; цвет не хранится в объекте.
- Стоимость барьера для компилятора: write barrier вставляется только на запись указателей и только когда барьер «включён» (флаг в рантайме), записи не-указателей и записи во время выключенного барьера идут напрямую; знание про
runtime.gcWriteBarrierи почему запись указателя дороже записи int. - Финализаторы, weak pointers (1.24+) и порядок освобождения: почему финализаторы задерживают сбор на цикл и не дружат с циклическими ссылками.
- Чтение gctrace вслух: разбор каждого поля строки
gc N @t Ns P%: a+b+c ms clock, ... X->Y->Z MB, G MB goal, P Pи постановка диагноза по числам. - Tiny allocator и size class waste: как уложить структуру под границу класса, чтобы сэкономить память на больших коллекциях, со ссылкой на
runtime/sizeclasses.go.