Механика и тюнинг GC: трёхцветный mark-sweep, пейсер, GOGC и GOMEMLIMIT без мифов
Сборщик Go — конкурентный трёхцветный mark-sweep с барьерами записи и двумя STW-паузами меньше миллисекунды. GOGC меняет запас кучи на частоту циклов; GOMEMLIMIT — мягкий потолок, у границы возможна спираль смерти. Без компактизации и поколений — осознанный выбор, а не пробел.
Сервису рекомендаций прилетел тикет на экономию: под просил 4 GiB, а живая куча была лишь 1,2 GiB, так что memory request урезали до 1,5 GiB и «для подстраховки» добавили GOMEMLIMIT=1400MiB. Стейджинг прошёл гладко. В проде пятничное обновление каталога ненадолго подняло живую кучу до 1,3 GiB — 93% лимита — и сервис не словил OOM. Случилось хуже: он остался жить. GC, зажатый между кучей, которую нечем уменьшить, и потолком, который нельзя пересечь, начал гонять циклы впритык, сжигая 40%+ CPU на маркировку. p99 вырос с 18 мс до 900 мс. Дашборды не показывали ни падений, ни OOM — просто сервис тихо тратил половину мозга на сборку мусора. Дежурный час перезапускал поды, пока кто-то не построил график GC CPU. Лимит ошибался ненамного: 200 MiB запаса отделяли настроенный сервис от спирали смерти.
К концу урока вы будете знать, где прячется настоящая цена GC (не в паузах), как читать GOGC и GOMEMLIMIT без гадания и как увидеть спираль смерти раньше, чем она вас разбудит.
Трёхцветный mark-sweep, работающий параллельно с вашим кодом
Сборщик Go — конкурентный mark-sweep: он находит живые объекты и освобождает остальное, причём почти вся работа идёт, пока ваши горутины продолжают выполняться. Фаза маркировки описывается трёхцветной моделью: каждый объект белый (ещё не увиден — презумпция мусора), серый (увиден, содержимое не просканировано) или чёрный (увиден и полностью просканирован). Маркировка стартует с корней — глобальных переменных и стека каждой горутины, — красит их в серый и затем вычерпывает серое множество: просканировать указательные поля объекта, посереть всё, на что они ссылаются, зачернить сам объект. Когда серых не осталось, всё достижимое — чёрное, а всё ещё белое — мусор. Sweep возвращает белые спаны аллокатору лениво, амортизированно в последующие аллокации.
Цикл обрамляют две stop-the-world паузы, обе обычно короче миллисекунды, как правило десятки–сотни микросекунд: одна запускает маркировку (включить барьер записи на каждом P), вторая её завершает (убедиться, что серой работы не осталось). Этот заголовочный показатель честный — но это не цена GC. Цена конкурентна: ~25% GOMAXPROCS зарезервированы под фоновую маркировку во время цикла, плюс mark assist — горутину, аллоцирующую быстрее, чем фоновые воркеры успевают сканировать, мобилизуют маркировать пропорционально её аллокациям. Субмиллисекундные паузы не бесплатны; это паузы, конвертированные в налог на пропускную способность.
Когда встречаете описание GC как «low-pause», помните: пауза не исчезла — она просто сменила форму.
Барьер записи: почему конкурентная маркировка не теряет объекты
У маркировки, идущей одновременно с мутациями, есть классическая опасность: код может скопировать указатель на белый объект в уже чёрный объект и стереть единственную другую ссылку. Сборщик, закончивший с чёрным объектом, никогда к нему не вернётся — белый объект сметут, хотя он достижим. Go закрывает дыру барьером записи: во время маркировки каждая запись указателя проходит через маленький хук рантайма. Гибридный барьер Go (с 1.8) затеняет старый затираемый указатель (deletion-стиль, Yuasa), а для записей в стек — и новый (insertion-стиль, Дейкстра) — комбинация гарантирует, что ни один достижимый объект не останется белым, и именно она избавила от повторного сканирования стеков горутин на завершении, ограничив финальную паузу. Цена: каждая запись указателя в программе чуть дороже, пока идёт цикл. Структуры, плотные по указателям (связные списки, деревья мелких узлов, map[string]*Thing), облагаются дважды — больше работы барьеру, больше погони маркировщику. Это механический аргумент за value-ориентированный дизайн: []Item лучше []*Item не из идеологии, а из экономики барьера и сканирования.
Во время конкурентной маркировки код записывает указатель на объект W (белый) в объект B (уже чёрный) и удаляет единственный другой путь к W. Почему GC не освободит W?
Пейсер, GOGC и GOMEMLIMIT — две ручки, два режима
Когда стартует цикл? Пейсер стремится закончить маркировку ровно к моменту, когда куча достигнет цели: примерно живая куча × (1 + GOGC/100) (плюс стеки и глобальные в современной формуле). С дефолтным GOGC=100 сервис с 1 GiB живых данных запускает GC около 2 GiB — потолок памяти примерно вдвое больше живой кучи. Поднимите GOGC (200, 400) — циклы станут реже: меньше CPU на GC, больше RAM; опустите — обмен в другую сторону. Пейсер самокорректируется по обратной связи от прошлых циклов и мобилизует аллоцирующие горутины через mark assist, когда они его обгоняют, — поэтому у аллокационно-тяжёлого кода латентность деградирует раньше, чем память начинает пугать.
GOMEMLIMIT (Go 1.19+) — второй режим: мягкий лимит на всю память рантайма (куча, стеки, структуры рантайма — но не аллокации C). По мере приближения к нему GC работает агрессивнее, чем диктовал бы один GOGC, пытаясь удержаться ниже. Используйте его, когда настоящее ограничение — контейнер: GOMEMLIMIT на ~90–95% лимита пода превращает «OOMKilled в два ночи» в «больше GC под давлением». Режим отказа — пролог: живая куча у самого лимита не оставляет GC ничего освобождать, и он работает непрерывно. Рантайм ограничивает GC половиной CPU, гарантируя прогресс, — осознанное проектное решение, предотвращающее полный клин, но всё равно означающее, что сервис может законно тратить половину процессора на маркировку. Оставляйте запас: при живой куче 1,3 GiB лимит 1,4 GiB — ловушка; 2 GiB — бюджет.
// Честное наблюдение за GC в проде:
// GODEBUG=gctrace=1 печатает строку на каждый цикл в stderr —
// gc 412 @1572.337s 2%: 0.06+312+0.04 ms clock, ...,
// 1290->1310->1190 MB, 1400 MB goal, ...
// читается так: 2% CPU на GC с момента старта; 0.06 и 0.04 мс STW вокруг
// 312 мс конкурентной маркировки; куча 1290 MB на старте, живых 1190 после;
// цель 1400 — живое составляет 85% цели: сервис у края спирали.
debug.SetGCPercent(200) // GOGC, в рантайме
debug.SetMemoryLimit(1900 << 20) // GOMEMLIMIT, байты
var m runtime.MemStats
runtime.ReadMemStats(&m) // m.NumGC, m.PauseNs, m.GCCPUFractionМифы: нет компактизации, нет поколений — и почему
Две вещи, которые инженеры «знают» про GC и которые сборщик Go сознательно не делает. Нет компактизации: объекты никогда не переезжают после аллокации. Фрагментацию вместо этого сдерживает аллокатор с размер-классами (~70 классов; объект на 18 байт живёт в слоте на 24 байта, в спане, выделенном под этот класс, — внутренние потери ограничены в худшем случае). Неперемещающий дизайн покупает дешёвый и стабильный интероп (указатели, отданные в syscall или cgo, остаются валидными) и отсутствие фазы копирования, конкурирующей за пропускную способность памяти. Нет поколений: нет «ясель»; каждый цикл сканирует весь живой набор. Генерационная гипотеза — большинство объектов умирает молодыми — в Go в значительной мере обработана заранее escape-анализом: самые молодые и короткоживущие значения вообще не доходят до кучи, бесплатно умирая на стеках. Команда Go прототипировала генерационный сборщик и обнаружила, что выигрыш не оправдывает цену: поколениям нужен ещё более дорогой барьер записи для отслеживания указателей из старого в молодое, что усложняет конкурентный дизайн, удерживающий паузы под миллисекундой. Это компромиссы, а не пробелы — но они означают, что GC Go вознаграждает маленький живой набор и низкую скорость аллокаций и не даёт «ясель», за которыми можно спрятать дизайн с высоким churn.
Сервис с живой кучей 1,3 GiB работает с GOMEMLIMIT=1400MiB. Трафик обычный, утечек нет. Какой симптом в проде наиболее вероятен?
- 01Пройдите один цикл GC: две паузы, что выполняется конкурентно и где живёт настоящая цена в CPU.
- 02Когда брать GOGC, а когда GOMEMLIMIT, и в чём случай спирали смерти с мягким лимитом?
Сборщик Go — конкурентный трёхцветный mark-sweep: объекты переходят белый→серый→чёрный по мере вычерпывания от корней, цикл обрамляют две stop-the-world паузы в десятки–сотни микросекунд, а гибридный барьер записи — затеняющий затираемые указатели — гарантирует, что конкурентные мутации не спрячут достижимый объект; он же убрал пересканирование стеков из финальной паузы. Честная цена конкурентна: ~25% GOMAXPROCS во время маркировки плюс mark assist, напрямую облагающий аллокационно-тяжёлые горутины, — скорость аллокаций превращается в латентность раньше, чем память выглядит тревожно. Пейсер запускает циклы около живое×(1+GOGC/100) — при дефолте потолок примерно вдвое больше живой кучи. GOMEMLIMIT добавляет мягкий потолок всей памяти под контейнеры; ставьте его с запасом, потому что живая куча у лимита заставляет циклы идти впритык с проектным потолком 50% CPU — сервис из пролога тратил половину мозга на маркировку при зелёных дашбордах. Плотные по указателям структуры платят дважды (трафик барьера плюс сканирование) — это механический довод за value-ориентированные данные. А два знаменитых отсутствия — выбор: нет компактизации (спаны с размер-классами ограничивают фрагментацию; указатели валидны для syscall и cgo) и нет поколений (escape-анализ и так даёт молодым умирать на стеках; прототип генерационного сборщика не окупил более тяжёлый барьер). GC вознаграждает ровно две вещи: маленький живой набор и низкую скорость аллокаций. Теперь, потянувшись к ручке GC, сначала проверьте долю GC CPU и запас между живой кучей и GOMEMLIMIT — ответ обычно указывает обратно на скорость аллокаций, а не на тюнинг.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.