open atlas
↑ К треку
Внутренности движка JavaScript JSE · 06 · 02

Поколенческий GC и Scavenger

Большинство объектов умирает молодыми, поэтому V8 делит кучу по возрасту. New space — это два semi-space, собираемые Scavenger'ом по копирующему алгоритму Чейни: скопировать выживших, поменять пространства местами, повысить дважды выживших в old space.

JSE Middle ◷ 14 min
Уровень
ОсновыJuniorMiddleSenior

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

Поколенческая гипотеза

Урок browser/03-v8-internals/05-gc-orinoco даёт обзор; здесь мы вскрываем механику Scavenger’а.

Эмпирический фундамент каждого продакшен-GC — поколенческая гипотеза (generational hypothesis): большинство объектов умирает молодыми. Распределение времён жизни аллокаций резко бимодально — подавляющее большинство недолговечны (временные значения запроса, промежуточные строки, React-элементы на рендер) и становятся недостижимыми за несколько миллисекунд, тогда как небольшое меньшинство (кэши, граф модулей, пулы соединений) живёт весь процесс. Почти ничто не умирает посередине.

V8 эксплуатирует это, деля кучу по возрасту:

  • New space (young generation) — где рождается каждый обычный объект. Маленький: бюджет, растущий по требованию примерно до 1–8 МБ на isolate (настраивается через --max-semi-space-size). Собирается часто и дёшево minor GC.
  • Old space (old generation) — долгоживущие выжившие. Может быть от сотен МБ до гигабайт (--max-old-space-size). Собирается нечасто major GC (следующий урок).

Поскольку new space мал и почти всё в нём мертво к моменту сборки, minor GC касается очень малого объёма живых данных — в этом вся выгода.

New space — это два semi-space; аллокация — это bump pointer

New space физически состоит из двух равных половин, называемых semi-space: to-space (активная) и from-space (простаивающая). Аллокация в to-space — самая дешёвая операция в движке — bump-pointer allocator: держим указатель на следующий свободный байт, выдаём его, продвигаем указатель на размер объекта. Никакого поиска по free-list, никакой фрагментации — просто сложение и проверка границ.

// Концептуально, каждый `new`/объектный литерал делает:
//   if (top + size > limit) triggerScavenge();
//   addr = top;
//   top += size;          // продвижение указателя (bump)
//   return addr;
const point = { x: 1, y: 2 };   // аллокация bump-pointer'ом в to-space

Когда bump pointer достигает предела (to-space заполнен), срабатывает Scavenge (minor GC).

Scavenger: копирующий алгоритм Чейни

Scavenger — это копирующий сборщик (copying collector), исполняющий алгоритм Чейни. Ключевая инверсия по сравнению с mark-sweep: он вообще не смотрит на мёртвые объекты. Он копирует живые и оптом бросает остальное.

  1. Flip. Роли меняются: текущая to-space становится from-space, пустая половина — новой to-space.
  2. Эвакуация корней. Сканируем корни (стек, глобальные, handle) плюс remembered set указателей old→new (см. урок 04 — old space может ссылаться на молодые объекты, а мы old space не сканируем). Копируем каждый живой молодой объект, на который ссылаются, из from-space в to-space, оставляя в старом слоте forwarding pointer.
  3. Скан Чейни. Обходим только что скопированные объекты в to-space «вширь» как рабочую очередь; для каждого копируем любые объекты from-space, на которые он ссылается (или идём по существующему forwarding pointer, если уже скопирован), обновляя указатель на новое место. Это продолжается, пока указатель скана не догонит указатель аллокации — очередь пуста.
  4. Освобождение неявно. Всё, что не скопировано, просто остаётся в from-space, которая теперь одним махом объявляется пустой. Освобождение мёртвых объектов не стоит ничего — их никогда не посещают.

Работа пропорциональна живому множеству, а не аллоцированному. Пока поколенческая гипотеза держится, живое множество крошечно, поэтому Scavenge быстр.

Promotion: выжить — значит постареть

Объект, переживший Scavenge, ещё не признаётся долгоживущим; его копируют в to-space и дают ещё один шанс. Если он переживает второй Scavenge, V8 заключает, что он, вероятно, долгоживущий, и повышает его (promotion): копирует в old space, а не обратно в semi-space. (V8 также повышает раньше под давлением «промежуточного» поколения и аллоцирует очень большие объекты сразу в old/large-object space, но «выжить дважды → повысить» — это модель, которую стоит держать в голове.)

Promotion — причина, по которой Scavenge может оставить new space почти пустым: временные объекты никогда не копировались (мертвы), а немногие настоящие выжившие окончили в old space. New space остаётся маленьким, и следующий Scavenge остаётся дешёвым.

Scavenger в цифрах
Размер new space (semi-space)
~1–8 МБ
Пауза minor GC (Scavenge)
доли мс — единицы мс
Порог promotion
пережить ~2 Scavenge
Стоимость аллокации
bump pointer (~O(1))
Работа пропорциональна
живому множеству, не аллоцированному
Параллельный scavenger с
V8 6.2 (2017)

Orinoco: делаем параллельным

Orinoco — зонтичное имя современного GC-проекта V8: набора техник, превращающих stop-the-world сборщик в по большей части конкурентный, параллельный и инкрементальный. Конкретно для Scavenger Orinoco сделал его параллельным: несколько вспомогательных потоков делят работу копирования через динамический work-stealing, так что время паузы по стенным часам падает, хотя сырой работы становится больше. Поскольку new space мал, minor-паузы укладываются в диапазон от долей миллисекунды до единиц миллисекунд — достаточно коротко, чтобы комфортно поместиться в кадр анимации 16,6 мс. Более тяжёлые конкурентность и инкрементальность относятся в основном к major GC — это следующие два урока.

Викторина

Цикл аллоцирует 1 000 000 недолговечных объектов, из которых при каждом Scavenge достижимо менее 100. Примерно с чем масштабируется стоимость каждого Scavenge?

Викторина

Объект, аллоцированный в new space, всё ещё достижим после двух Scavenge. Куда он попадёт и почему?

Расставь шаги по порядку

Расставьте по порядку один цикл Scavenge (minor GC).

  1. 1 Bump pointer достигает предела to-space; аллокация запускает Scavenge
  2. 2 Flip: to-space становится from-space, пустая половина — новой to-space
  3. 3 Скопировать живые молодые объекты корней и remembered set в to-space, оставляя forwarding pointer
  4. 4 Скан Чейни: обойти скопированные объекты, скопировать то, на что они ссылаются, обновить указатели
  5. 5 Повысить объекты, пережившие второй Scavenge, в old space
  6. 6 Бросить from-space оптом — мёртвые объекты освобождены неявно
Граничные случаи

В «копировать дёшево» спрятана реальная стоимость: только что повышенный объект может нести указатели в ставшие устаревшими места, а насыщенные указателями выжившие делают Scavenge медленнее, чем чисто временная нагрузка с тем же числом аллокаций. Быстрый путь предполагает низкую долю выживания. Нагрузка, которая аллоцирует и удерживает большую долю новых объектов (строит, скажем, один гигантский массив), побеждает поколенческую ставку — выжившие копируются раз за разом, пока не будут повышены, и вы видите больше времени minor GC, чем предсказывает одно лишь число аллокаций.

Вспомните перед уходом
  1. 01
    Пройдите по одному циклу Scavenge (minor GC) в V8.
  2. 02
    Почему копирующий сборщик делает недолговечный мусор по сути бесплатным для освобождения?
  3. 03
    Что такое поколенческая гипотеза и как её эксплуатирует раскладка кучи V8?
Итог

Поколенческая гипотеза — большинство объектов умирает молодыми — определяет двухобластную кучу V8. New space мал (~1–8 МБ) и держит свежеаллоцированные объекты; аллокация там — это bump pointer, самая дешёвая операция в движке. Когда он заполняется, запускается minor GC (Scavenge), исполняющий копирующий алгоритм Чейни: сделать flip двух semi-space, скопировать живые молодые объекты из from-space в to-space (оставляя forwarding pointer и следуя remembered set для ссылок old→young), обойти копии вширь, чтобы эвакуировать то, на что они ссылаются, затем бросить from-space оптом. Поскольку копируются только живые объекты, мёртвые не стоят ничего, и Scavenge масштабируется с числом выживших — крошечным по гипотезе — удерживая minor-паузы субмиллисекундными. Объекты, пережившие два Scavenge, повышаются в большой old space, собираемый отдельно и редко через major GC. Orinoco, GC-проект V8, сделал Scavenger параллельным через work-stealing по вспомогательным потокам, ещё сильнее снизив время паузы по стенным часам. Теперь, когда в профиле скачет время minor GC, вы знаете, что проверить: доля выживших — если нагрузка удерживает большую часть того, что аллоцирует, поколенческая ставка сломана, и --trace-gc покажет это напрямую.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки
встречается в208

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.