Модуль: 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.8Hybrid write barrier (Dijkstra + Yuasa). Убрали STW re-scan стеков → паузы стабильно <1 ms, часто <100 мкс.
Go 1.9–1.11Полировка, sweep/scan оптимизации.
Go 1.12Улучшение возврата памяти ОС, переработка mark termination, более ровные паузы.
Go 1.13MADV_FREE по умолчанию на Linux (позже частично откатили в 1.16 на MADV_DONTNEED из-за непонятных метрик RSS).
Go 1.14Asynchronous preemption (вытеснение по сигналам) — горутины в плотных циклах без вызовов функций теперь могут быть остановлены для STW; раньше такой цикл мог затянуть паузу.
Go 1.18Переработанный PI-controller pacer (Michael Knyszek).
Go 1.19Soft 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 ./app
import "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.