• 4 фазы: Sweep Termination (STW) → Mark (concurrent) → Mark Termination (STW) → Sweep (concurrent)
  • STW паузы: ~10-30µs + ~60-90µs, основная работа concurrent
  • триггер: heap goal, 2 мин без GC, runtime.GC()

Триггер запуска

Heap вырос до Live heap + (Live heap + stacks + globals) × GOGC/100 — момент запуска выбирает GC Pacer, параметры задаются через GOGC и GOMEMLIMIT.

Также запускается если прошло >2 минут без GC (sysmon) или вызван runtime.GC().

Фаза 1: Sweep Termination (STW, ~10-30µs)

Останавливаем все горутины. Дочищаем sweep предыдущего цикла (если не закончили). Включаем Write barrier. Возобновляем горутины.

Фаза 2: Mark (concurrent, ~25% CPU)

Работает одновременно с программой, несколькими потоками параллельно.

Объекты не помечены (white по умолчанию). От root objects (стеки горутин + глобальные + runtime) обходим граф, помечаем живые — это Tri-color marking.

Программа продолжает работать и меняет указатели — Write barrier следит, чтобы GC не потерял живой объект.

Если горутина аллоцирует быстрее, чем GC маркирует — Mark Assist заставляет её помогать GC.

Объекты с Finalizers не удаляются — ставятся в очередь, будут освобождены в следующем цикле.

Фаза 3: Mark Termination (STW, ~60-90µs)

Останавливаем горутины. Дочищаем очередь серых объектов. Убеждаемся что серых нет. Выключаем Write barrier. Собираем статистику для следующего цикла. Возобновляем горутины.

Фаза 4: Sweep (concurrent + lazy)

Работает в фоне параллельно с программой. Освобождает white объекты — память возвращается Пример аллокации.

Часть sweep происходит прямо при новых аллокациях (lazy). Scavenger возвращает совсем ненужное ОС.


  • мусор = память, которая раньше использовалась, но стала никому не нужна
  • GC = всегда overhead (ручной или автоматический). Иногда мусор можно не собирать
  • исключения: короткоживущие воркеры, биржевые приложения с рестартом между сессиями

Что такое мусор

Память, которая раньше была нужна, но стала неиспользуемой. Например: вызвали функцию, аллоцировали гигабайт, вышли — эта память никому не нужна. Её нужно освободить, чтобы аллокатор мог переиспользовать это место.

Всегда ли нужно собирать мусор?

Нет. GC — это всегда overhead: дополнительные операции, CPU, паузы.

Иногда можно не собирать:

Воркер, который отработает несколько минут — памяти хватает, зачем тратить CPU на GC? Биржевое приложение (10:00–20:00) — если памяти хватает на весь день, можно рестартнуть перед следующей сессией.

Не значит, что так нужно делать. Это исключительные ситуации. Но важно понимать: если GC не нужен — мы экономим CPU и избегаем пауз.

Способы сборки мусора

Ручной (C/C++): программист сам malloc/free. Минимальный overhead. Проблемы: double-free, забытый free, утечки при разрастании проекта.

Автоматический: два основных подхода — reference counting и tracing. Больше overhead, но программист не думает об освобождении.


  • каждый объект хранит счётчик ссылок. Копируют ссылку → инкремент. Удаляют ссылку → декремент. Счётчик = 0 → объект можно освободить
  • плюс: не нужен фоновый поток GC. Основной поток сам видит ref_count = 0 и освобождает.
  • минусы:
    • overhead на ведение счетчика
    • синхронизация счетчиков между разными потоками,
    • циклические ссылки(двухсвязный список)
    • Каскадное синхронное освобождение: связанный список на миллион элементов → удаляем head → поочерёдно освобождается каждый элемент → spike latency.
  • почему не подошло для Go: weak/strong refs = дополнительная сложность для программиста

Идея

Каждый объект содержит счётчик: сколько других объектов на него ссылаются. Копируют ссылку → инкремент. Удаляют ссылку → декремент. Счётчик = 0 → объект можно освободить.

text
Object в heap: [данные | ref_count = 2] ↑ ↑ obj_A obj_B (копия) obj_B уничтожен → ref_count = 1 obj_A уничтожен → ref_count = 0 → free!

Плюсы

Не нужен никакой фоновый поток для сборки мусора. Основной поток сам видит ref_count = 0 и освобождает.

Минусы

Overhead: дополнительная память на счётчик + инструкции инкремента/декремента при каждой операции.

Синхронизация: из нескольких потоков нужны атомики или мьютексы для счётчика.

Каскадное синхронное освобождение: связанный список на миллион элементов → удаляем head → поочерёдно освобождается каждый элемент → spike latency.

Циклические ссылки (главная проблема):

text
Двусвязный список: A.next → B ref_count(A) = 2 B.prev → A ref_count(B) = 2 Удалили внешний указатель: ref_count(A) = 1 (B всё ещё ссылается) ref_count(B) = 1 (A всё ещё ссылается) → Никогда не станет 0 → утечка памяти!

Решение в C++: weak_ptr / shared_ptr (слабые/сильные ссылки, разные счётчики). Но это дополнительная сложность для программиста: нужно знать, где weak, где strong.

Почему не подошло для Go

Если бы Go использовал ref counting, программисту пришлось бы знать про weak/strong refs, управлять ими. Дополнительные контейнеры, функции, вопросы на собесах — язык усложняется. Go выбрал tracing GC, где программист вообще не думает об освобождении.


  • tracing ищет живые объекты (ref counting ищет мёртвые) — семантически противоположные подходы, все что не живые - мертвые
  • STW: остановить мутаторы → обойти граф от root set → пометить живые → удалить непомеченные
    • мутаторы - потоки которые выполняют что-то, мутирую память
    • root set - стеки всех горутин + глобальные переменные + runtime-структуры, место, откуда начинаются все цепочки ссылок на heap
  • Проблема: Чем больше куча → тем больше граф → тем дольше обход → тем дольше паузы.
  • Sweep (Go): освободить белые (непомеченные) объекты. Быстро, но heap остаётся фрагментированным.
  • Copying: перенести живые объекты в новое место компактно, старое освободить целиком. Дольше, но нет фрагментации.

Идея

В отличие от ref counting, tracing ищет живые объекты, а не мёртвые. Всё, до чего не дошли — мусор.

Мутаторы — потоки, выполняющие бизнес-логику (называются так, потому что мутируют память: создают объекты, изменяют ссылки).

Алгоритм (простейший, STW)

text
1. Остановить все мутаторы (Stop the World) 2. Обойти граф ссылок от root set (стеки + глобальные) - можно параллельно, несколькими потоками 3. Пометить все живые объекты 4. Непомеченные = мусор → удалить

Root set: стеки всех горутин + глобальные переменные. Оттуда начинаются все цепочки ссылок в heap.

Фаза маркировки может выполняться параллельно несколькими потоками внутри STW.

Фаза очистки: Sweep vs Copying

Sweep (Go): освободить белые (непомеченные) объекты. Быстро, но heap остаётся фрагментированным.

Copying: перенести живые объекты в новое место компактно, старое освободить целиком. Дольше, но нет фрагментации.

Проблема STW

Чем больше куча → тем больше граф → тем дольше обход → тем дольше паузы. Для больших приложений неприемлемо: циклические спайки latency на время каждой сборки.

Как уменьшить паузы

Поколения: сканировать не всю кучу, а только молодые объекты (чаще умирают).

Concurrent GC: не останавливать мутаторы, работать параллельно с ними. Go пошёл по пути concurrent (без поколений).


  • базовый алгоритм маркировки GC. В чистом виде требует STW
  • Go работает concurrent → нужны доп. механизмы:
    • Hybrid write barrier — корректность (не потерять живой объект)
    • Mark Assist — скорость (GC успевает за аллокациями)
  • три цвета:
    • White = не помечен, кандидат на удаление
    • Grey = обнаружен, но ссылки не просканированы
    • Black = просканирован, все ссылки обработаны
  • инварианты (правила корректности для concurrent режима):
    • Strong (Go до 1.8): чёрный НЕ может указывать на белый
    • Weak (Go 1.8+): чёрный МОЖЕТ указывать на белый, но белый должен быть достижим через серый

Алгоритм

text
1. Объекты не помечены (white по умолчанию) 2. Root objects → grey (стеки горутин + глобальные + runtime) 3. Цикл пока есть серые: - берём серый объект из очереди (назовём его ТЕКУЩИЙ) - смотрим на кого ТЕКУЩИЙ ссылается (его дети) - для каждого ребёнка: - ребёнок white → красим ребёнка в grey, кладём ребёнка в очередь - ребёнок grey/black → пропускаем - всех детей просмотрели → ТЕКУЩИЙ → black (полностью обработан) 4. Конец: серых нет → всё что white = мусор → sweep

Основа

Алгоритм маркировки живых объектов. Три цвета = состояние объекта.

Цвета:

White — не помечен, кандидат на удаление.

Grey — обнаружен, но ссылки ещё не просканированы (фронт волны обхода).

Black — просканирован, все ссылки обработаны.

Зачем три цвета, а не два? Для concurrent маркировки. Grey означает «обнаружен, но дети ещё не просканированы». Без него невозможно понять, какой объект уже полностью обработан, а какой нет, пока мутаторы параллельно меняют указатели. Два цвета хватило бы только для STW-сборщика.

Алгоритм:

Обход можно параллелить: каждый поток забирает элемент из очереди и идёт в глубину.

Инварианты (гарантии корректности):

Strong tri-color invariant: чёрный объект НЕ может указывать на белый. Go использовал до 1.8 (Dijkstra barrier).

Weak tri-color invariant: чёрный МОЖЕТ указывать на белый, НО белый должен быть достижим через серый. Go 1.8+ использует это (hybrid Write barrier).

Почему базовый tri-color marking требует STW? В чистом виде: если мутатор одновременно меняет указатели, он может создать ситуацию когда чёрный указывает на белый (invariant нарушен), и GC удалит живой объект. Concurrent режим требует write barrier для поддержания инвариантов.


  • write barrier — общий принцип: при записи указателя → уведомить GC, чтобы не потерять живой объект
  • три реализации: insertion (новый ребёнок → серый), deletion (старый ребёнок → серый), hybrid (оба ребёнка → серый)
  • минусы: insertion — стек не покрыт → rescan стеков в Mark Termination (долгий STW). Deletion — мусор живёт +1 цикл GC
  • Go использует hybrid write barrier (1.8+): стек без barrier (дорого), стеки сканируются целиком один раз. Rescan не нужен → паузы короче
  • включение/выключение требует короткого STW (~10-30µs), активен только в Mark phase
  • overhead: ~2 инструкции на запись указателя. Fast path: проверка writeBarrier.enabled (почти бесплатно когда GC не активен). Общий overhead ~10-20% на pointer-heavy код
  • корнер-кейс: серые не заканчиваются → после лимита новые объекты сразу чёрные, мусор доживёт до следующего цикла

Что такое write barrier

Общий принцип: при записи указателя в heap → уведомить GC. Нужен потому что в concurrent режиме программа меняет указатели пока GC маркирует. Без barrier GC может потерять живой объект (lost object).

Insertion barrier (Dijkstra):

text
A.field = C // записали новый указатель A → C // новый ребёнок C → красится серым

Появился новый ребёнок → красим его серым, чтобы GC не пропустил.

Минус insertion barrier: стек не покрыт → в Mark Termination нужен полный rescan стеков (долгий STW).

Deletion barrier (Yuasa):

text
// было: A.field = B (B — ребёнок A) A.field = C // перезаписали: B отвязан от A // старый ребёнок B → красится серым

Ребёнок потерял родителя → красим его серым, чтобы GC не потерял B и его потомков.

Минус deletion barrier: мусор живёт +1 цикл GC (старый ребёнок серый → не удалится в этом цикле).

Hybrid (Go 1.8+):

text
// было: A.field = B A.field = C // и старый ребёнок B → серый // и новый ребёнок C → серый

Стек НЕ покрыт barrier (дорого). Стеки сканируются целиком один раз при приостановке горутины.

Rescan стеков не нужен → паузы короче.

Когда активен в Go

Mark phase: write barrier ON. Sweep phase: write barrier OFF.

Включение/выключение требует STW (~10-30µs).

Корнер-кейс: серые не заканчиваются. Мутаторы плодят объекты → все красятся серым → очередь не пустеет.

Решение: после лимита красить новые объекты сразу чёрными. Мусор доживёт до следующего цикла, но фаза Mark завершится.

Overhead

~2 доп. инструкции на запись указателя.

Fast path: проверка writeBarrier.enabled (почти бесплатно когда GC не активен).

Общий overhead: ~10-20% на pointer-heavy код.

Эволюция Go

Go 1.5: insertion barrier → STW rescan стеков.

Go 1.8: hybrid barrier → нет rescan → короче паузы.


  • GOGC: % роста heap до следующего GC (default 100 → next GC = prev × 2). Трейдофф: GOGC↓ = чаще GC, меньше памяти, больше CPU. GOGC↑ = реже GC, больше памяти, меньше CPU.
  • GOMEMLIMIT: soft-лимит на всю память, runtime сам уменьшает GOGC. Защита: CPU GC ≤ 50% при Если не справляется — позволяет превысить лимит (soft, не hard). Иначе deadlock.
  • death spiral: live heap ≈ лимит → GC постоянно работает → CPU 100% на GC (но ≤50% с GOMEMLIMIT) → программа не обрабатывает запросы → запросы копятся → нужно ещё больше памяти → ...

Два способа управления GC: частота (GOGC) и лимит памяти (GOMEMLIMIT).

GOGC — частота сборки

text
Процент роста heap до следующего GC. Default: 100 next GC = live heap × (1 + GOGC/100) GOGC=100, live heap=100MB → GC при 200MB (×2) GOGC=50, live heap=100MB → GC при 150MB (×1.5) GOGC=200, live heap=100MB → GC при 300MB (×3)

Трейдофф: GOGC↓ = чаще GC, меньше памяти, больше CPU. GOGC↑ = реже GC, больше памяти, меньше CPU.

Проблема ручного подбора: значение зависит от нагрузки. Сегодня 50, завтра нужно 80, послезавтра 30. Вечная ручная подстройка.

Пример OOM: VM с лимитом 10GB, GOGC=100. После GC heap = 5.1GB. Следующий GC при 5.1 × 2 = 10.2GB > лимит VM → OOM-killer убьёт процесс до срабатывания GC.

Ballast-хак

go
var ballast = make([]byte, 2<<30) // 2GB

Аллокация большого массива для поднятия порога GC. Работает из-за lazy allocation ОС: пока не пишем в память, RSS не растёт (только VSS). С появлением GOMEMLIMIT в 99% случаев не нужен.

GOMEMLIMIT (Go 1.19) — soft memory limit

GOMEMLIMIT (Go 1.19) — soft memory limit

Учитывает всю память (heap + runtime metadata), не только кучу. НЕ включает: memory-mapped files, cgo memory.

Runtime динамически крутит GOGC при приближении к лимиту.

Защита от death spiral: CPU на GC ≤ 50%. Если не справляется — позволяет превысить лимит (soft, не hard). Иначе deadlock.

text
# Рекомендация в контейнерах GOMEMLIMIT = container_limit * 0.9 # Обычный режим GOGC=100 GOMEMLIMIT=2GiB # Максимум памяти, минимум GC GOGC=off GOMEMLIMIT=4GiB → GC только при приближении к лимиту # Агрессивный GC GOGC=50 GOMEMLIMIT=512MiB

Программно:

go
debug.SetGCPercent(50) // GOGC=50 debug.SetGCPercent(-1) // выключить GC debug.SetMemoryLimit(1 << 30) // 1GiB

Death spiral

text
live heap ≈ лимит → GC постоянно работает → CPU 100% на GC (но ≤50% с GOMEMLIMIT) → программа не обрабатывает запросы → запросы копятся → нужно ещё больше памяти → ...

Сложнее всего обнаружить: приложение работает, CPU загружен, но throughput нулевой.

С GOGC легко попасть при ручном подборе. С GOMEMLIMIT — защита через ограничение 50% CPU.


  • Pacer: завершить GC цикл ДО исчерпания heap target. Если запустить слишком поздно — heap вырастет больше цели.
  • формула (Go 1.18+): Target = Live heap + (Live heap + stacks + globals) × GOGC/100
  • Go 1.18: добавили стеки и globals в формулу. До этого маленький heap + большие стеки = GC запускался слишком редко
  • принудительный GC: если не было GC > 2 минут (sysmon), или runtime.GC()
  • runtime.GC(): если вызвать во время активного GC, по достижению фазы Sweep он запустится заново

GC Pacer — алгоритм выбора момента запуска GC.

Задача: завершить GC цикл ДО исчерпания heap target. Если запустить слишком поздно — heap вырастет больше цели.

Формула heap goal (Go 1.18+):

text
Target = Live heap + (Live heap + stacks + globals) × GOGC/100

Изменения в Go 1.18: учитывает стеки и globals (раньше только heap).

Лучше работает с маленькими heap — раньше маленький heap + большие стеки = GC запускался слишком редко.

Принудительный GC: если не было GC > 2 минут — запускает sysmon (компонент runtime).

runtime.GC(): если вызвать во время активного GC, по достижению фазы Sweep он запустится заново.


  • проблема: горутина аллоцирует быстрее, чем GC маркирует → GC не успевает
  • решение: Pacer заставляет «виновную» горутину тратить часть времени на маркировку
  • CPU GC: базово 25%, с Mark Assist до ~30%
  • runtime/trace покажет «mark assist» время для горутин

Mark Assist — принудительная помощь GC от горутин.

Проблема: горутина аллоцирует быстрее, чем GC успевает маркировать. Фаза Mark может не завершиться вовремя.

Решение: Pacer заставляет «виновную» горутину помогать маркировке.

Время помощи пропорционально объёму аллокаций горутины — кто больше мусорит, тот больше убирает.

CPU на GC:

Базово GC использует ~25% CPU (жёстко зафиксировано).

С Mark Assist — до ~30%: некоторые горутины выполняют код маркировки вместо бизнес-логики.

Концептуально: потоки ОС могут выполнять как код мутатора (бизнес-логика), так и код сборки мусора. Mark Assist переключает горутину на код GC.

Диагностика: runtime/trace покажет «mark assist» время — видно, какие горутины сколько времени потратили на помощь GC.


  • идея: большинство объектов умирают молодыми → сканируем молодых чаще, старых — редко
  • пережил сборку → перемещается в старшее поколение. Старшие поколения сканируются реже
  • в Go нет поколений: escape analysis кладёт короткоживущие на стек ≈ «молодое поколение», стек GC не трогает, а вот в Java почти все на хипе

Идея

Разбить кучу на поколения по возрасту объектов. Большинство объектов удаляются молодыми (как в естественном отборе). Те, кто пережил сборку мусора, вероятно переживут и следующую.

text
Поколение 0 (young): частые сборки, маленький объём Поколение 1 (old): редкие сборки, большой объём Поколение 2 (oldest): очень редко, только при нехватке памяти

Как работает

text
Создали X1..X8 → все в поколении 0 GC поколения 0 → X1, X3, X4, X8 живые → переносим в поколение 1 Создали Y1..Y8 → в поколении 0 GC поколения 0 → Y3, Y8 живые → переносим в поколение 1 ... Когда памяти не хватает → GC поколения 1 (заглянуть к «старикам»)

Идея: если не критическое потребление памяти, проходим GC только по молодым. Если 10 раз прошли молодых и толком не очистили — заглядываем к старшим.

Почему в Go нет поколений

Go через escape analysis старается класть максимум на стек. Короткоживущие объекты (обработка запроса → больше не нужны) с большой вероятностью окажутся на стеке, а не в хипе.

Стек ≈ «молодое поколение»: объекты создаются и умирают при выходе из функции, GC их не трогает.

В Java почти всё в хипе (new → heap), поэтому поколения критически важны. В Go хип уже «отфильтрован» escape analysis.


  • callback перед очисткой объекта GC. Минимум 2 цикла GC для освобождения (1-й: объект unreachable → финалайзер в очередь, объект НЕ удаляется. 2-й: объект удаляется)
  • два кейса: 1) страховка закрытия дескрипторов (os.File уже имеет финалайзер), 2) освобождение CGO-памяти (вместо ручного C.free())
  • для стековых объектов не работает (GC не трогает стек, нужен escape на heap)
  • runtime.KeepAlive(obj) — гарантирует что объект жив до этой точки (защита от преждевременного финалайзера)
  • Go 1.24+: runtime.AddCleanup — замена SetFinalizer с меньшим количеством проблем

Функция, вызываемая когда объект становится unreachable. Использовать осторожно.

Базовое использование:

go
obj := &MyResource{fd: openFile()} runtime.SetFinalizer(obj, func(o *MyResource) { o.Close() })

Как работает:

text
1. GC находит unreachable объект с finalizer 2. Объект НЕ удаляется, finalizer ставится в очередь 3. Отдельная горутина выполняет finalizer 4. Следующий GC удаляет объект └── минимум 2 цикла GC для освобождения

Два основных кейса:

1. Страховка закрытия дескрипторов. os.File в Go уже имеет runtime.SetFinalizer внутри — если забыли Close(), финалайзер закроет файл. То же для connection, handler и любых ресурсов ОС.

2. Освобождение CGO-памяти. Если через CGO аллоцируете память в C — поставьте финалайзер вместо того, чтобы просить программиста не забыть C.free(). Go — язык с автоматической сборкой мусора, CGO-память тоже должна очищаться автоматически.

Стековые объекты: GC не трогает стек → финалайзер скорее всего не сработает для объектов, которые не escape на хип.

Проблемы: непредсказуемое время выполнения, не гарантировано при завершении программы, объект живёт +1 GC цикл, циклические ссылки с finalizer → утечка, порядок не определён.

runtime.KeepAlive:

go
func process(f *File) { fd := f.fd // после этой строки f больше не используется напрямую // GC может решить что f unreachable → запустить финалайзер → закрыть файл // ... долгая работа с fd ... syscall.Read(fd, buf) // финалайзер может закрыть файл ДО этого → ошибка! runtime.KeepAlive(f) // говорит компилятору: f жив до этой точки, не давай GC забрать }

Когда использовать:

text
✗ Закрытие файлов → лучше defer Close() (финалайзер = страховка) ✗ Освобождение Go-памяти → GC сам ✓ Страховка для CGO ресурсов ✓ Отладка утечек

Очистка и новый API:

go
runtime.SetFinalizer(obj, nil) // убрать finalizer // Go 1.24+: runtime.AddCleanup — меньше проблем runtime.AddCleanup(&obj, func(ptr *int) { /* cleanup */ }, &arg)