Команда Go for Devs подготовила перевод статьи о новом экспериментальном сборщике мусора Green Tea, появившемся в Go 1.25. Он уже используется в Google и показывает снижение затрат CPU на GC до 40%. Разбираемся, почему это не просто оптимизация, а новый уровень эффективности.


В Go 1.25 появился новый экспериментальный сборщик мусора под названием Green Tea, который можно включить, установив переменную окружения GOEXPERIMENT=greenteagc во время сборки. Во многих сценариях работы программы время, затрачиваемое на сборку мусора, сокращается примерно на 10%, а в некоторых случаях — до 40%!

Green Tea уже готов для продакшена и используется в Google, поэтому мы настоятельно рекомендуем попробовать его. Мы знаем, что не все нагрузки дают заметный выигрыш, а в некоторых случаях улучшения может не быть вовсе — поэтому ваша обратная связь крайне важна, чтобы мы могли двигаться дальше. На основе текущих данных мы планируем сделать Green Tea сборщиком мусора по умолчанию в Go 1.26.

Трассировка сборки мусора

Прежде чем говорить о Green Tea, давайте разберёмся, как вообще работает сборка мусора.

Объекты и указатели

Цель сборщика мусора — автоматически освобождать и повторно использовать память, которая больше не нужна программе.

Для этого сборщик мусора Go работает с объектами и указателями.

В контексте рантайма Go объекты — это значения Go, память под которые выделяется в куче. Объекты кучи создаются тогда, когда компилятор Go не может определить другой способ выделить память для значения. Например, следующий фрагмент кода создаёт один объект в куче — хранилище (backing store) для slice из указателей:

var x = make([]*int, 10) // глобальная переменная

Компилятор Go не может разместить хранилище этого slice где-либо, кроме кучи, потому что ему очень трудно, а порой и вовсе невозможно понять, как долго переменная x будет ссылаться на этот объект.

Указатели — это просто числа, указывающие на расположение значения Go в памяти. Именно с помощью указателей программа Go обращается к объектам. Например, чтобы получить указатель на начало объекта, выделенного в приведённом выше примере, можно написать:

&x[0] // 0xc000104000

Алгоритм mark-sweep

Сборщик мусора Go использует стратегию, известную как tracing garbage collection. Это означает, что сборщик мусора «следит» за указателями в программе, чтобы определить, какие объекты всё ещё используются.

Более конкретно, в Go реализован алгоритм mark-sweep («пометь и очисти»). Он проще, чем кажется. Представьте, что объекты и указатели образуют нечто вроде графа (в терминах информатики): объекты — это узлы, а указатели — рёбра.

Алгоритм mark-sweep работает с этим графом и, как следует из названия, состоит из двух фаз.

  1. Фаза маркировки (mark phase) – Сборщик начинает обход графа объектов от хорошо определённых исходных рёбер, называемых корнями (roots). Корнями обычно являются глобальные и локальные переменные. Во время обхода он помечает все найденные объекты как посещённые, чтобы не зациклиться. Это похоже на стандартные алгоритмы обхода графа — например, поиск в глубину (DFS) или поиск в ширину (BFS).

  2. Фаза очистки (sweep phase) – после обхода все объекты, которые не были посещены, считаются неиспользуемыми или недостижимыми для программы. Мы называем их недостижимыми, потому что с точки зрения семантики языка Go нет безопасного способа снова обратиться к этой области памяти. Чтобы завершить фазу очистки, алгоритм просто проходит по всем непомеченным узлам и освобождает связанную с ними память, чтобы аллокатор мог использовать её повторно.

И это всё?

Может показаться, что я всё слишком упростил. Сборщики мусора часто называют чем-то вроде «магии» или «чёрного ящика». И отчасти вы будете правы — там действительно есть своя сложность.

Например, на практике этот алгоритм выполняется параллельно с обычным кодом Go. А обход графа, который при этом ещё и изменяется на лету, порождает немало трудностей. Кроме того, сам алгоритм работает в несколько потоков, и к этому моменту мы ещё вернёмся позже.

Но поверьте: все эти детали существуют поверх базового алгоритма. В своей сути он действительно представляет собой просто обход графа.

Комментарий от редакции Go for Devs:

В оригинальной статьей используется карусель с пошаговым примером работы сборщика мусора. Возможности Хабра не позволяют отобразить её здесь в удобном виде. Если хотите ознакомиться с алгоритмом работы подробнее, пожалуйста, обратитесь к оригинальной статье.

Проблема

Теперь, когда мы разобрались, что именно делает сборщик мусора Go, может показаться, что всё работает вполне неплохо. Так в чём же тогда проблема?

Оказывается, в некоторых программах выполнение этого алгоритма занимает очень много времени, создавая заметные накладные расходы практически во всех Go-приложениях. Не редкость, когда сборщик мусора потребляет 20% и более общего времени работы CPU.

Давайте разберёмся, куда уходит всё это время.

Стоимость сборки мусора

Если смотреть в целом, стоимость работы сборщика мусора складывается из двух факторов:

  1. Как часто он запускается;

  2. Сколько работы выполняет за один запуск.

Если перемножить эти два значения, мы получим общую стоимость сборки мусора:

Общая стоимость GC = Количество циклов GC × Средняя стоимость одного цикла GC

За прошедшие годы мы активно оптимизировали обе части этого уравнения. Подробно о том, как часто запускается сборщик мусора, рассказывает доклад Майкла на GopherCon EU 2022, посвящённый ограничениям по памяти. В официальном руководстве по сборщику мусора Go эта тема также разобрана очень подробно — туда стоит заглянуть, если хотите глубже понять механику GC.

А сейчас сосредоточимся только на втором факторе — стоимости одного цикла.

Где именно тратится время?

Многолетние исследования профилей CPU показали две ключевые вещи о сборщике мусора Go.

  1. Около 90% всех затрат приходится на фазу marking (пометки), и лишь около 10% — на фазу sweeping (очистки). Очистка оказалась гораздо проще для оптимизации, и в Go уже много лет используется очень эффективный sweeper.

  2. Из всего времени, затраченного на фазу пометки, существенная доля — как минимум 35% — уходит на ожидание доступа к памяти кучи. Это уже само по себе плохо, но есть ещё хуже: такие задержки полностью разрушают механизмы, делающие современные процессоры быстрыми.

«Микроархитектурная катастрофа»

Что означает выражение “gum up the works” — «всё застопорить» — в этом контексте? Чтобы объяснить, не вдаваясь в дебри современной архитектуры процессоров, воспользуемся аналогией.

Представьте, что CPU едет по дороге, и эта дорога — это ваш код. Процессор хочет разогнаться до высокой скорости, но для этого ему нужно видеть далеко вперёд, а путь должен быть прямым и свободным. Однако алгоритм обхода графа — это как езда по городским улицам. Процессор не видит, что за поворотом, и не может предсказать, что произойдёт дальше. Чтобы продолжить движение, ему приходится постоянно сбрасывать скорость, поворачивать, останавливаться на светофорах и уступать пешеходам. И неважно, насколько мощный у вас двигатель — ускориться всё равно негде.

Переход от аналогии к практике

Теперь сделаем пример более наглядным. На схеме ниже показано, как сборщик мусора двигался по куче во время нашего примера обхода графа. Каждая стрелка слева направо обозначает участок работы по сканированию, а пунктирные стрелки показывают, как мы перескакивали между этими участками.

Путь, который прошёл сборщик мусора по куче в нашем примере обхода графа.
Путь, который прошёл сборщик мусора по куче в нашем примере обхода графа.

Как видно, сборщик постоянно прыгает по памяти, выполняя крошечные порции работы в разных местах. Особенно часто он перескакивает между страницами памяти и даже между разными частями одной страницы.

Современные процессоры активно используют кэширование. Доступ к основной памяти может быть в 100 раз медленнее, чем к данным, уже находящимся в кэше. Кэш заполняется недавно использованными участками памяти и областями, расположенными рядом с ними. Но в Go нет гарантии, что два объекта, которые ссылаются друг на друга, также расположены рядом в памяти. Алгоритм обхода графа этого не учитывает.

Почему это так плохо для CPU

Если бы дело было только в медленных обращениях к памяти, всё было бы не так трагично. Современные процессоры могут асинхронно запрашивать данные из памяти, перекрывая медленные операции. Но в случае обхода графа каждое действие крошечное, непредсказуемое и зависит от результата предыдущего. Поэтому процессору приходится ждать завершения почти каждого обращения к памяти, и его конвейеры простаивают.

И, увы, ситуация только ухудшается

Есть старая поговорка в индустрии:

«Подожди пару лет — и твой код станет работать быстрее».

Но для Go, как языка со сборкой мусора, основанной на алгоритме mark-sweep, эта фраза всё чаще звучит наоборот:

«Подожди пару лет — и твой код станет медленнее».

Дело в том, что современные тенденции в архитектуре процессоров усложняют жизнь сборщикам мусора. Вот несколько ключевых причин:

  • Неоднородный доступ к памяти (NUMA) – в современных системах память часто «привязана» к отдельным наборам ядер. Доступ других ядер к этой памяти стал медленнее. То есть стоимость обращения к основной памяти теперь зависит от того, какое ядро это делает.

  • Снижение пропускной способности памяти на одно ядро – хотя ядер становится больше, доступная полоса пропускания памяти на каждое ядро уменьшается. В результате запросы, не попавшие в кэш, ждут дольше, чем раньше.

  • Рост количества ядер – мы рассматривали последовательный вариант алгоритма пометки, но в реальности сборщик выполняет его параллельно. Это хорошо масштабируется до определённого числа ядер, однако общая очередь объектов для сканирования становится узким местом, даже при тщательной оптимизации.

  • Новые возможности железа – современные CPU поддерживают, например, векторные инструкции, позволяющие обрабатывать большие объёмы данных за один такт. Однако применить их к фазе пометки непросто — она выполняет нерегулярную, мелкую и непредсказуемую работу, плохо подходящую для векторизации.

Green Tea

И вот мы подошли к Green Tea — нашему новому подходу к алгоритму mark-sweep.
Ключевая идея Green Tea удивительно проста:

Работать со страницами, а не с объектами.

Звучит почти банально, правда? Но на практике потребовалось огромное количество экспериментов, чтобы понять, как правильно упорядочить обход графа объектов и что именно нужно отслеживать, чтобы это решение реально работало эффективно.

Если говорить конкретнее:

  • Вместо того чтобы сканировать объекты, мы сканируем целые страницы.

  • Вместо того чтобы отслеживать объекты в списке задач, мы отслеживаем страницы.

  • Нам всё ещё нужно помечать объекты, но теперь пометки хранятся локально для каждой страницы, а не для всей кучи сразу.

Комментарий от редакции Go for Devs:

Как и в случае с обычным алгоритмом, в оригинальной статьей используется карусель с пошаговым примером работы алгоритма работы Green Tea. Возможности Хабра не позволяют отобразить её здесь в удобном виде. Если хотите ознакомиться с алгоритмом работы подробнее, пожалуйста, обратитесь к оригинальной статье.

Выезд на автостраду

Вернёмся к нашей автомобильной аналогии. Мы наконец-то выезжаем на шоссе?

Вспомним, как выглядел путь обычного обхода графа:

Путь, который проходил классический алгоритм обхода графа по куче, требовал 7 отдельных проходов.
Путь, который проходил классический алгоритм обхода графа по куче, требовал 7 отдельных проходов.

Мы всё время перепрыгивали между разными участками памяти, выполняя крошечные порции работы то тут, то там. А теперь посмотрим, как выглядит путь при работе Green Tea:

Путь, который проходит Green Tea, требует всего 4 прохода.
Путь, который проходит Green Tea, требует всего 4 прохода.

Вместо множества коротких прыжков, Green Tea делает реже, но длиннее проходы по страницам A и B. Чем длиннее эти стрелки, тем лучше, и на больших кучах эффект выражен ещё сильнее. Вот в этом и заключается магия Green Tea.

Теперь мы действительно «на трассе»

Такой подход гораздо лучше согласуется с микроархитектурой процессора.
Теперь объекты сканируются плотнее, с гораздо большей вероятностью того, что:

  • данные уже находятся в кэше, и не придётся обращаться к основной памяти;

  • метаданные страниц также с высокой вероятностью окажутся в кэше;

  • список задач (work list) стал меньше, а значит — меньше конкуренции между потоками и меньше простоев CPU.

И, продолжая нашу аналогию с вождением, теперь мы можем переключить воображаемую коробку передач на более высокую — ведь Green Tea позволяет использовать векторные инструкции процессора, чего раньше было невозможно!

Векторное ускорение

Если вы лишь поверхностно знакомы с векторным оборудованием, может быть не сразу понятно, как вообще можно использовать его здесь. Но помимо привычных арифметических и тригонометрических операций, современные векторные блоки процессоров поддерживают две крайне полезные для Green Tea вещи:

  1. очень широкие регистры,

  2. продвинутые побитовые операции.

Большинство современных процессоров x86 поддерживают AVX-512, где ширина векторных регистров составляет 512 бит. Этого достаточно, чтобы хранить всю метаинформацию одной страницы всего в двух регистрах, прямо в CPU. Благодаря этому Green Tea может обрабатывать всю страницу за несколько простых линейных инструкций.

Векторные блоки давно умеют выполнять базовые побитовые операции над целыми регистрами, но начиная с архитектур AMD Zen 4 и Intel Ice Lake появилось новое универсальное побитовое средство — своего рода «швейцарский нож» для битовых векторов. Оно позволяет выполнять ключевую часть сканирования в Green Tea всего за несколько тактов процессора. В совокупности эти возможности превращают цикл сканирования Green Tea в настоящий турбонаддув.

Для старого подхода с обходом графа подобное было просто невозможно. Там мы постоянно прыгали между объектами разных размеров: иногда нужно было два бита метаданных, а иногда десять тысяч. Такой хаотичный и непредсказуемый характер работы не позволял эффективно использовать векторное железо.

Если вам интересны технические детали, можно продолжить чтение — но если хочется перейти сразу к результатам, смело пролистывайте к разделу “Оценка”.

Используем AVX-512

Чтобы представить, как выглядит сканирование в сборщике мусора с поддержкой AVX-512, взгляните на схему ниже.

Здесь происходит довольно много всего — на эту тему можно было бы написать отдельный пост. Но давайте разберём общую идею по шагам.

1. Получаем биты seen и scanned

Сначала мы извлекаем два битовых массива для страницы:

  • seen — объекты, которые мы уже видели,

  • scanned — объекты, которые уже просканированы.

Каждый объект в странице представлен одним битом, а все объекты в пределах страницы имеют одинаковый размер.

2. Сравниваем эти два набора битов

Мы вычисляем их объединение и разность:

  • объединение образует новый набор scanned,

  • разность — это bitmap активных объектов (active objects), то есть те объекты, которые нужно просканировать в этом проходе (а не в предыдущих).

3. Расширяем bitmap активных объектов

Далее мы берём разность и «расширяем» её: теперь вместо одного бита на объект у нас по одному биту на каждое машинное слово (8 байт) страницы.
Этот новый набор называется bitmap активных слов (active words).

Например, если страница хранит объекты по 6 слов (48 байт), то каждый бит из bitmap активных объектов копируется в 6 битов bitmap активных слов:

0 0 1 1 ...
→
000000 000000 111111 111111 ...

4. Получаем bitmap указателей и скаляров

Теперь мы загружаем bitmap указателей/скаляров (pointer/scalar bitmap) для страницы. Здесь также каждому слову (8 байт) соответствует один бит, показывающий, содержит ли это слово указатель. Эти данные поддерживаются аллокатором памяти.

5. Находим активные указатели

Затем мы берём пересечение bitmap указателей/скаляров и bitmap активных слов.
Результат — bitmap активных указателей (active pointer bitmap). Он показывает, где именно находятся указатели в памяти страницы, принадлежащие живым, но ещё не просканированным объектам.

6. Сканируем страницу

Теперь можно пройтись по памяти страницы и собрать все указатели.
Логически это выглядит так:
для каждого установленного бита в bitmap активных указателей
— загрузить значение указателя из соответствующего слова
и записать его в буфер, который позже будет использован для пометки найденных объектов и добавления страниц в список задач.

Благодаря векторным инструкциям, Green Tea может выполнять это по 64 байта за раз, буквально за пару инструкций.

Одним из ключевых факторов скорости является инструкция VGF2P8AFFINEQB,
входящая в расширение x86 Galois Field New Instructions — тот самый «швейцарский нож» для побитовых манипуляций, о котором говорилось выше.
Она позволяет выполнять шаг (3) — расширение bitmap — с поразительной эффективностью.

Эта инструкция выполняет аффинное побитовое преобразование, рассматривая каждый байт регистра как вектор из 8 бит и умножая его на 8×8-битную матрицу. Все операции происходят над полем GF(2), где умножение — это AND, а сложение — XOR. В результате можно заранее задать несколько таких 8×8-битных матриц для разных размеров объектов, чтобы выполнять нужное расширение битов 1:n буквально за один векторный шаг.

Полный ассемблерный код можно посмотреть в этом файле. «Расширители» используют разные матрицы и разные перестановки для каждого класса размеров, поэтому они вынесены в отдельный файл, который генерируется автоматически.
Помимо функций расширения, кода там совсем немного. Большую часть удаётся существенно упростить благодаря тому, что почти все операции можно выполнять с данными, находящимися исключительно в регистрах.
И, надеемся, совсем скоро этот ассемблерный код будет заменён на код на Go!

Отдельное спасибо Остину Клементсу за разработку этого подхода. Это невероятно круто — и невероятно быстро!

Оценка

Вот и всё о том, как это работает. А насколько это действительно помогает?

Оказалось, что очень даже существенно. Даже без векторных улучшений мы видим снижение затрат CPU на сборку мусора от 10% до 40% в нашем наборе бенчмарков. Например, если приложение тратит 10% своего времени на работу сборщика мусора, это даст от 1% до 4% общего снижения загрузки CPU — в зависимости от конкретной нагрузки. Типичное улучшение — около 10% снижения CPU, занятого сборкой мусора. (Подробнее см. в обсуждении на GitHub)

Мы уже внедрили Green Tea внутри Google и наблюдаем аналогичные результаты на масштабах компании.

Мы всё ещё раскатываем векторные улучшения, но тесты и первые результаты показывают, что это принесёт дополнительные 10% снижения затрат CPU сборщика мусора.

Хотя большинство нагрузок выигрывают в той или иной степени, есть и такие, которым это не помогает.

Green Tea основан на гипотезе, что можно накопить достаточно объектов для сканирования на одной странице за один проход, чтобы компенсировать издержки самого процесса накопления. Это явно работает, если куча имеет достаточно регулярную структуру: объекты одинакового размера на схожей глубине в графе объектов. Но бывают нагрузки, где приходится сканировать лишь один объект на странице за раз. Это может оказаться даже хуже, чем «заливка графа» (graph flood), потому что мы тратим больше ресурсов на попытку накопить объекты на странице — и не достигаем этого.

В реализации Green Tea предусмотрен особый случай для страниц, где нужно сканировать только один объект. Это помогает сократить деградации, но полностью их не устраняет.

Однако, чтобы превзойти подход с заливкой графа, нужно гораздо меньше накопления на страницу, чем можно было ожидать. Один из удивительных результатов работы — то, что сканирование всего лишь 2% страницы за один раз уже даёт выигрыш по сравнению с graph flood.

Доступность

Green Tea уже доступен в экспериментальном режиме в свежем релизе Go 1.25. Его можно включить, установив переменную окружения GOEXPERIMENT=greenteagc при сборке. Это пока не включает векторное ускорение, о котором говорилось выше.

Мы ожидаем, что Green Tea станет сборщиком мусора по умолчанию в Go 1.26, но при желании вы сможете отключить его, установив GOEXPERIMENT=nogreenteagc при сборке. В Go 1.26 также появится векторное ускорение для современных процессоров x86 и множество доработок, основанных на собранных отзывах.

Если можете — попробуйте Green Tea на свежей версии Go из ветки tip-of-tree! Если предпочитаете Go 1.25 — всё равно будем рады вашим отзывам. В этом комментарии на GitHub указано, какие диагностические данные нас интересуют, если вы можете ими поделиться, и какие каналы связи предпочтительнее для обратной связи.

Русскоязычное Go сообщество

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!

Пройденный путь

Прежде чем закончить эту статью, давайте немного поговорим о пути, который привёл нас сюда. О человеческой стороне технологии.

На первый взгляд может показаться, что в основе Green Tea лежит одна простая идея — вспышка вдохновения у одного человека.

Но это совсем не так. Green Tea — результат работы и идей многих людей на протяжении нескольких лет. Несколько участников команды Go внесли свой вклад в концепцию, включая Майкла Пратта, Черри Муй, Дэвида Чейза и Кита Рэндалла. Микроархитектурные идеи Ивса Вандриесса из Intel также сильно повлияли на направление проектирования. Было множество идей, которые не сработали, и огромное количество деталей, которые пришлось проработать, чтобы сделать эту, казалось бы, простую идею реально жизнеспособной.

Хронология, отражающая лишь часть идей, которые мы перепробовали, прежде чем прийти к нынешнему решению.
Зарождение этой идеи уходит корнями в 2018 год. Забавно, что каждый в команде уверен, будто это придумал кто-то другой.

Название Green Tea появилось в 2024 году, когда Остин создал прототип ранней версии, путешествуя по кофейням Японии и выпивая много матча! Этот прототип показал, что основная идея Green Tea действительно жизнеспособна. С этого момента всё закрутилось.

На протяжении 2025 года Майкл занимался реализацией и внедрением Green Tea в продакшн, и идеи продолжали развиваться и меняться.

Этот путь потребовал огромного совместного поиска, ведь Green Tea — это не просто алгоритм, а целое пространство проектных решений. Пространство, в котором никто из нас не смог бы разобраться в одиночку. Недостаточно просто придумать идею — нужно проработать детали и доказать её работоспособность. И теперь, когда мы это сделали, можно наконец-то начинать итерации.

Будущее Green Tea выглядит многообещающе!

Комментарии (0)