voidlearn
 

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 не сделал поколенческий сборщик

Гипотеза поколений («большинство объектов умирает молодыми») в Go выполняется, но обслуживается не сборщиком, а компилятором: короткоживущие значения escape analysis оставляет на стеке, и они умирают бесплатно, вообще не попадая в кучу. Вдобавок поколенческий GC почти всегда перемещающий, а перемещение ломает совместимость с cgo и стабильность адресов. К 2018 году команда попробовала non-moving generational GC и отказалась: выигрыш не окупал цены барьера, который пришлось бы держать включённым всегда. Так глубоко на собесе обычно не копают.

Трёхцветная маркировка: зачем нужны именно три цвета

Возьмём бытовую картинку, её хватит надолго. Пусть куча будет большим зданием, объекты комнатами, а указатели дверями между комнатами. Входов с улицы несколько, и это корни: глобальные переменные и стеки живых горутин. Обходчик начинает от входов и метит комнаты мелом:

  • Белая комната - до неё ещё не дошли.
  • Серая - в неё зашли и пометили, но ещё не проверили, куда ведут двери из неё.
  • Чёрная - зашли и все двери из неё уже проверили; возвращаться сюда незачем.

Обход кончается, когда серых комнат не осталось: значит, всё, до чего есть путь от входов, уже почернело. В оставшиеся белые комнаты с улицы не попасть. Их можно сносить.

Почему три, а не два («посетили / не посетили»). Потому что обход надо уметь прерывать и продолжать. Серые объекты и составляют список незавершённых дел: сборщик работает порциями, между порциями крутится приложение, и без отдельного цвета «нашли, но не дочитали» после перерыва пришлось бы начинать сначала. А главное, правило корректности, ради которого всё затевалось, формулируется через пару «чёрный–белый».

Где аналогия перестаёт работать

В двух местах. Первое: обходчик по зданию не ходит, а просто читает карту указателей и перескакивает по адресам. Второе, и куда важнее: в настоящей переписи жильцы сидят смирно, а здесь они всё время переносят двери, программа-то работает параллельно. Из-за одного этого отличия и понадобился барьер записи.

Физически цвет не поле в объекте, отдельного байта под него нет. Есть биты маркировки в метаданных спана (один бит на объект) и очередь работы (work queue, распределённая по P в виде gcWork с локальными буферами). Цвет складывается из двух признаков:

  • Белый - бит маркировки не стоит, объекта нет в очереди. Кандидат в мусор.
  • Серый - бит стоит, но объект лежит в очереди: его нашли, а поля ещё не просмотрели.
  • Чёрный - бит стоит, из очереди объект уже вынули и просканировали его поля.

Алгоритм: покрасить корни (стеки всех горутин, глобальные переменные, регистры) в серый; пока очередь не пуста, доставать объект, красить его в чёрный, а все его белые «дети» красить в серый и класть в очередь. Когда очередь опустела, всё белое считается мусором.

Инвариант, который надо назвать дословно

Сильный трёхцветный инвариант: ни один чёрный объект не должен ссылаться на белый. У него есть ослабленная версия, слабый инвариант: чёрный может ссылаться на белый, но только если до этого белого остаётся путь от какого-нибудь серого. Эти два правила и охраняют барьеры записи, а за точной формулировкой обычно охотится интервьюер.

Откуда такое правило, видно по аналогии со зданием: из чёрной комнаты обходчик уже ушёл и больше не вернётся. Если из неё внезапно появилась дверь в белую комнату, эту белую никто уже не пометит. Она так и останется белой и пойдёт под снос вместе с ведущей в неё дверью. Слабый инвариант такую дверь терпит, потому что оставляет запасной вход: пока до белой комнаты можно дойти от какой-нибудь серой (то есть от той, чьи двери ещё будут проверены), она будет найдена.

Объект теряется, когда одновременно случились две вещи: (1) ссылку на белый объект записали в уже чёрный и (2) удалили ту ссылку, по которой сборщик собирался до него дойти. Без барьера сборщик до белого объекта не доберётся, посчитает мусором и подметёт, а приложение потом прочитает освобождённую память.

белый - не видели серый - найден, поля не прочитаны чёрный - просканирован Нормальный ход маркировки 1. Корни красим в серый root A B очередь: [root] 2. Взяли root, покрасили детей root A B очередь: [A] 3. Очередь пуста - белых нет root A B очередь: [] - всё живое чёрное А теперь мутатор вмешивается - и без барьера объект теряется 1. root уже чёрный, A серый root A B B ждёт своей очереди через A 2. Приложение: root.p = B; A.p = nil root A ссылка снята B чёрный root теперь ссылается на белый B 3. Маркировка кончилась root A B B остался белым - sweep его освободит, а root на него всё ещё смотрит Барьер записи ломает этот сценарий: он перехватывает саму запись указателя и красит нужный объект в серый до того, как ссылка потеряется.
Почему нужен барьер. В верхнем ряду обход идёт без вмешательства. В нижнем гонка: чёрный получил ссылку на белого, а единственный «серый» путь к нему исчез. Эту пару событий барьер и обязан перехватить.

Барьер записи (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
}
Что дал гибридный барьер в Go 1.8

До 1.8 стоял чистый барьер Dijkstra, и он охранял только записи в кучу, а записи в стек не барьерились. Из-за этого чёрный стек мог получить ссылку на белый объект незаметно для сборщика, и в конце цикла приходилось останавливать мир и пересканировать все стеки. Пауза росла вместе с числом горутин: 100 мс на большом сервисе были нормой. Гибридный барьер добавил Yuasa-часть, «снимок на начало цикла»: если ссылка была видна в момент старта, объект выживет в этом цикле независимо от того, куда её потом перевесили. Это позволило сканировать каждый стек ровно один раз и навсегда красить его в чёрный. Поэтому mark termination стал занимать десятки микросекунд и перестал зависеть от размера кучи и числа горутин.

Чем добить: «барьера чтения в Go нет»

Read barrier нужен перемещающим сборщикам (Java ZGC/Shenandoah): через него читатель узнаёт, что объект уехал. Go неперемещающий, поэтому чтение указателя бесплатно. По той же причине в Go нет уплотнения кучи, а unsafe.Pointer вообще может существовать как рабочий инструмент.

Фазы цикла и где именно останавливается мир

В цикле GC четыре фазы. Две из них короткие и идут под stop-the-world, две другие работают параллельно с приложением. Сам STW делает stopTheWorld: рантайм выставляет флаг преемптивности, и каждая горутина останавливается в ближайшей безопасной точке (с Go 1.14 преемпция асинхронная, по сигналу, поэтому цикл без вызовов функций больше не подвешивает сборщик).

  1. Sweep termination (STW). Домести хвост спанов с прошлого цикла, включить write barrier, перевести мир в режим маркировки. Десятки микросекунд.
  2. Mark (конкурентно). Сканируются корни (глобальные переменные, стеки горутин, каждый ровно один раз), затем обходится граф. Работают dedicated воркеры (25% от GOMAXPROCS) и fractional воркер, добирающий дробную часть. Есть ещё mark assist: горутина, аллоцирующая быстрее, чем сборщик успевает метить, сама делает пропорциональную часть маркировки прямо в mallocgc.
  3. Mark termination (STW). Убедиться, что очередь пуста, выключить write barrier, посчитать цель следующего цикла (pacer). Тоже десятки микросекунд.
  4. Sweep (конкурентно и лениво). Спаны метутся не разом, а фоновой горутиной и по требованию: когда mcache просит новый спан, тот подметается прямо перед выдачей. Поэтому «уборка» размазана по времени и почти не видна в профиле как отдельный пик.
Один цикл GC на временной оси STW 1 STW 2 Concurrent Mark 25% CPU: dedicated + fractional воркеры + mark assist Concurrent Sweep лениво: спан метётся перед выдачей в mcache до старта STW 1 · sweep termination - домести хвост прошлого цикла, включить write barrier, перевести мир в режим маркировки. STW 2 · mark termination - очередь пуста, выключить write barrier, посчитать цель следующего цикла. Обе паузы - десятки-сотни микросекунд и не зависят от размера кучи: с Go 1.8 стеки не пересканируются под STW. Горутины приложения работают, но платят mark assist за аллокации работают на полной скорости время Красным - stop-the-world. Всё остальное время сборщик работает параллельно с приложением, отъедая примерно четверть CPU.
Фазы GC. Пауз ровно две, обе короткие и почти постоянные по длительности. Настоящая цена сборщика не в паузах, а в тех 25% CPU на маркировке и в assist-работе, которую платит слишком быстро аллоцирующая горутина.
Классическая ошибка в ответе

«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

Цикл запускается в трёх случаях:

  1. По достижении цели кучи. Основной путь. GOGC задаёт, на сколько процентов куче разрешено вырасти относительно живых данных прошлого цикла (с Go 1.18 к ним прибавляются стеки и глобальные переменные): target = live + (live + стеки + глобальные) × GOGC/100. При GOGC=100 (по умолчанию) цикл стартует, когда куча удвоилась. Следит за этим pacer: цели он буквально не дожидается, а стартует заранее, оценивая скорость аллокации, чтобы маркировка успела закончиться к моменту, когда куча дорастёт до цели.
  2. Принудительно, вызовом runtime.GC(): он блокирует вызывающую горутину до конца полного цикла (и, если предыдущий цикл ещё идёт, сначала дожидается его).
  3. По таймеру: sysmon форсирует цикл, если GC не запускался 2 минуты (forcegcperiod). Так простаивающий сервис всё-таки возвращает память ОС и не держит мусор бесконечно.

GOMEMLIMIT (Go 1.19) добавляет второй, независимый триггер: мягкий лимит на общий объём памяти рантайма. В него входят куча, стеки горутин, метаданные GC и внутренние структуры. Когда суммарный объём подбирается к лимиту, pacer запускает циклы чаще, вплоть до непрерывной работы, чтобы удержаться под ним. «Мягкий» означает, что рантайм не убьёт программу и не откажет в аллокации: если живых данных больше лимита, он просто будет молотить GC, а память всё равно вырастет.

GOGC=100 - по умолчанию live heap цель = live x 2 цикл стартует, когда куча удвоилась циклов мало, пик памяти высокий меньше CPU на GC GOGC=50 - жёстче live heap цель = live x 1.5 старт при меньшем приросте циклов вдвое больше, пик ниже CPU на GC заметно растёт GOMEMLIMIT в контейнере live heap растёт (кэш наполняется) GOMEMLIMIT куда тянет GOGC у лимита GC зовётся чаще и чаще потолок держится, OOM-kill не наступает платим CPU вместо падения пода
Пила и как её настроить. Зубчатый график памяти нормален и утечки не означает: на вершине стартует цикл, на спаде работает sweep. GOGC задаёт высоту зуба в процентах от живых данных, GOMEMLIMIT ставит абсолютный потолок и ценой лишних циклов не пускает пилу выше.
GOGC своими словами

«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] - жив весь bigslices.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) }
Две вещи, которые добавили в Go 1.25
  • runtime.AddCleanup выполняется параллельно. Раньше cleanup-ы разбирала та же одна горутина, что и финалайзеры, строго по очереди, и один медленный обработчик подвешивал все остальные. Теперь cleanup-ы раскладываются по нескольким горутинам, и очередь перестала быть узким местом. Финалайзеры по-прежнему идут в одной горутине. Для программы, которая вешает cleanup на каждый объект (буферы, дескрипторы, cgo-память), разница принципиальная.
  • GODEBUG=checkfinalizers=1 включает режим диагностики. Рантайм на каждом цикле печатает размер очереди и, главное, ловит классические ошибки: замыкание финалайзера, захватившее сам объект (тот самый «бессмертный объект»), и cleanup, привязанный к объекту, до которого можно дойти из самого cleanup-а. Найдя такое, рантайм показывает, где финалайзер создан, и валит программу фатальной ошибкой. Выглядит так:
    $ 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
    Держать включённым в проде не надо (лишние проверки под stop-the-world на каждом цикле и падение процесса на первой находке), но интеграционные тесты с этим флагом прогнать стоит: так дёшево находится утечка, которая иначе живёт месяцами.
Правило

Финалайзер и AddCleanup нужны как страховка от чужой забывчивости, а не как механизм освобождения ресурсов. Так их и применяют в стандартной библиотеке: os.File закрывает дескриптор, если пользователь забыл Close(). Детерминированное освобождение в Go делается через Close() + defer, и никак иначе. На собесе так и говори: «единственный корректный сценарий - подстраховка и диагностика утечек, всё остальное - явный Close».

Вопросы

11
Суть: конкурентный, параллельный, неперемещающий, без поколений трёхцветный mark & sweep с барьером записи. Метит одновременно с приложением, забирая около 25% CPU, и останавливает мир всего дважды на десятки микросекунд. С Go 1.26 маркировка идёт движком Green Tea - сканирование спанами вместо прыжков по отдельным объектам.

Цвета

  • Белый - бит маркировки не стоит, объекта нет в очереди: кандидат в мусор.
  • Серый - бит стоит, объект в очереди работы: нашли, но поля ещё не прочитали.
  • Чёрный - бит стоит, поля просканированы, из очереди вынут.

Сам цвет не поле объекта. Физически есть биты маркировки в метаданных спана (gcmarkBits) и распределённая очередь работы: у каждого P свой буфер gcWork, при переполнении он отдаётся в глобальную очередь, откуда его крадут другие воркеры. За счёт этого маркировка и масштабируется по ядрам.

Ход цикла

  1. Корни (глобальные переменные, стеки всех горутин, регистры) красятся в серый.
  2. Пока очередь не пуста: достать объект, просканировать его поля по карте указателей типа, все белые дети - в серый и в очередь, сам объект - чёрный.
  3. Очередь пуста → всё белое недостижимо → 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 в начале цикла и mark termination в конце. Обе длятся десятки-сотни микросекунд и почти не зависят от размера кучи. Маркировка и уборка идут параллельно с приложением.
  1. Sweep termination (STW). Домести спаны, оставшиеся от прошлого цикла (sweep ленивый, хвост мог не разобраться), включить write barrier, перевести все P в режим маркировки.
  2. Mark (конкурентно). Сканируются корни: глобальные переменные и стек каждой горутины (ровно один раз за цикл; горутина при этом коротко приостанавливается, но общего STW нет). Затем обход графа. Работают dedicated воркеры (25% от GOMAXPROCS), fractional-воркер на дробную часть и mark assist у аллоцирующих горутин.
  3. Mark termination (STW). Проверить, что очередь работы пуста, выключить write barrier, посчитать цель следующего цикла, сбросить статистику.
  4. Sweep (конкурентно и лениво). Спаны подметает фоновая горутина, а недометённый спан подметается в момент, когда mcache просит новый. Значит, стоимость размазана по времени и аллокациям и отдельного пика в профиле не даёт.

Как физически реализуется STW

stopTheWorld выставляет у каждого P флаг преемпции и ждёт, пока все горутины дойдут до безопасной точки. До Go 1.14 такой точкой был только вызов функции, поэтому «горячий цикл без вызовов» мог задержать весь STW на неопределённое время. Отсюда знаменитый баг с зависанием на for {}. С 1.14 работает асинхронная преемпция: рантайм шлёт потоку сигнал SIGURG и останавливает горутину практически где угодно.

Правильная мысль про «GC тормозит сервис»

У 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

Цель считается от живых данных, поэтому у сервиса с крошечным 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 death spiral

Лимит мягкий: если живых данных стало больше лимита, рантайм не упадёт и не откажет в аллокации, а начнёт запускать 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 setGOGC вверх + 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 без capO(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 контейнера.
Почему ReadMemStats лучше не звать часто

runtime.ReadMemStats останавливает мир на время копирования структуры. На сервисе с тысячами горутин и экспортом раз в секунду это заметная доля пауз, причём их создаёт сам мониторинг. runtime/metrics (Go 1.16+) читает те же данные без STW и с версионированными именами метрик. Для новых сервисов выбор однозначен.

Суть: сборщик освобождает недостижимое. Утечка в Go - это достижимый мусор: данные, которые логически не нужны, но на них осталась ссылка от корня. GC про «логически не нужны» ничего не знает.

Корни, из которых тянутся ссылки

Глобальные переменные пакетов, стеки всех живых горутин, значения в sync.Pool, объекты, зарегистрированные в рантайме (таймеры, тикеры, финалайзеры), а также всё, что достижимо из этого транзитивно. Утечка = что-то из этого списка держит ссылку слишком долго.

Типовые причины

  1. Глобальный кэш/мапа без вытеснения. Только пишем, никогда не удаляем. Есть и отдельный нюанс: delete из мапы память не возвращает, и мапа, разросшаяся до миллиона записей, после очистки держит таблицу прежнего размера. Вернуть память можно только одним способом: создать новую мапу.
  2. Утечка горутин в проде встречается чаще всех. Горутина, навсегда вставшая на отправке в небуферизованный канал или на select без ctx.Done(), держит свой стек (минимум 2 КБ, часто больше) и всё, до чего дотягивается из него. Тысяча зависших горутин с телом запроса внутри съедает сотни мегабайт.
  3. Подслайс держит весь массив. small := big[:10] - cap остался от big, значит жив весь исходный массив.
  4. Подстрока держит исходную строку по той же механике.
  5. «Хвост» слайса после удаления. s = s[:len(s)-1] оставляет указатель в подложке массива: элемент недоступен, но достижим.
  6. Забытые time.Ticker / time.AfterFunc. Тикер без Stop() до Go 1.23 жил в рантайме вечно. С 1.23 недостижимые таймеры и Ticker собираются и без Stop, а вот AfterFunc до срабатывания по-прежнему держит замыкание со всем захваченным.
  7. Не закрытый http.Response.Body держит соединение, буферы чтения и мешает переиспользованию keep-alive.
  8. Замыкание, захватившее лишнее. Колбэк, положенный в долгоживущую структуру, тянет за собой весь контекст запроса.
  9. Растущий контекст: цепочка 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.

Суть: снять два heap-профиля с интервалом, сравнить их через -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.

Шесть причин не полагаться

  1. Вызова может не быть вообще. При завершении программы финалайзеры не запускаются: если объект дожил до выхода, функция не выполнится.
  2. Время не определено. Между смертью объекта и вызовом может пройти сколько угодно циклов GC. Для файловых дескрипторов и соединений это означает исчерпание лимита ОС задолго до срабатывания.
  3. Объект живёт минимум на цикл дольше. Первый цикл обнаруживает недостижимость и ставит объект в очередь финалайзеров, откуда тот снова становится достижимым (воскрешение). Освободит его только следующий цикл.
  4. Циклы не собираются. Если объекты с финалайзерами ссылаются друг на друга, не освободится ни один и не вызовется ни один финалайзер.
  5. Легко случайно сделать объект бессмертным. Замыкание, захватившее сам obj, держит ссылку на него от корня очереди финалайзеров, и объект не умрёт никогда. Классический баг.
  6. Выполняется в отдельной горутине и последовательно для всех объектов: медленный финалайзер блокирует остальные, а паника в нём валит процесс.
// так нельзя: объект никогда не умрёт
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 формально не удалён, но в новом коде считается устаревшим.
Чем это диагностировать: GODEBUG=checkfinalizers=1 (Go 1.25)

Режим ловит те самые два бага, о которых шла речь выше: замыкание финалайзера, захватившее сам объект, и 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.