- 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 → объект можно освободить.
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.
Циклические ссылки (главная проблема):
Двусвязный список:
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)
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+): чёрный МОЖЕТ указывать на белый, но белый должен быть достижим через серый
Алгоритм
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):
A.field = C // записали новый указатель A → C
// новый ребёнок C → красится серым
Появился новый ребёнок → красим его серым, чтобы GC не пропустил.
Минус insertion barrier: стек не покрыт → в Mark Termination нужен полный rescan стеков (долгий STW).
Deletion barrier (Yuasa):
// было: A.field = B (B — ребёнок A)
A.field = C // перезаписали: B отвязан от A
// старый ребёнок B → красится серым
Ребёнок потерял родителя → красим его серым, чтобы GC не потерял B и его потомков.
Минус deletion barrier: мусор живёт +1 цикл GC (старый ребёнок серый → не удалится в этом цикле).
Hybrid (Go 1.8+):
// было: 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 — частота сборки
Процент роста 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-хак
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.
# Рекомендация в контейнерах
GOMEMLIMIT = container_limit * 0.9
# Обычный режим
GOGC=100 GOMEMLIMIT=2GiB
# Максимум памяти, минимум GC
GOGC=off GOMEMLIMIT=4GiB → GC только при приближении к лимиту
# Агрессивный GC
GOGC=50 GOMEMLIMIT=512MiB
Программно:
debug.SetGCPercent(50) // GOGC=50
debug.SetGCPercent(-1) // выключить GC
debug.SetMemoryLimit(1 << 30) // 1GiB
Death spiral
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+):
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 почти все на хипе
Идея
Разбить кучу на поколения по возрасту объектов. Большинство объектов удаляются молодыми (как в естественном отборе). Те, кто пережил сборку мусора, вероятно переживут и следующую.
Поколение 0 (young): частые сборки, маленький объём
Поколение 1 (old): редкие сборки, большой объём
Поколение 2 (oldest): очень редко, только при нехватке памяти
Как работает
Создали 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. Использовать осторожно.
Базовое использование:
obj := &MyResource{fd: openFile()}
runtime.SetFinalizer(obj, func(o *MyResource) {
o.Close()
})
Как работает:
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:
func process(f *File) {
fd := f.fd
// после этой строки f больше не используется напрямую
// GC может решить что f unreachable → запустить финалайзер → закрыть файл
// ... долгая работа с fd ...
syscall.Read(fd, buf) // финалайзер может закрыть файл ДО этого → ошибка!
runtime.KeepAlive(f) // говорит компилятору: f жив до этой точки, не давай GC забрать
}
Когда использовать:
✗ Закрытие файлов → лучше defer Close() (финалайзер = страховка)
✗ Освобождение Go-памяти → GC сам
✓ Страховка для CGO ресурсов
✓ Отладка утечек
Очистка и новый API:
runtime.SetFinalizer(obj, nil) // убрать finalizer
// Go 1.24+: runtime.AddCleanup — меньше проблем
runtime.AddCleanup(&obj, func(ptr *int) { /* cleanup */ }, &arg)