3.2Сборщик мусора
Проследим достижимость объектов и фазы GC, затем свяжем GOGC и GOMEMLIMIT с целью ограничивать ресурсы, а не с фиксированным временем сборки.
Зачем сборщик мусора и почему нельзя просто «остановиться и посчитать»
Задача сборщика умещается в одну строку: найти в куче память, до которой программа уже никогда не дотянется, и вернуть её в оборот. Трудно другое: сделать это, не останавливая программу.
Альтернатив ровно две, и обе Go отверг. Первая - освобождать вручную, как в C:
каждый malloc обязан иметь парный free. Работает, но человек ошибается
двумя способами, и оба тяжёлые. Забыл освободить - утечка памяти. Освободил дважды или освободил
то, на что ещё смотрит чей-то указатель, получил use-after-free, один из главных источников уязвимостей
за последние тридцать лет. Вторая - считать ссылки на каждый объект, как в Python или Swift:
счётчик упал до нуля, объект освобождается сразу. Проще, но платит на каждом присваивании
указателя, требует атомарных операций в многопоточной программе (а Go весь построен на горутинах) и в принципе
не умеет собирать циклы: два объекта, ссылающиеся друг на друга, никогда не обнулят счётчики.
Go выбрал третий путь: трассирующий сборщик. Он не ведёт учёт постоянно, а время от времени обходит граф ссылок от корней (глобальных переменных и стеков живых горутин) и смотрит, до чего вообще можно дойти. До чего дошёл, то и живое, а всё остальное мусор. Циклы такой подход собирает сам собой: недостижимый цикл просто не встречается при обходе.
Почему нельзя просто остановить всё и посчитать
Технически можно, и первые сборщики так и работали: остановил мир, обошёл весь граф, подмёл, отпустил. Проблема в цене. Время обхода пропорционально объёму живых данных: на кучу в 10 ГБ с десятками миллионов объектов уходят сотни миллисекунд, иногда секунды. Для сервиса с SLA «ответ за 50 мс» пауза в 300 мс означает отказ, и чем успешнее сервис (больше данных в памяти), тем пауза хуже. Поэтому Go пошёл на сделку: метить одновременно с работающей программой, оставив под STW только два коротких служебных момента, длина которых от размера кучи не зависит вообще. Платой за сделку и стала вся остальная сложность: сборщик обходит граф, который мутатор всё время переделывает у него под руками, отсюда цвета, инварианты, барьер записи.
Пока сборщик работал под STW, инварианты были не нужны: граф не менялся, обход был очевидно корректен. Как только маркировка пошла параллельно с программой, потребовалось правило вида «вот такого состояния не бывает никогда». Без него не доказать, что живой объект не примут по дороге за мусор. Это правило и есть инвариант сборщика, а код, который его насильно удерживает, называется барьером.
Какой именно сборщик стоит в Go
Одной фразой: конкурентный (concurrent), неперемещающий (non-moving), трёхцветный mark & sweep с write barrier и без поколений. На собесе про каждое слово спрашивают отдельно.
| Свойство | Что значит | Следствие |
|---|---|---|
| Конкурентный | метит одновременно с работающим приложением | паузы - доли миллисекунды, но GC отъедает ~25% CPU во время цикла |
| Параллельный | маркировкой заняты несколько воркеров сразу | цикл масштабируется по ядрам |
| Неперемещающий | объекты в куче никогда не меняют адрес | указатели стабильны, cgo и unsafe.Pointer работают; но куча фрагментируется, и её нельзя «уплотнить» |
| Mark & sweep | обход графа живых, затем возврат мёртвых в спаны | стоимость маркировки пропорциональна живым объектам, а не мусору |
| Без поколений | нет отдельной «молодой» кучи | роль young generation играет стек: escape analysis отправляет туда почти весь короткоживущий мусор |
| С барьером записи | компилятор вставляет хук на запись указателя | это цена корректности конкурентной маркировки |
Гипотеза поколений («большинство объектов умирает молодыми») в Go выполняется, но обслуживается не сборщиком, а компилятором: короткоживущие значения escape analysis оставляет на стеке, и они умирают бесплатно, вообще не попадая в кучу. Вдобавок поколенческий GC почти всегда перемещающий, а перемещение ломает совместимость с cgo и стабильность адресов. К 2018 году команда попробовала non-moving generational GC и отказалась: выигрыш не окупал цены барьера, который пришлось бы держать включённым всегда. Так глубоко на собесе обычно не копают.
Трёхцветная маркировка: зачем нужны именно три цвета
Возьмём бытовую картинку, её хватит надолго. Пусть куча будет большим зданием, объекты комнатами, а указатели дверями между комнатами. Входов с улицы несколько, и это корни: глобальные переменные и стеки живых горутин. Обходчик начинает от входов и метит комнаты мелом:
- Белая комната - до неё ещё не дошли.
- Серая - в неё зашли и пометили, но ещё не проверили, куда ведут двери из неё.
- Чёрная - зашли и все двери из неё уже проверили; возвращаться сюда незачем.
Обход кончается, когда серых комнат не осталось: значит, всё, до чего есть путь от входов, уже почернело. В оставшиеся белые комнаты с улицы не попасть. Их можно сносить.
Почему три, а не два («посетили / не посетили»). Потому что обход надо уметь прерывать и продолжать. Серые объекты и составляют список незавершённых дел: сборщик работает порциями, между порциями крутится приложение, и без отдельного цвета «нашли, но не дочитали» после перерыва пришлось бы начинать сначала. А главное, правило корректности, ради которого всё затевалось, формулируется через пару «чёрный–белый».
В двух местах. Первое: обходчик по зданию не ходит, а просто читает карту указателей и перескакивает по адресам. Второе, и куда важнее: в настоящей переписи жильцы сидят смирно, а здесь они всё время переносят двери, программа-то работает параллельно. Из-за одного этого отличия и понадобился барьер записи.
Физически цвет не поле в объекте, отдельного байта под него
нет. Есть биты маркировки в метаданных спана (один бит на объект) и очередь работы
(work queue, распределённая по P в виде gcWork с локальными буферами).
Цвет складывается из двух признаков:
- Белый - бит маркировки не стоит, объекта нет в очереди. Кандидат в мусор.
- Серый - бит стоит, но объект лежит в очереди: его нашли, а поля ещё не просмотрели.
- Чёрный - бит стоит, из очереди объект уже вынули и просканировали его поля.
Алгоритм: покрасить корни (стеки всех горутин, глобальные переменные, регистры) в серый; пока очередь не пуста, доставать объект, красить его в чёрный, а все его белые «дети» красить в серый и класть в очередь. Когда очередь опустела, всё белое считается мусором.
Сильный трёхцветный инвариант: ни один чёрный объект не должен ссылаться на белый. У него есть ослабленная версия, слабый инвариант: чёрный может ссылаться на белый, но только если до этого белого остаётся путь от какого-нибудь серого. Эти два правила и охраняют барьеры записи, а за точной формулировкой обычно охотится интервьюер.
Откуда такое правило, видно по аналогии со зданием: из чёрной комнаты обходчик уже ушёл и больше не вернётся. Если из неё внезапно появилась дверь в белую комнату, эту белую никто уже не пометит. Она так и останется белой и пойдёт под снос вместе с ведущей в неё дверью. Слабый инвариант такую дверь терпит, потому что оставляет запасной вход: пока до белой комнаты можно дойти от какой-нибудь серой (то есть от той, чьи двери ещё будут проверены), она будет найдена.
Объект теряется, когда одновременно случились две вещи: (1) ссылку на белый объект записали в уже чёрный и (2) удалили ту ссылку, по которой сборщик собирался до него дойти. Без барьера сборщик до белого объекта не доберётся, посчитает мусором и подметёт, а приложение потом прочитает освобождённую память.
Барьер записи (write barrier): два классических и гибридный из Go 1.8
Барьер записи - это код, который компилятор вставляет перед каждой записью указателя
в кучу на время фазы маркировки. Слово «барьер» здесь в смысле «контрольно-пропускной
пункт», а не «преграда»: запись не запрещается, но не проходит незамеченной. Строчка
obj.field = ptr в исходнике на время цикла GC превращается в «сообщи сборщику,
что тут сейчас произойдёт, и только потом записывай». Так барьер и удерживает инвариант,
пока программа перевешивает указатели у сборщика за спиной.
Барьер не бесплатен: он проверяет глобальный флаг writeBarrier.enabled и,
если флаг взведён, пишет пару указателей в буфер P. Вне цикла GC флаг сброшен,
и остаётся одна предсказуемая ветка, которую предсказатель ветвлений процессора берёт почти даром.
Про это любят спрашивать отдельно: барьер стоит только на записи указателей,
и только тех, что идут в кучу или в глобальные переменные. Запись int или запись в локальную переменную на
стеке обходится без барьера, иначе каждое присваивание в программе стало бы дороже.
| Барьер | Что делает | Что охраняет | Цена |
|---|---|---|---|
| Dijkstra (insertion) | при slot = ptr красит новое значение ptr в серый | сильный инвариант | стеки в конце надо пересканировать под STW |
| Yuasa (deletion) | при slot = ptr красит старое значение *slot в серый | слабый инвариант («snapshot at the beginning») | консервативнее: спасает то, что уже умерло |
| Гибридный, Go 1.8+ | красит в серый и старое, и новое значение | слабый инвариант без пересканирования стеков | две записи в буфер вместо одной |
// Псевдокод гибридного барьера (runtime/mbarrier.go, hybrid Dijkstra-Yuasa)
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(*slot) // Yuasa: спасаем то, на что ссылались раньше
if current_stack_is_grey() { // так в предложении; в реализации условия нет - красятся оба
shade(ptr) // Dijkstra: спасаем то, что записываем
}
*slot = ptr
}
До 1.8 стоял чистый барьер Dijkstra, и он охранял только записи в кучу, а записи в стек не барьерились. Из-за этого чёрный стек мог получить ссылку на белый объект незаметно для сборщика, и в конце цикла приходилось останавливать мир и пересканировать все стеки. Пауза росла вместе с числом горутин: 100 мс на большом сервисе были нормой. Гибридный барьер добавил Yuasa-часть, «снимок на начало цикла»: если ссылка была видна в момент старта, объект выживет в этом цикле независимо от того, куда её потом перевесили. Это позволило сканировать каждый стек ровно один раз и навсегда красить его в чёрный. Поэтому mark termination стал занимать десятки микросекунд и перестал зависеть от размера кучи и числа горутин.
Read barrier нужен перемещающим сборщикам (Java ZGC/Shenandoah): через него читатель узнаёт,
что объект уехал. Go неперемещающий, поэтому чтение указателя бесплатно.
По той же причине в Go нет уплотнения кучи, а unsafe.Pointer
вообще может существовать как рабочий инструмент.
Фазы цикла и где именно останавливается мир
В цикле GC четыре фазы. Две из них короткие и идут под stop-the-world, две другие работают
параллельно с приложением. Сам STW делает stopTheWorld: рантайм
выставляет флаг преемптивности, и каждая горутина останавливается в ближайшей безопасной
точке (с Go 1.14 преемпция асинхронная, по сигналу, поэтому цикл без вызовов функций
больше не подвешивает сборщик).
- Sweep termination (STW). Домести хвост спанов с прошлого цикла, включить write barrier, перевести мир в режим маркировки. Десятки микросекунд.
- Mark (конкурентно). Сканируются корни (глобальные переменные, стеки горутин,
каждый ровно один раз), затем обходится граф. Работают dedicated воркеры
(25% от
GOMAXPROCS) и fractional воркер, добирающий дробную часть. Есть ещё mark assist: горутина, аллоцирующая быстрее, чем сборщик успевает метить, сама делает пропорциональную часть маркировки прямо вmallocgc. - Mark termination (STW). Убедиться, что очередь пуста, выключить write barrier, посчитать цель следующего цикла (pacer). Тоже десятки микросекунд.
- Sweep (конкурентно и лениво). Спаны метутся не разом, а фоновой горутиной и по требованию: когда
mcacheпросит новый спан, тот подметается прямо перед выдачей. Поэтому «уборка» размазана по времени и почти не видна в профиле как отдельный пик.
«GC в Go останавливает мир на время sweep» - нет: sweep конкурентный и ленивый. «Пауза зависит от размера кучи» - с Go 1.8 нет. «Пауз три» - нет, две. И ещё: latency у сборщика отличная, но throughput он забирает. Если сервис тормозит во время GC, чаще виноваты не паузы, а mark assist и потерянные 25% CPU. Это разные проблемы, и лечат их по-разному.
Green Tea: как переделали маркировку в Go 1.26
Раз больше всего стоит маркировка, её и надо ускорять в первую очередь. Этим
и занялись в Green Tea GC. В Go 1.25 он был экспериментом
(GOEXPERIMENT=greenteagc), а с Go 1.26 включён по умолчанию. Отключить,
если очень надо, можно через GOEXPERIMENT=nogreenteagc, но задать её придётся
при сборке программы: переменной окружения на запуске тут не обойтись.
В чём была проблема. Классическая маркировка ходит по графу по одному объекту: достала указатель из очереди, прыгнула по нему, прочитала поля, положила детей в очередь, прыгнула дальше. С точки зрения алгоритма всё честно, а для железа хуже сценария не придумать: случайные прыжки по памяти. Указатели ведут куда угодно, предсказать следующий адрес процессор не может, поэтому почти каждый шаг обхода оборачивается промахом кэша и походом в оперативную память на ~80 нс. Разрыв между скоростью процессора и скоростью памяти за двадцать лет только рос, так что сборщик всё это время заметную часть маркировки (по замерам команды Go - от 35 %) просто ждал память, а не считал.
Что поменяли. Green Tea меняет единицу работы для мелких объектов (до 512 байт): в очереди лежат не отдельные объекты, а спаны, непрерывные блоки памяти одного размерного класса (те самые, из главы 3.1). Нашли живой объект - помечаем бит и ставим в очередь весь спан, в котором он лежит. Когда до спана доходит очередь, сборщик сканирует сразу все накопившиеся в нём помеченные объекты, за один заход.
- Доступ становится последовательным. Объекты внутри спана лежат подряд, значит сканирование идёт линейно, как процессор и любит: работает предвыборка, одна кэш-линия закрывает несколько объектов сразу.
- Работа накапливается. Пока спан ждёт в очереди, в нём успевают пометиться другие объекты. За один визит сборщик обрабатывает сразу несколько, а не прыгает в ту же область памяти по разу на каждый.
- Метаданные читаются один раз на спан, а не на каждый объект: биты маркировки лежат рядом и берутся пачкой.
Что это дало. −10…40 % накладных расходов GC на реальных нагрузках; разброс большой, потому что выигрыш зависит от того, насколько плотно живые объекты лежат в спанах: у сервиса с большим графом мелких структур эффект максимальный. Работа к тому же стала однородной и пачечной, и к ней получилось применить векторные инструкции SIMD. На свежих amd64 (Intel Ice Lake и новее, AMD Zen 4 и новее) они дают ещё примерно 10 % сверху.
«В Green Tea переписали фазу маркировки, с Go 1.26 он включён по умолчанию. Идея в том, что старый обход графа шёл по одному объекту и упирался не в вычисления, а в промахи кэша: указатели ведут в случайные места памяти. Green Tea сделал единицей работы спан, а не объект, и сканирование стало последовательным и дружественным к кэшу, а заодно поддалось векторизации. В цифрах: минус 10–40 % накладных расходов GC и ещё около 10 % на свежих amd64. Алгоритм остался прежним: трёхцветный mark & sweep, инвариант и барьер записи не изменились, поменялся порядок обхода.» Последняя фраза показывает, что ты видишь, где проходит граница изменения.
Когда стартует цикл: pacer, GOGC, GOMEMLIMIT
Цикл запускается в трёх случаях:
- По достижении цели кучи. Основной путь.
GOGCзадаёт, на сколько процентов куче разрешено вырасти относительно живых данных прошлого цикла (с Go 1.18 к ним прибавляются стеки и глобальные переменные):target = live + (live + стеки + глобальные) × GOGC/100. ПриGOGC=100(по умолчанию) цикл стартует, когда куча удвоилась. Следит за этим pacer: цели он буквально не дожидается, а стартует заранее, оценивая скорость аллокации, чтобы маркировка успела закончиться к моменту, когда куча дорастёт до цели. - Принудительно, вызовом
runtime.GC(): он блокирует вызывающую горутину до конца полного цикла (и, если предыдущий цикл ещё идёт, сначала дожидается его). - По таймеру:
sysmonфорсирует цикл, если GC не запускался 2 минуты (forcegcperiod). Так простаивающий сервис всё-таки возвращает память ОС и не держит мусор бесконечно.
GOMEMLIMIT (Go 1.19) добавляет второй, независимый триггер: мягкий лимит
на общий объём памяти рантайма. В него входят куча, стеки горутин, метаданные GC и
внутренние структуры. Когда суммарный объём подбирается к лимиту, pacer запускает циклы
чаще, вплоть до непрерывной работы, чтобы удержаться под ним. «Мягкий» означает, что рантайм
не убьёт программу и не откажет в аллокации: если живых данных больше лимита, он просто
будет молотить GC, а память всё равно вырастет.
GOGC задаёт высоту
зуба в процентах от живых данных, GOMEMLIMIT ставит абсолютный потолок
и ценой лишних циклов не пускает пилу выше.
«GOGC работает как ручка обмена памяти на CPU. Увеличил до 400 - циклов вчетверо меньше,
CPU на GC падает, пиковая память растёт примерно в 2.5 раза. Уменьшил до 50 - наоборот.
Цель считается от живых данных, а не от текущей кучи: если live set
маленький (скажем, 20 МБ), GOGC=100 даст цикл каждые 20 МБ аллокаций,
и сервис, который аллоцирует гигабайт в секунду, получит полсотни циклов в секунду.
Поэтому таким сервисам помогает связка «поднять GOGC + поставить GOMEMLIMIT».»
# лимит пода 1Gi. Запас - на то, чего GOMEMLIMIT не видит: бинарник, cgo, прочая не-Go память.
GOMEMLIMIT=800MiB
GOGC=off # или большое значение, если боишься полной остановки GC
GOMAXPROCS=2 # до Go 1.25 (или при go ниже 1.25 в go.mod) выставляй вручную по CPU limit пода
Смысл: пока памяти вдоволь, GC не мешает; когда куча подбирается к 800 МБ, лимит
включает сборщик и держит потолок. Этот паттерн разобран в официальном руководстве по GC
(go.dev/doc/gc-guide). Про GOMAXPROCS: с Go 1.25 рантайм наконец учитывает
CPU-лимит cgroup сам, если директива go в go.mod не ниже 1.25; до этого без automaxprocs Go видел все ядра ноды
и создавал лишние P, что било и по планировщику, и по числу воркеров GC.
Что реально нагружает сборщик
Маркировка стоит пропорционально числу указателей в живых объектах, а не
объёму памяти. Гигабайт []byte сборщик проходит мгновенно (спан помечен
noscan, сканировать там нечего), а сто мегабайт map[string]*Item
на миллион записей будут обходиться каждый цикл.
// грузит GC
type Cache struct {
items map[string]*Item // млн указателей
}
// рост слайса без cap: O(log n) перевыделений,
// каждое плодит объект и мусор
var out []Row
for _, r := range rows { out = append(out, r) }
// на каждой итерации новая строка в куче
for _, id := range ids {
key := fmt.Sprintf("user:%d", id)
_ = store[key]
}
// интерфейс боксит значение
var vals []any
for i := 0; i < n; i++ { vals = append(vals, i) }
// бережёт GC
type Cache struct {
items map[string]Item // значения: миллионы отдельных *Item исчезают (noscan мешают ключи-строки)
idx map[string]int32 // или индекс в плотный слайс
}
out := make([]Row, 0, len(rows))
for _, r := range rows { out = append(out, r) }
var b []byte
for _, id := range ids {
b = append(b[:0], "user:"...)
b = strconv.AppendInt(b, id, 10)
_ = store[string(b)] // конверсия в ключ мапы не аллоцирует
}
vals := make([]int, 0, n) // конкретный тип, боксинга нет
- Меньше указателей.
[]Itemвместо[]*Item,[16]byteвместоstringдля UUID,int32-индексы вместо ссылок. Структура без указателей внутри попадает в noscan-спан и для сборщика становится невидимой. - Преаллокация.
make([]T, 0, n),make(map[K]V, n)убирают цепочку перевыделений и связанный с ней мусор. - Переиспользование.
buf = buf[:0]внутри цикла,sync.Poolдля крупных короткоживущих буферов,bytes.Buffer/strings.Builderвместо конкатенации. - Значения вместо указателей в горячих контейнерах дают и меньше объектов, и лучшую локальность кэша.
- Арены и батчинг: один большой слайс и индексы в него вместо миллиона мелких объектов. Это тот же приём, что в ECS и в колоночных движках.
fmt.Sprintfв горячем цикле: и боксинг аргументов черезany, и рефлексия, и новая строка на каждый вызов.appendв слайс безcapпри известной длине.- Дерево из мелких объектов с указателями (связный список,
*node) там, где хватило бы плоского слайса. - Логирование на «горячем» уровне с интерфейсными аргументами без проверки
Enabled(). deferв цикле: и отложенное освобождение, и рост списка defer-записей.- Мапа со строковыми ключами, которые собираются на каждый запрос заново.
Мониторинг GC
Три источника данных, от самого дешёвого к самому подробному:
# 1. Не трогая код: по строке на каждый цикл GC в stderr
# Настоящий вывод go1.27 (GOMAXPROCS=8, программа льёт мусор в цикле), четыре подряд:
$ GODEBUG=gctrace=1 ./service
gc 40 @0.164s 4%: 0.010+2.7+0.010 ms clock, 0.086+0.33/4.8/8.3+0.080 ms cpu, 30->31->18 MB, 34 MB goal, 0 MB stacks, 0 MB globals, 8 P
gc 41 @0.175s 4%: 0.009+1.5+0.010 ms clock, 0.077+0.13/2.6/6.4+0.086 ms cpu, 32->33->18 MB, 37 MB goal, 0 MB stacks, 0 MB globals, 8 P
gc 42 @0.184s 4%: 0.011+1.8+0.019 ms clock, 0.095+0.13/3.2/7.3+0.15 ms cpu, 32->33->19 MB, 37 MB goal, 0 MB stacks, 0 MB globals, 8 P
gc 43 @0.195s 4%: 0.013+1.9+0.027 ms clock, 0.10+0.012/3.3/7.8+0.22 ms cpu, 33->35->20 MB, 39 MB goal, 0 MB stacks, 0 MB globals, 8 P
# читается так:
# gc 42 - номер цикла
# @0.184s - время от старта программы
# 4% - доля CPU, съеденная сборщиком с момента старта
# 0.011+1.8+0.019 ms clock - STW1 + конкурентная маркировка + STW2
# 32->33->19 MB - куча в начале цикла -> в конце маркировки -> живые данные
# 37 MB goal - цель, посчитанная pacer-ом
# 8 P - GOMAXPROCS
// 2. runtime/metrics: стабильный и работает без stop-the-world
import "runtime/metrics"
samples := []metrics.Sample{
{Name: "/gc/heap/live:bytes"}, // живые данные после последнего цикла
{Name: "/gc/heap/goal:bytes"}, // цель pacer-а
{Name: "/gc/cycles/total:gc-cycles"}, // сколько циклов прошло
{Name: "/sched/pauses/total/gc:seconds"}, // гистограмма пауз GC
{Name: "/memory/classes/total:bytes"}, // вся память рантайма; за вычетом heap/released её и ограничивает GOMEMLIMIT
{Name: "/sched/goroutines:goroutines"}, // ловит утечку горутин
}
metrics.Read(samples)
Третий источник, runtime.ReadMemStats, старше остальных. Данные в нём те же,
только в плоском виде, но он останавливает мир на время снятия. В горячем цикле экспорта метрик
его лучше не звать чаще раза в 10–15 секунд, а новым сервисам стоит сразу брать
runtime/metrics.
Пила - это норма. Проблему выдают не зубцы, а поведение нижней огибающей:
если минимум после каждого цикла ползёт вверх, растёт live set, и это утечка.
Плюс три сигнала: доля CPU на GC выше 10–15% (сервис аллоцирует слишком много),
частота циклов в секунду (если их слишком много, мал live set при большом трафике аллокаций)
и /sched/pauses/total/gc в хвосте (при p99 паузы выше миллисекунды ищи
длинные безопасные точки или слишком большое число горутин).
Утечка памяти в языке со сборщиком мусора
Сборщик освобождает недостижимые объекты. Утечкой в Go всегда оказывается достижимый мусор: данные, которые логически не нужны, но на них остаётся ссылка от корня. GC тут бессилен по определению: намерений он читать не умеет.
| Причина | Как выглядит | Лечение |
|---|---|---|
| Глобальная мапа/кэш без вытеснения | var cache = map[string]*Obj{} и только запись | TTL, LRU, ограничение размера; и помнить: delete уменьшает len, но не память мапы |
| Утечка горутин | горутина навсегда встала на канале или select без ctx.Done() | context, таймауты, закрытие каналов; мониторить /sched/goroutines, а с Go 1.27 снимать профиль goroutineleak (глава 3.3) |
| Подслайс держит большой массив | small := big[:10] - жив весь big | slices.Clone или явное копирование |
| Подстрока | срез большой строки держит её целиком | strings.Clone (Go 1.18+) |
Забытый time.Ticker | до Go 1.23 тикер без Stop() не собирался никогда; теперь его соберут, когда пропадут ссылки | defer t.Stop() |
Не закрытые resp.Body | соединение, горутины транспорта и буферы висят, GC их не освободит | defer resp.Body.Close() + вычитать тело |
| «Хвост» слайса после удаления элемента | s = s[:len(s)-1] оставляет указатель в массиве | обнулить последний элемент перед обрезкой |
| Замыкание, захватившее лишнее | колбэк в долгоживущей структуре тянет весь запрос | захватывать только нужные поля |
// Классика: 10-байтовый подслайс держит 10 МБ
func head(path string) []byte {
data, _ := os.ReadFile(path) // 10 МБ
return data[:10] // весь массив остаётся живым
}
func headFixed(path string) []byte {
data, _ := os.ReadFile(path)
return append([]byte(nil), data[:10]...) // с копией 10 МБ освободятся
}
// Классика: указатели в хвосте слайса
func pop(s []*Item) []*Item {
s[len(s)-1] = nil // обязательно, иначе объект достижим из массива
return s[:len(s)-1]
}
Финалайзеры и runtime.AddCleanup
runtime.SetFinalizer(obj, fn) регистрирует функцию, которую рантайм вызовет
когда-нибудь после того, как объект станет недостижимым. Проблем столько, что в
проде это фактически запрещённый приём:
- Нет гарантии вызова вообще. При выходе из программы финалайзеры не запускаются.
- Нет гарантии времени. Между «объект умер» и «финалайзер вызван» может пройти сколько угодно циклов.
- Объект живёт минимум на цикл дольше. Финалайзер воскрешает объект: сначала цикл обнаруживает недостижимость, ставит его в очередь (объект снова достижим из очереди), и только следующий цикл его действительно освободит.
- Циклы не собираются. Если два объекта с финалайзерами ссылаются друг на друга, не освободится ни один.
- Финалайзер держит объект. Замыкание, захватившее сам
obj, делает его бессмертным, и это типовой баг. - Выполняется в отдельной горутине, паника в нём валит процесс.
// Go 1.24: замена SetFinalizer, без воскрешения и без утечек на циклах
type File struct {
fd int
cleanup runtime.Cleanup
}
func Open(name string) (*File, error) {
fd, err := syscall.Open(name, 0, 0)
if err != nil { return nil, err }
f := &File{fd: fd}
// cleanup получает копию нужных данных, а не сам объект,
// поэтому не держит f и не откладывает его освобождение
f.cleanup = runtime.AddCleanup(f, func(fd int) { syscall.Close(fd) }, fd)
return f, nil
}
// но правильный API всё равно даёт явный Close(), и он снимает страховку:
// иначе cleanup позже закроет номер дескриптора, который уже достался другому файлу
func (f *File) Close() error { f.cleanup.Stop(); return syscall.Close(f.fd) }
runtime.AddCleanupвыполняется параллельно. Раньше cleanup-ы разбирала та же одна горутина, что и финалайзеры, строго по очереди, и один медленный обработчик подвешивал все остальные. Теперь cleanup-ы раскладываются по нескольким горутинам, и очередь перестала быть узким местом. Финалайзеры по-прежнему идут в одной горутине. Для программы, которая вешает cleanup на каждый объект (буферы, дескрипторы, cgo-память), разница принципиальная.GODEBUG=checkfinalizers=1включает режим диагностики. Рантайм на каждом цикле печатает размер очереди и, главное, ловит классические ошибки: замыкание финалайзера, захватившее сам объект (тот самый «бессмертный объект»), и cleanup, привязанный к объекту, до которого можно дойти из самого cleanup-а. Найдя такое, рантайм показывает, где финалайзер создан, и валит программу фатальной ошибкой. Выглядит так:
Держать включённым в проде не надо (лишние проверки под stop-the-world на каждом цикле и падение процесса на первой находке), но интеграционные тесты с этим флагом прогнать стоит: так дёшево находится утечка, которая иначе живёт месяцами.$ GODEBUG=checkfinalizers=1 ./service checkfinalizers: queue: 0 finalizers + 0 cleanups WARNING: LIKELY CLEANUP/FINALIZER ISSUES Value of type main.File at 0x5468152c00e0 is reachable from finalizer Has finalizer at 0x10046de00 main.Open.func1() service/main.go:18 +0x0 created at: main.Open() service/main.go:19 +0xac fatal error: detected possible issues with cleanups and/or finalizers
Финалайзер и AddCleanup нужны как страховка от чужой забывчивости, а не как
механизм освобождения ресурсов. Так их и применяют в стандартной библиотеке:
os.File закрывает дескриптор, если пользователь забыл Close().
Детерминированное освобождение в Go делается через Close() + defer,
и никак иначе. На собесе так и говори: «единственный корректный сценарий - подстраховка
и диагностика утечек, всё остальное - явный Close».
Вопросы
11Цвета
- Белый - бит маркировки не стоит, объекта нет в очереди: кандидат в мусор.
- Серый - бит стоит, объект в очереди работы: нашли, но поля ещё не прочитали.
- Чёрный - бит стоит, поля просканированы, из очереди вынут.
Сам цвет не поле объекта. Физически есть биты маркировки в
метаданных спана (gcmarkBits) и распределённая очередь работы: у каждого
P свой буфер gcWork, при переполнении он отдаётся в глобальную
очередь, откуда его крадут другие воркеры. За счёт этого маркировка и масштабируется по ядрам.
Ход цикла
- Корни (глобальные переменные, стеки всех горутин, регистры) красятся в серый.
- Пока очередь не пуста: достать объект, просканировать его поля по карте указателей типа, все белые дети - в серый и в очередь, сам объект - чёрный.
- Очередь пуста → всё белое недостижимо → sweep возвращает эти блоки в спаны.
Что назвать, чтобы ответ был сильным
- Неперемещающий: адреса стабильны, барьера чтения нет, cgo и
unsafe.Pointerработают; платить приходится фрагментацией и невозможностью уплотнения. - Без поколений: роль young generation исполняет стек, куда escape analysis отправляет почти весь короткоживущий мусор, и там он умирает бесплатно.
- Стоимость пропорциональна живым данным, а не мусору: гигабайт
[]byte(noscan-спан) сборщик не сканирует вообще, аmap[string]*Itemна 10 млн записей обходится каждый цикл. - Цель - latency, а не throughput. Go сознательно отдал пропускную способность ради коротких пауз: 25% CPU во время цикла и есть эта плата, заложенная в дизайн.
- Green Tea - маркировка по умолчанию с Go 1.26. Экспериментом был в 1.25
(
GOEXPERIMENT=greenteagc), теперь включён сам; выключаетсяGOEXPERIMENT=nogreenteagc. Суть: для мелких объектов (до 512 байт) единицей работы стал спан, а не отдельный объект. Старый обход прыгал по указателям в случайные места памяти и упирался не в вычисления, а в промахи кэша; Green Tea ставит в очередь спан целиком и сканирует все накопившиеся в нём помеченные объекты разом. Доступ становится последовательным, работает предвыборка, метаданные читаются пачкой. Итог: −10…40 % накладных расходов GC, плюс ещё около 10 % на свежих amd64 (Intel Ice Lake и новее, AMD Zen 4 и новее) за счёт векторных инструкций. Сам алгоритм не изменился: те же три цвета, тот же инвариант, тот же барьер записи, поменялся только порядок обхода.
«Sweep под STW?» - нет, конкурентный и ленивый: спан метёт фоновая горутина или
аллокация в момент выдачи в mcache. «GC перемещает объекты?» - нет.
«Сколько поколений?» - ни одного.
«Куда девается память после sweep?» - сначала обратно в спаны рантайма, ОС она
возвращается отдельно и постепенно (MADV_FREE/MADV_DONTNEED),
поэтому RSS падает не сразу. На этом ловят при разборе «памяти в дашборде».
Какую гонку он ловит
Беда случается, когда совпадают два события: ссылку на белый объект записали в уже чёрный, и одновременно удалили ту ссылку, по которой сборщик до белого собирался дойти. Чёрный объект повторно не сканируется, значит, белый останется белым, будет подметён, и приложение получит указатель в освобождённую память. Барьер перехватывает саму запись и красит нужный объект в серый.
Три варианта
| Что красит | Инвариант | Цена | |
|---|---|---|---|
| Dijkstra, insertion | новое значение ptr | сильный: чёрный никогда не смотрит на белый | нужен STW-пересканинг стеков в конце |
| Yuasa, deletion | старое значение *slot | слабый: снимок графа на начало цикла | консервативен - сохраняет уже умершее |
| Гибрид Go 1.8+ | и старое, и новое | слабый, без пересканирования стеков | две записи в буфер вместо одной |
// то, что компилятор подставляет вместо obj.field = ptr во время маркировки
func gcWriteBarrier(slot *unsafe.Pointer, ptr unsafe.Pointer) {
if writeBarrier.enabled { // вне цикла GC это одна предсказуемая ветка
shade(*slot) // Yuasa: спасаем старую цель
if stackIsGrey(getg()) { // упрощение: рантайм кладёт в буфер оба указателя всегда
shade(ptr) // Dijkstra: спасаем новую цель
}
}
*slot = ptr
}
Ключевое следствие гибрида (то, ради чего его сделали)
Записи в стек барьером не покрываются, иначе каждое присваивание локальной переменной стало бы дорогим. При чистом Dijkstra это означало, что стеки нельзя считать окончательно чёрными, и в конце цикла их пересканировали под stop-the-world: пауза росла с числом горутин и доходила до сотен миллисекунд. Yuasa-часть даёт «снимок на начало цикла»: всё, что было достижимо в момент старта, переживает этот цикл. Поэтому стек сканируется один раз и красится в чёрный навсегда, а mark termination стал занимать десятки микросекунд и перестал зависеть от размера кучи.
Барьер стоит единицы наносекунд на запись указателя, и платишь их только во время маркировки; вне цикла остаётся проверка глобального флага, которую предсказатель ветвлений берёт почти бесплатно. И полезная добивка: read barrier в Go нет вообще. Он нужен перемещающим сборщикам (ZGC, Shenandoah), чтобы читатель узнал о переезде объекта. Go неперемещающий, поэтому чтение указателя ничего не стоит.
- Sweep termination (STW). Домести спаны, оставшиеся от прошлого цикла (sweep ленивый,
хвост мог не разобраться), включить write barrier, перевести все
Pв режим маркировки. - Mark (конкурентно). Сканируются корни: глобальные переменные и стек каждой горутины
(ровно один раз за цикл; горутина при этом коротко приостанавливается, но общего STW нет).
Затем обход графа. Работают dedicated воркеры (25% от
GOMAXPROCS), fractional-воркер на дробную часть и mark assist у аллоцирующих горутин. - Mark termination (STW). Проверить, что очередь работы пуста, выключить write barrier, посчитать цель следующего цикла, сбросить статистику.
- Sweep (конкурентно и лениво). Спаны подметает фоновая горутина, а недометённый спан
подметается в момент, когда
mcacheпросит новый. Значит, стоимость размазана по времени и аллокациям и отдельного пика в профиле не даёт.
Как физически реализуется STW
stopTheWorld выставляет у каждого P флаг преемпции и ждёт, пока все
горутины дойдут до безопасной точки. До Go 1.14 такой точкой был только вызов функции, поэтому
«горячий цикл без вызовов» мог задержать весь STW на неопределённое время. Отсюда знаменитый баг с
зависанием на for {}. С 1.14 работает асинхронная преемпция: рантайм шлёт
потоку сигнал SIGURG и останавливает горутину практически где угодно.
У Go сборщик оптимизирован по latency, а не по throughput. Если сервис проседает во
время GC, паузы почти наверняка ни при чём: смотри на mark assist (горутина аллоцирует
быстрее, чем сборщик метит, и вынуждена метить сама; снаружи это выглядит как рост латентности
конкретных запросов) и на потерянные 25% CPU. Лечат их по-разному: assist убирают, снижая
аллокации, а потерю CPU снимают повышением GOGC. В ответе эти две вещи
стоит развести.
target = live + (live + стеки + глобальные) × GOGC/100,
отслеживает pacer), явный runtime.GC(), и форс от sysmon, если циклов не
было 2 минуты. С Go 1.19 добавился четвёртый - приближение к GOMEMLIMIT.1. Цель кучи и pacer
GOGC задаёт процент прироста относительно живых данных прошлого цикла
(с Go 1.18 к ним прибавляются стеки и глобальные переменные).
При GOGC=100 и live set 100 МБ следующий цикл целится примерно в 200 МБ.
Но pacer не ждёт достижения цели буквально: он оценивает скорость аллокации и скорость
маркировки и стартует заранее, чтобы маркировка успела закончиться ровно к тому
моменту, как куча дорастёт до цели. Если pacer ошибся и приложение аллоцирует быстрее,
включается mark assist, который тормозит аллокаторов и не даёт куче перелететь цель.
// живых данных 100 МБ, стеки и глобальные ничтожны:
GOGC=100 -> цель 200 МБ // по умолчанию
GOGC=50 -> цель 150 МБ // циклов больше, память ниже, CPU выше
GOGC=400 -> цель 500 МБ // циклов меньше, память выше, CPU ниже
GOGC=off -> цели нет // GC не запускается по росту кучи
debug.SetGCPercent(200) // то же самое из кода, возвращает старое значение
2. Принудительно
runtime.GC() блокирует вызывающую горутину до завершения полного цикла;
если цикл уже идёт, сначала дожидается его, потом запускает свой. Звать его законно
перед снятием heap-профиля (чтобы inuse отражал реально живое), в тестах
на утечки, перед долгой паузой в работе демона. В обычном коде такой вызов почти всегда ошибка.
Есть ещё debug.FreeOSMemory(): он делает runtime.GC() и вдобавок агрессивно
возвращает страницы ОС.
3. По таймеру
Системный монитор sysmon форсирует цикл, если с последнего прошло больше
2 минут (константа forcegcperiod в runtime/proc.go).
Простаивающий сервис не аллоцирует, цель кучи не достигается никогда, и без
таймера он бесконечно держал бы мусор и не отдавал память ОС.
Цель считается от живых данных, поэтому у сервиса с крошечным live set
(скажем, 4 МБ) и высоким темпом аллокаций GOGC=100 означает цикл каждые
4 МБ, сотни циклов в секунду и заметную долю CPU на GC при почти пустой памяти.
Симптом в gctrace: очень частые циклы и высокий процент CPU. Лечится это так:
поднять GOGC и поставить GOMEMLIMIT как реальный потолок,
либо debug.SetMemoryLimit + GOGC=off. Пример «взрослый»,
его редко приводят.
GOMEMLIMIT (Go 1.19) - мягкий лимит на всю память
рантайма. По мере приближения к нему pacer запускает GC всё чаще, вплоть до непрерывной работы.
Решает главную боль контейнеров: GOGC задаёт относительный порог и ничего
не знает про абсолютный лимит пода.Проблема, которую он закрыл
Под с лимитом 1 ГБ. Живых данных обычно 300 МБ, GOGC=100 → цель 600 МБ, всё хорошо.
Приходит всплеск трафика, live set подскакивает до 600 МБ → цель 1.2 ГБ → OOM-kill до того,
как сборщик успел что-то предпринять. До 1.19 выкручивались через ballast:
выделить make([]byte, 1<<30), никогда его не трогать (страницы не коммитятся,
RSS не растёт) и тем самым искусственно задрать базу для расчёта цели. Костыль работал,
но был хрупким и непонятным. GOMEMLIMIT сделал это штатной ручкой.
Что именно ограничивается
В лимит входит больше, чем куча: куча + стеки горутин + метаданные GC + внутренние структуры
рантайма, то есть всё, что в runtime/metrics называется /memory/classes/total:bytes,
минус то, что уже возвращено ОС. Вне лимита остаётся память, о которой рантайм не знает:
cgo, mmap руками, память драйверов. Поэтому лимит ставят с запасом.
# лимит пода 1Gi -> оставляем 20% запаса
GOMEMLIMIT=800MiB
GOGC=off # GC срабатывает только у потолка: максимум throughput, минимум циклов
GOMAXPROCS=2 # до Go 1.25 (или при go ниже 1.25 в go.mod) - automaxprocs или вручную
import "runtime/debug"
func main() {
// то же из кода, в байтах; лимит cgroup лежит в /sys/fs/cgroup/memory.max
debug.SetMemoryLimit(800 << 20)
debug.SetGCPercent(-1) // GOGC=off
}
Сценарии, где он реально нужен
- Контейнер с жёстким лимитом, основной случай: OOM-kill превращается в замедление.
- Сервис с большим кэшем в памяти: кэш растёт, но не должен утащить под за границу.
- Batch/CLI-задачи:
GOGC=off+ лимит = максимум скорости при известном потолке. - Сервис с маленьким live set и высоким темпом аллокаций:
GOGCпобольше, лимит как страховка.
Лимит мягкий: если живых данных стало больше лимита, рантайм не упадёт и не откажет
в аллокации, а начнёт запускать GC почти непрерывно. Весь CPU сборщик не съест: с Go 1.19
лимитер ограничивает его примерно половиной CPU и дальше пускает память за лимит. Но сервис заметно
замедляется, оставаясь формально живым, и это хуже быстрого OOM-kill, потому что health-check
может не заметить. А если память дорастёт до лимита пода, OOM-kill всё равно придёт.
Защищаются так: runtime/debug.SetMemoryLimit вместе с разумным
GOGC (не off), мониторинг доли CPU на GC и лимит с запасом 15–25%
от реального лимита пода. Кто назовёт death spiral на собесе, тот явно это
включал в проде, а не читал в релиз-ноутах.
GOGC=off или debug.SetGCPercent(-1):
сборщик перестаёт запускаться по росту кучи. Но это не «выключить совсем» -
runtime.GC() и GOMEMLIMIT продолжают работать; молчит только форс
по таймеру. «Поставить на паузу»
отдельной кнопкой нельзя.debug.SetGCPercent(-1) // выключить, как GOGC=off
old := debug.SetGCPercent(400) // изменить, вернуть предыдущее
runtime.GC() // синхронно прогнать полный цикл
debug.FreeOSMemory() // цикл + агрессивно вернуть страницы ОС
debug.SetMemoryLimit(n) // потолок; работает даже при GOGC=off
Когда выключение оправдано
- Короткоживущие CLI и batch-задачи: процесс живёт минуту, память заведомо влезает. Зачем платить за сборку того, что ОС всё равно освободит при выходе?
- Связка
GOGC=off+GOMEMLIMIT, паттерн из официального руководства по GC (go.dev/doc/gc-guide): GC не мешает, пока памяти вдоволь, и включается ровно у потолка. - Латентно-критичные окна (торговые системы): выключить на время сессии,
прогнать
runtime.GC()в паузе. - Бенчмарки и профилирование: так из измерений уходит шум сборщика.
Когда оправдана настройка GOGC (без выключения)
| Симптом | Куда крутить | Что получишь |
|---|---|---|
Доля CPU на GC 15%+ в gctrace, памяти вдоволь | GOGC вверх (200–500) | меньше циклов, ниже CPU, выше пик памяти |
| Много циклов в секунду при крошечном live set | GOGC вверх + GOMEMLIMIT | убирает «холостую молотилку» |
| Память впритык, CPU есть | GOGC вниз (50) | ниже пик, дороже по CPU |
| Периодические OOM-kill при всплесках | GOMEMLIMIT, не GOGC | абсолютный потолок вместо относительного |
Крутить GOGC - последнее средство, а не первое. Сначала: снять
heap-профиль, убрать лишние аллокации, преаллоцировать, убрать лишние указатели. Настройка
GOGC мусора не уменьшает, а лишь даёт ему дольше копиться. Если после ручки
сервис «стал быстрее», а аллокации остались, значит, ты купил latency за память, и при следующем
всплеске трафика получишь OOM. Поэтому любую такую настройку обязательно сопровождай
GOMEMLIMIT.
1. Меньше объектов
- Преаллокация:
make([]T, 0, n)убирает цепочку перевыделений; каждое из них порождает новый объект и отправляет старый в мусор.make(map[K]V, n)делает то же для мап и избавляет от серии рехэшей. - Переиспользование буфера:
buf = buf[:0]сохраняет ёмкость и обнуляет длину, так что после прогрева аллокаций нет. - Батчинг: один слайс на N элементов вместо N отдельных объектов.
2. Меньше указателей
Спан, в котором лежат объекты без указателей, помечен как noscan, и сборщик
в него не заглядывает. Отсюда:
// GC обходит 10 млн объектов каждый цикл
items := make([]*Item, 10_000_000)
// GC не сканирует ничего, если в Item нет указателей
items := make([]Item, 10_000_000)
// UUID как строка = указатель + длина, объект в куче
type Row struct {
ID string
}
// UUID как массив = 16 байт внутри структуры, noscan
type Row struct {
ID [16]byte
}
// ссылки заменяем индексами в плотный слайс
type Node struct {
Left, Right int32
}
nodes := make([]Node, n)
3. sync.Pool - и его правила
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func handle(w http.ResponseWriter, r *http.Request) {
b := bufPool.Get().(*bytes.Buffer)
b.Reset() // обязательно: в пуле лежит грязный объект
defer bufPool.Put(b)
// ... пишем в b, отдаём ответ ...
}
- Пул не ограничивает число объектов и не служит кэшем: содержимое чистится каждый цикл GC (точнее, victim cache отдаёт объектам ещё один цикл жизни).
- Кладя объект обратно, обнуляй его, иначе данные утекут между запросами (реальный класс уязвимостей), а память останется занятой.
- Не клади в пул слайсы разного размера: один жирный буфер будет держаться вечно. Обычно в пул просто не возвращают то, что выросло больше порога.
- Пул выигрывает только на крупных объектах:
Get/Putсами по себе стоят около десятка наносекунд, для мелочи это дороже аллокации. - Значение из пула нельзя использовать после
Put, но на этом регулярно попадаются.
Что грузит GC
| Паттерн | Почему дорого | Замена |
|---|---|---|
fmt.Sprintf в горячем цикле | боксинг через any, рефлексия, новая строка | strconv.Append*, strings.Builder |
append без cap | O(log n) перевыделений и столько же мусорных массивов | make([]T, 0, n) |
[]*T на миллионы элементов | миллион объектов для обхода каждый цикл | []T или индексы |
Связные списки, деревья из *node | максимум указателей на единицу данных | плоские массивы, арены |
Строка ↔ []byte туда-сюда | копия на каждой конверсии | работать в одном представлении; string(b) как ключ мапы оптимизируется компилятором |
Логирование с any-аргументами | боксинг даже если уровень выключен | проверка Enabled(), типизированные атрибуты slog |
| Мапа с ключами, собираемыми на лету | строка в куче на каждый lookup | переиспользуемый []byte + string(b) в индексе |
defer внутри цикла | рост списка defer и отложенное освобождение | вынести тело в функцию |
«Я смотрю не на B/op, а на allocs/op: сборщик платит за объекты,
а не за байты. И не на скорость аллокации, а на то, сколько указателей будет в живых
данных: маркировка стоит пропорционально им. Гигабайтный []byte для GC
бесплатен, а map[string]*Item на 10 миллионов записей обходится дороже всего,
что можно положить в кучу.»
GODEBUG=gctrace=1 для быстрого взгляда,
runtime/metrics для постоянного экспорта, runtime.ReadMemStats как
легаси (он делает STW). Пила на графике - норма; тревожит не она, а ползущая вверх
нижняя огибающая.gctrace - когда надо посмотреть здесь и сейчас
$ GODEBUG=gctrace=1 ./service 2>&1 | grep '^gc '
gc 43 @0.912s 1%: 0.028+1.6+0.013 ms clock, ..., 107->108->54 MB, 110 MB goal, 0 MB stacks, 0 MB globals, 8 P
# | | | | | | | | |
# | | | | STW2 | | live цель pacer-а
# | | | конкурентная маркировка | куча в конце
# | | STW1 куча в начале цикла
# | доля CPU на GC с момента старта
# время от старта
# полезные соседи:
$ GODEBUG=gctrace=1,scavtrace=1 ./service # ещё и возврат памяти ОС
$ GODEBUG=inittrace=1 ./service # долгие init(), частая причина медленного старта
Что экспортировать в Prometheus
samples := []metrics.Sample{
{Name: "/gc/heap/live:bytes"}, // живые данные, главный признак утечки
{Name: "/gc/heap/goal:bytes"}, // цель pacer-а
{Name: "/gc/heap/allocs:bytes"}, // сколько всего нааллоцировано (счётчик)
{Name: "/gc/cycles/total:gc-cycles"}, // частота циклов
{Name: "/sched/pauses/total/gc:seconds"}, // гистограмма пауз, смотреть p99
{Name: "/cpu/classes/gc/total:cpu-seconds"}, // доля CPU на GC
{Name: "/memory/classes/total:bytes"}, // то, что ограничивает GOMEMLIMIT
{Name: "/sched/goroutines:goroutines"}, // ловит утечку горутин
}
metrics.Read(samples)
Если сервис уже отдаёт promhttp, коллекторы collectors.NewGoCollector
с WithGoCollectorRuntimeMetrics подтягивают это без ручного кода.
Как читать пилу
- Зубцы сами по себе нормальны: вершина = момент старта цикла, спад = освобождение. Отсутствие зубцов при активном трафике подозрительно сильнее, чем их наличие.
- Нижняя огибающая ползёт вверх, значит, растёт live set. Это либо утечка, либо прогрев кэша (у кэша рост выходит на плато, а у утечки нет).
- Зубцы стали чаще и мельче: то ли куча подошла к
GOMEMLIMIT, то ли упал live set. - RSS не падает вслед за heap, и это не утечка: рантайм отдаёт страницы ОС лениво,
фоновым scavenger-ом, поэтому RSS отстаёт от кучи. На Linux Go по умолчанию отдаёт их
через
MADV_DONTNEED, и тогда они из RSS уходят; задержка тут не от разметки, а от неспешности возврата.MADV_FREE(«можешь забрать, если понадобится», RSS остаётся высоким до нехватки памяти) включается только черезGODEBUG=madvdontneed=0. Смотреть надо на/gc/heap/live, а не на RSS контейнера.
runtime.ReadMemStats останавливает мир на время копирования структуры. На
сервисе с тысячами горутин и экспортом раз в секунду это заметная доля пауз, причём
их создаёт сам мониторинг. runtime/metrics (Go 1.16+) читает те же данные
без STW и с версионированными именами метрик. Для новых сервисов выбор однозначен.
Корни, из которых тянутся ссылки
Глобальные переменные пакетов, стеки всех живых горутин, значения в sync.Pool,
объекты, зарегистрированные в рантайме (таймеры, тикеры, финалайзеры), а также всё, что
достижимо из этого транзитивно. Утечка = что-то из этого списка держит ссылку слишком долго.
Типовые причины
- Глобальный кэш/мапа без вытеснения. Только пишем, никогда не удаляем. Есть и отдельный
нюанс:
deleteиз мапы память не возвращает, и мапа, разросшаяся до миллиона записей, после очистки держит таблицу прежнего размера. Вернуть память можно только одним способом: создать новую мапу. - Утечка горутин в проде встречается чаще всех. Горутина, навсегда вставшая на отправке в
небуферизованный канал или на
selectбезctx.Done(), держит свой стек (минимум 2 КБ, часто больше) и всё, до чего дотягивается из него. Тысяча зависших горутин с телом запроса внутри съедает сотни мегабайт. - Подслайс держит весь массив.
small := big[:10]-capостался отbig, значит жив весь исходный массив. - Подстрока держит исходную строку по той же механике.
- «Хвост» слайса после удаления.
s = s[:len(s)-1]оставляет указатель в подложке массива: элемент недоступен, но достижим. - Забытые
time.Ticker/time.AfterFunc. Тикер безStop()до Go 1.23 жил в рантайме вечно. С 1.23 недостижимые таймеры иTickerсобираются и безStop, а вотAfterFuncдо срабатывания по-прежнему держит замыкание со всем захваченным. - Не закрытый
http.Response.Bodyдержит соединение, буферы чтения и мешает переиспользованию keep-alive. - Замыкание, захватившее лишнее. Колбэк, положенный в долгоживущую структуру, тянет за собой весь контекст запроса.
- Растущий контекст: цепочка
context.WithValueв долгоживущем контексте.
// 1. Классическая утечка горутины: писатель без читателя
func leak() {
ch := make(chan int) // небуферизованный
go func() { ch <- 42 }() // навсегда: читателя не будет
// забыли прочитать -> горутина, её стек и всё захваченное живут вечно
}
// 2. Подслайс
data, _ := os.ReadFile("10mb.bin")
head := data[:16] // жив весь 10 МБ массив
head = append([]byte(nil), data[:16]...) // а так - только 16 байт
// 3. Хвост слайса
func remove(s []*Conn, i int) []*Conn {
copy(s[i:], s[i+1:])
s[len(s)-1] = nil // без этой строки последний Conn недостижим, но жив
return s[:len(s)-1]
}
// 4. Мапа не отдаёт память
delete(cache, k) // память под таблицу остаётся
cache = make(map[string]V, 0) // единственный способ реально освободить
Три разных диагноза с похожим графиком. Кэш: /gc/heap/live растёт и
выходит на плато. Утечка: растёт линейно и не останавливается, обычно
пропорционально числу обработанных запросов. Память не отдана ОС: live стабилен,
а RSS высокий, потому что scavenger не спешит; это не утечка. Проверка
занимает минуту: снять /debug/pprof/heap дважды с интервалом и сравнить
inuse_space.
-base, смотреть на inuse_space (что живо сейчас), а не на
alloc_space (что аллоцировалось за всё время). Параллельно всегда проверять
/debug/pprof/goroutine - половина «утечек памяти» - это утечка горутин.Четыре режима heap-профиля
| Флаг | Показывает | Для чего |
|---|---|---|
-inuse_space | байты, живые на момент снятия | утечки - основной режим |
-inuse_objects | число живых объектов | много мелочи vs мало крупного |
-alloc_space | байты за всё время жизни процесса | нагрузка на GC, кто мусорит |
-alloc_objects | число аллокаций за всё время | горячие точки для оптимизации |
Разница принципиальная: функция, которая создала терабайт короткоживущих буферов, будет
топ-1 в alloc_space и вообще не появится в inuse_space. И наоборот,
утечка на 500 МБ может быть незаметна в alloc, если копится медленно.
Ищешь утечку → inuse. Ищешь нагрузку на GC → alloc.
Пошагово
# 0. Профиль снимаем после полного цикла GC,
# иначе в inuse попадёт ещё не собранный мусор.
$ curl -s 'localhost:6060/debug/pprof/heap?gc=1' > h1.pprof
$ sleep 600
$ curl -s 'localhost:6060/debug/pprof/heap?gc=1' > h2.pprof
# 1. Разница снапшотов показывает, что выросло за 10 минут
$ go tool pprof -base h1.pprof h2.pprof
(pprof) top20 # кто вырос
(pprof) list ^myapp\.Cache # построчно внутри подозреваемого
(pprof) traces # полные стеки аллокаций
(pprof) peek NewBuffer # кто вызывает
# 2. Веб-интерфейс с флеймграфом и графом вызовов
$ go tool pprof -http=:8080 -base h1.pprof h2.pprof
# 3. Параллельно всегда проверяй горутины
$ curl -s 'localhost:6060/debug/pprof/goroutine?debug=1' | head -40
$ go tool pprof -http=:8081 'localhost:6060/debug/pprof/goroutine'
Как читать результат
- flat - сколько аллоцировала сама функция, cum - она и всё, что она вызвала.
Утечку ищут по
cumсверху вниз, конкретную строку - поflat. list ФункцияNameпоказывает исходник с байтами напротив каждой строки, и так быстрее всего находится конкретныйappendилиSprintf.- Если в топе
runtime.malgилиruntime.newproc1, вопрос не к тебе, а к числу горутин. - Стеки с
chan receive/chan sendи большим временем вgoroutine?debug=2прямо указывают на зависшую горутину.
Heap-профиль сэмплированный: по умолчанию runtime.MemProfileRate = 512 КБ,
то есть учитывается примерно одна аллокация на каждые 512 КБ, а числа экстраполируются. Поэтому
абсолютные байты в профиле приблизительны, а редкие крупные аллокации могут не попасть.
Для точной охоты можно поставить runtime.MemProfileRate = 1 в самом начале
main: профилироваться будет всё, но программа заметно замедлится. По этой детали
и отличают того, кто читал про pprof, от того, кто им пользовался.
runtime.SetFinalizer не даёт гарантий ни того, что
функция вызовется, ни когда. Это страховка от чужой забывчивости и инструмент диагностики,
а не механизм освобождения ресурсов. Освобождение в Go делается через Close()
и defer.Шесть причин не полагаться
- Вызова может не быть вообще. При завершении программы финалайзеры не запускаются: если объект дожил до выхода, функция не выполнится.
- Время не определено. Между смертью объекта и вызовом может пройти сколько угодно циклов GC. Для файловых дескрипторов и соединений это означает исчерпание лимита ОС задолго до срабатывания.
- Объект живёт минимум на цикл дольше. Первый цикл обнаруживает недостижимость и ставит объект в очередь финалайзеров, откуда тот снова становится достижимым (воскрешение). Освободит его только следующий цикл.
- Циклы не собираются. Если объекты с финалайзерами ссылаются друг на друга, не освободится ни один и не вызовется ни один финалайзер.
- Легко случайно сделать объект бессмертным. Замыкание, захватившее сам
obj, держит ссылку на него от корня очереди финалайзеров, и объект не умрёт никогда. Классический баг. - Выполняется в отдельной горутине и последовательно для всех объектов: медленный финалайзер блокирует остальные, а паника в нём валит процесс.
// так нельзя: объект никогда не умрёт
runtime.SetFinalizer(f, func(x *File) {
log.Printf("closing %v", f) // замыкание захватило f, ссылка вечная
syscall.Close(f.fd)
})
// Go 1.24: заменяем на AddCleanup
runtime.AddCleanup(f, func(fd int) { syscall.Close(fd) }, f.fd)
Чем runtime.AddCleanup (Go 1.24) лучше
- Функция получает отдельный аргумент, а не сам объект, поэтому держать объект ей незачем. Но если замыкание или аргумент всё же ведут к объекту, хотя бы через третью структуру, он не умрёт никогда: паникой рантайм ловит только простые случаи, когда аргумент равен объекту или замыкание захватило его напрямую.
- Нет воскрешения: объект освобождается в том же цикле, cleanup выполняется после.
- Циклические структуры собираются нормально.
- На один объект можно повесить несколько cleanup-ов (
SetFinalizerразрешает строго один). - Работает с внутренними указателями объекта.
- С Go 1.25 очередь cleanup-ов разбирается параллельно, несколькими горутинами: раньше всё делала одна, и один медленный обработчик подвешивал остальные.
SetFinalizerформально не удалён, но в новом коде считается устаревшим.
Режим ловит те самые два бага, о которых шла речь выше: замыкание финалайзера,
захватившее сам объект, и cleanup, из которого достижим объект-владелец. Рантайм печатает
размер очереди на каждом цикле и выдаёт WARNING: LIKELY CLEANUP/FINALIZER ISSUES
с типом и стеком регистрации, после чего роняет программу фатальной ошибкой. В проде его
держать не надо (лишние проверки под stop-the-world на каждом цикле GC и падение процесса
на первой находке), а вот интеграционные тесты с этим флагом прогнать стоит: находится то, что
иначе живёт в коде годами. Если упомянешь этот флаг на собесе, ответ сразу прозвучит на другом уровне.
Где они всё-таки уместны
- Подстраховка в библиотеке: так
os.Fileзакрывает дескриптор, если пользователь забылClose(). Именно подстраховка, а не основной путь. - Освобождение памяти, выделенной вне Go (cgo,
mmap): GC про неё не знает. - Диагностика: повесить cleanup, который пишет в лог «объект X собран без Close». Так в тестах и находят утечки ресурсов.
«Финалайзеры в Go не деструкторы. Деструктор в C++ вызывается детерминированно
в конце области видимости, а финалайзер - когда-нибудь и не обязательно. Аналог RAII
в Go один: defer x.Close(). Финалайзер оправдан только
как страховка библиотеки от забытого Close, для не-Go памяти и для диагностики.
С Go 1.24 вместо SetFinalizer пишут runtime.AddCleanup:
он не воскрешает объект, и сделать объект бессмертным по ошибке с ним куда труднее.»
Что стоит запомнить
- GC собирает недостижимые объекты.
- Корни и ссылки определяют достижимость.
- GOGC и GOMEMLIMIT задают политику сборки в связи с объёмом живой памяти и лимитом.
Частые ошибки
- Ручной вызов GC не освобождает достижимые объекты.
- Частота сборки зависит от нагрузки и версии runtime.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Разбери результат
После присваивания p в глобальную переменную и вызова GC значение остаётся доступным. Почему GC не удаляет его?
type Node struct { Value int }
var keep *Node
func main() { keep = &Node{Value: 8}; runtime.GC(); fmt.Println(keep.Value) }
Ответ и разбор
Печатается 8: глобальный keep остаётся достижимым корнем, поэтому Node не мусор. GC освобождает недостижимые объекты, а не объекты, которые «давно не использовались».
Код-ревью
2. Проверь вывод и исправь
Heap profile показывает рост inuse_space; разработчик запускает GC вручную в каждом запросе, ожидая освободить память. Что сначала нужно проверить?
func handle() { runtime.GC() }
Ответ и разбор
Сними heap profile с правильным sample type и найди удерживающие ссылки/пути достижимости. Ручной GC не освободит объекты, достижимые из глобальных кэшей, очередей или долгоживущих структур; сначала проверь владельца и срок жизни ссылок. Сопоставь in-use после GC с allocation profile, чтобы не путать накопленные выделения с удержанной памятью.
Применение
3. Реши сценарий
В контейнере с лимитом памяти GC часто запускается и растёт CPU. Как отличить слишком частый цикл GC от настоящего удержания памяти?
Ответ и разбор
Сними heap profile для retained/in-use и alloc_space, посмотри runtime GC metrics и сравни live heap после сборки. Высокий alloc_space при стабильном live heap указывает на интенсивные временные аллокации; растущий in-use требует искать удерживаемые ссылки. GOMEMLIMIT можно настроить по контейнерному бюджету, но он не устраняет утечку достижимых объектов.
Применение
4. Разбери другое ограничение
Предположим, после каждого GC live heap равен 200 MiB, roots и GOMEMLIMIT не влияют на цель. Какова приблизительная heap goal при GOGC=100 и GOGC=50? Что означает разница для CPU?
Ответ и разбор
Упрощённая цель роста heap примерно live × (1 + GOGC/100): около 400 MiB при GOGC=100 и 300 MiB при GOGC=50. Меньший GOGC обычно начинает следующий цикл раньше и может поднять долю CPU на сборку. Это оценка для заданных условий, не точный порог срабатывания: реальная цель зависит от roots, allocation и memory limit.