Наблюдение за работой Green Tea в куче Golang
- title
- Наблюдение за работой Green Tea в куче Golang
- type
- summary
- summary
- Фил Итон исследует сборщик Green Tea в Golang 1.26 с помощью perf и разбирает проблему разреженных страниц
- tags
- golang, garbage-collection, performance, microarchitecture
- sources
- golang-green-tea-gc
- created
- 2026-07-29
- updated
- 2026-09-14
- lang
- ru
- translation_of
- golang-green-tea-gc
- source_updated
- 2026-09-14
- translated
- 2026-09-14
- translator
- lllm/antigravity/gemini-3.7-flash-medium
В Golang 1.25 сборщик мусора Green Tea появился как опция, а в 1.26 стал работать по умолчанию. Статья Фила Итона - это попытка увидеть эти изменения наглядно, а не просто прочитать о них: отрисовать кучу, замерить показатели с помощью perf, а затем найти нагрузку, где новый сборщик бессилен, поскольку дело было вовсе не в нём.
Отрисовка кучи
Golang округляет размер каждого выделения памяти вверх до размерного класса (size class) и размещает объект в span'е - непрерывной последовательности из одной или нескольких страниц по 8 КиБ, содержащей объекты только этого класса. Аллокатор восходит к tcmalloc, где разделение по размерным классам является стандартом.
В демонстрационном коде выделяется 100 объектов, случайно выбираемых из трёх типов (Small [32]byte, Medium [64]byte, Large [128]byte), затем адрес каждого объекта считывается через reflect.ValueOf(o).Pointer(), сортируется по адресу и выводится по одному символу на каждые 32 байта адресного пространства. Объекты одинакового размера выстраиваются непрерывными цепочками, несмотря на случайный порядок выделения памяти:
=== pass 0 (base 0xba4841580c0) ===
0xba4841580c0 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
...
0xba48415bcc0 ............................SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS
...
0xba4841add40 ..........................L---L---L---L---L---L---L---L---L-
Проход 1 сначала вызывает runtime.GC() и выводит побайтово идентичный результат, включая базовый адрес. Golang никогда не перемещает объекты. Та же программа на C# выдаёт чередующиеся последовательности L---M-M-SL---, поскольку .NET не разделяет объекты по размерным классам, а в последующем эксперименте адреса в ней смещаются после сборки мусора.
Что меняет Green Tea
Классическая фаза маркировки переходит по каждому указателю примерно по мере его обнаружения. Если объект указывает на объекты других размеров или на объекты того же типа, выделенные в совершенно другое время, цели оказываются в разрозненных частях адресного пространства, и обход превращается в произвольный доступ к памяти. Green Tea вместо этого сканирует весь span на наличие объектов и указателей, а затем ставит span'ы, на которые ведут эти указатели, в очередь для последующего сканирования. Единицей работы становится span, а не отдельный объект.
Прямая демонстрация пути маркировки потребовала бы патчей к runtime, поэтому Итон вместо этого измеряет итоговый эффект. Тестовая нагрузка создаёт 2 000 000 структур Node по четыре указателя в каждой и связывает их либо плотно (packed, узел i указывает на узлы с i+1 по i+4), либо вразброс (scattered, четыре случайных индекса), после чего 100 раз вызывает runtime.GC(). Массивы индексов генерируются отдельным скриптом на Python и читаются из stdin в виде сырых uint32, чтобы в варианте со случайными связями не тратилось время на генерацию случайных чисел внутри измеряемой программы. Оба бинарника собраны из одного исходного кода; старый сборщик компилируется с флагом GOEXPERIMENT=nogreenteagc.
Где замеры впервые дают сбой
Результаты по астрономическому времени однозначны. Вариант packed ускоряется с 4.23 с до 2.70 с, scattered - с 11.05 с до 6.96 с. Однако показатели кэша из perf stat -e cache-references,cache-misses говорят об обратном:
| binary | input | cache-references | cache-misses |
|---|---|---|---|
| old GC | packed | 1,130,709,755 | 290,434,782 (25.69%) |
| old GC | scattered | 13,247,268,612 | 2,325,799,796 (17.56%) |
| Green Tea | packed | 481,414,281 | 257,894,055 (53.57%) |
| Green Tea | scattered | 3,398,491,016 | 2,195,902,796 (64.61%) |
Процент промахов вырастает примерно в два-три раза. В такой интерпретации есть две ошибки. cache-references и cache-misses в perf обычно относятся к L3, что ничего не говорит о поведении L1 или L2. Кроме того, бинарники теперь выполняются разное время, поэтому абсолютные значения несравнимы: решением является нормализация по числу инструкций (instructions) и подсчёт промахов на тысячу инструкций (MPKI). Но даже это не спасает картину: L3 MPKI для packed вырастает с 1.59 до 1.81, а для scattered - с 12.70 до 15.39.
Арифметика объясняет эту инверсию. На нагрузке scattered Green Tea сократил обращения к L3 на 74%, тогда как абсолютное количество промахов L3 снизилось всего на 5.6%. Дробь, чей знаменатель резко уменьшился, растёт. Исчезли именно те чтения, которые L3 успешно обслуживал, а не те, на которых происходили промахи.
Чтобы получить полезные данные, нужны счётчики L1, а большинство виртуальных машин не пробрасывают счётчики PMU, необходимые для событий L1. Поэтому Итон переходит на физический сервер Vultr и добавляет счётчики L1-dcache-loads и L1-dcache-load-misses:
binary ordering elapsed(s) L1miss% L1-MPKI L3miss% L3-MPKI
readorder_oldgc packed 4.47±0.32 0.9 2.23 26.2 1.61
readorder_oldgc scattered 11.44±0.85 12.9 31.57 17.5 12.76
readorder_greentea packed 2.70±0.01 1.0 1.98 53.8 1.81
readorder_greentea scattered 7.01±0.04 7.3 14.06 63.2 15.37
L1 MPKI на нагрузке scattered падает с 31.57 до 14.06. Больше чтений на фазе маркировки обслуживается из L1 (и, вероятно, L2) и вообще не доходит до L3, из-за чего статистика L3 перестала быть показательной. Это та же ловушка измерений, что и в false-sharing-alignment-128, где вопрос о том, лучше ли выравнивание в 128 байт, чем в 64, проясняется лишь при выборе счётчика, на котором этот эффект реально виден. Ситуация перекликается и с golang-maps-swiss-tables, где выигрыш в 30% на микротестах Swiss Tables превращается в 1.5% в продакшене. Если число изменилось, это ещё не значит, что вы нашли нужный показатель.
Проблема разреженных страниц
Green Tea делает маркировку более дружественной к кэшу. Но он не делает сборщик перемещающим, и именно в этом заключается оставшаяся структурная слабость Golang.
Во втором эксперименте выделяется 50 000 объектов, сбрасывается 90% ссылок, после чего вызывается runtime.GC(), а затем debug.FreeOSMemory(). Объём живых данных падает с 3639.9 КиБ до 364.6 КиБ. Количество отдельных span'ов по 8 КиБ, удерживающих хотя бы один живой объект, снижается с 464 всего до 463:
=== pass 1 (5000 live, base 0x10a2674acc00) ===
0x10a2674acc00 L---........................L---............................
0x10a2674ad380 ................L---............................L---........
...
live data 364.6 KiB
spans pinned 463 (46 if the survivors were packed)
runtime HeapInuse 6320.0 KiB | HeapIdle 5552.0 KiB | HeapReleased 5512.0 KiB
Выжили десять процентов объектов, распределённые настолько равномерно, что почти каждый span по-прежнему содержит как минимум один из них, а span хотя бы с одним живым объектом вернуть операционной системе нельзя. Значение HeapInuse в обоих проходах остаётся равным 6320.0 КиБ.
Обходной путь - выполнить уплотнение вручную. Третья программа копирует каждый выживший объект по значению в срез соответствующего типа ([]Small, []Medium, []Large), обнуляет исходные ссылки и снова запускает сборку мусора. После этого выжившие объекты размещаются в трёх плотных массивах: число занятых span'ов падает с 465 до 48 при идеальном значении 47, HeapInuse снижается с 6232.0 КиБ до 2768.0 КиБ, а HeapReleased вырастает с 5672.0 КиБ до 9040.0 КиБ. Это работает только потому, что объекты представляют собой чистые значения без идентичности, которую нужно сохранять; любые структуры, хранящие указатели на них, теперь указывали бы на мусор. В общем виде этот приём известен как arena-allocation - владеть регионом целиком, а не отдельными объектами, и освобождать его полностью за один раз.
Аналогичному эксперименту с освобождением 90% в C# ручные действия не требуются. Вызов GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive) выполняет уплотнение памяти, и число отдельных 8-КиБ блоков с живыми объектами падает с 458 до 47, в точности совпадая с идеальным плотным значением. Выжившие объекты к тому же оказываются по совершенно другому базовому адресу, что служит наглядным подтверждением их перемещения в .NET.
Итон подчёркивает, что это застарелая проблема Golang, а не регрессия Green Tea: новый сборщик устранил накладные расходы на обход графа, а фрагментация - это отдельное следствие отказа от перемещения объектов. Стабильность указателей, делающая фрагментацию неизбежной, одновременно обеспечивает корректность таких приёмов, как арифметика unsafe-указателей в go-bounds-checks-unsafe.
Тот же подход с самостоятельным воспроизведением эффектов Итон применяет к детектору гонок в threadsanitizer-limits.
Та же фаза маркировки повторно используется для поиска утечек в goroutine-leak-profiler, где маркировка начинается только с незаблокированных горутин, а заблокированные добавляются в качестве корней по мере достижения блокирующих их примитивов.