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

Когда V8 аллоцирует Context (и чего это стоит)

V8 аллоцирует Context только когда внутренняя функция захватывает переменную; иначе переменные в регистрах, бесплатны. Цикл с let, чьё тело захватывает привязку, аллоцирует новый Context на итерацию — реальная цена в горячих циклах. Как сократить захваты.

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

Вы меняете for (var i …) на for (let i …), потому что так сказал линтер, и микробенчмарк, строящий миллион колбэков, ощутимо замедляется. В вашей логике ничего не изменилось. Изменилось то, что let даёт каждой итерации собственную привязку — и когда тело захватывает эту привязку, V8 обязан аллоцировать совершенно новый кучевой Context на каждом обороте цикла. Семантика, которую вы хотели, пришла с аллокацией, которую вы не увидели.

Правило аллокации: захват и ничего больше

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

Это самая важная модель стоимости в этом юните. Большинство функций, которые вы пишете, аллоцируют ноль Context’ов, потому что они либо не определяют внутренних функций, либо определяют внутренние функции, которые ничего не захватывают. Цена появляется ровно в момент захвата.

function noCapture(xs) {
  let sum = 0;
  for (let i = 0; i < xs.length; i++) sum += xs[i];
  return sum;            // нет внутренней функции -> нет Context, всё в регистрах
}

function capture(xs) {
  let sum = 0;
  xs.forEach(x => sum += x); // стрелка захватывает 'sum' -> 'sum' context-allocated
  return sum;                // один Context для этой области
}

Ловушка на итерацию: let в цикле + захват

let и const, объявленные в заголовке for, создают свежую привязку на каждую итерацию. Это та возможность, которая убирает классический баг «все мои колбэки печатают последнее значение». Но у реализации есть цена, когда тело захватывает эту привязку.

const fns = [];
for (let i = 0; i < n; i++) {
  fns.push(() => i);   // захватывает 'i' ЭТОЙ итерации
}
// Каждый колбэк возвращает свой i: fns[3]() === 3. Корректно!
// Но: НОВЫЙ Context аллоцируется на каждую итерацию, чтобы держать её i.

Чтобы каждое замыкание видело свой i, i каждой итерации должен жить в отдельном месте — поэтому V8 аллоцирует свежий Context на итерацию и копирует туда привязку. При большом n и захватывающем теле это n кучевых аллокаций и n объектов для трассировки GC. Сравните с var, у которого одна привязка на весь цикл:

const fns = [];
for (var i = 0; i < n; i++) {
  fns.push(() => i);   // все захватывают ОДИН И ТОТ ЖЕ 'i'
}
// Каждый колбэк возвращает финальный i (=== n). Одна привязка, один Context.

var аллоцирует здесь максимум один Context (всё же один, потому что привязка захвачена) — но не умножает на n. Широко повторяемый совет «всегда используй let» верен для корректности, но в по-настоящему горячем цикле, создающем замыкания, Context на итерацию — это реальная, измеримая цена.

Аллокации Context по паттерну (цикл из n)
Цикл, нет внутренней функции
0 Context'ов
Тело захватывает, `var i`
1 Context всего
Тело захватывает, `let i`
n Context'ов (1/итер)
Внутр. fn ничего не захватывает
0 Context'ов
Вынуждает Context: eval / with
всегда
Объектов GC от n замыканий
растёт с n

Что вынуждает Context и как его сократить

Что вынуждает аллокацию Context: любое замыкание над переменной, блок with, прямой eval (он может читать/объявлять локальные) и в некоторых случаях наблюдение arguments способами, которые мешают анализу. Рычаги, чтобы сократить это на горячих путях:

  • Вынесите замыкание из цикла. Если колбэку на самом деле не нужна привязка каждой итерации, определите его один раз вне цикла. Никакого Context на итерацию.
  • Передавайте данные аргументами вместо захвата. Функция, получающая x параметром, его не захватывает; параметры дешевле захваченного слота Context и держат форму вызова предсказуемой (следующий урок).
  • Используйте индекс, а не захваченную привязку, когда нужно значение только в момент вызова. Иногда обычный for с var плюс явная копия — правильный инструмент в горячем цикле на миллион итераций — измеряйте.
  • Держите большие или долгоживущие данные вне захваченных областей (урок об удержании) — отдельная забота от числа аллокаций, но та же дисциплина.

Применённые вместе, эти четыре рычага сокращают и количество объектов Context, которые GC обязан трассировать, и число кучевых аллокаций на горячую итерацию. Без выноса даже тривиальный () => i внутри тесного цикла тихо умножает аллокации на n — именно тот тип затрат, который появляется только в профиле аллокаций, но не в код-ревью.

// Горячий путь: избегаем n Context'ов на итерацию, когда захват не нужен.
function attach(handlers, fn) {            // fn определён ОДИН раз, ничего не захват. на итер
  for (let i = 0; i < handlers.length; i++) handlers[i].on(fn);
}
Викторина

`for (let i = 0; i < n; i++) cbs.push(() => i);` — сколько Context'ов V8 аллоцирует для цикла и почему?

Викторина

Горячий цикл пушит колбэк, которому НЕ нужно значение каждой итерации. Какой фикс самый дешёвый?

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

Расставьте эти циклы по числу аллокаций Context для n итераций — от нуля аллокаций к наибольшему.

  1. 1 Цикл суммирует в локальную, без внутренней функции (0 Context'ов)
  2. 2 Цикл захватывает привязку `var` в колбэке (1 Context)
  3. 3 Цикл захватывает привязку `let` на итерацию (n Context'ов)
  4. 4 Цикл с блоком `with` плюс захват на итерацию (n Context'ов, все неоптимизируемые)
Граничные случаи

V8 не наивен в этом: если замыкание тела цикла доказуемо не убегает, а привязка по сути не наблюдается на итерацию, оптимизатор иногда может избежать материализации различных Context’ов. Но полагаться на это нельзя — escape-анализ хрупок и пасует на многих реальных формах. Считайте захват на итерацию как «n аллокаций, пока не доказано обратное», и проверяйте горячие пути через --trace-gc или профиль аллокаций, а не доверяйте оптимизатору вас спасти.

Вспомните перед уходом
  1. 01
    Что именно вызывает аллокацию Context, а что — нет?
  2. 02
    Почему `for (let …)` может быть медленнее `for (var …)`, когда тело создаёт замыкания, и повод ли это избегать let?
  3. 03
    Как сократить аллокацию контекста на горячем пути?
Итог

Модель стоимости областей видимости в V8 сводится к одному правилу: Context аллоцируется только когда внутренняя функция захватывает переменную. Функции без внутренних функций или те, чьи внутренние функции ничего не захватывают, аллоцируют ноль Context’ов и держат всё в регистрах и слотах стека. Дорогой и легко упускаемый случай — цикл, чьё тело захватывает привязку let/const на итерацию: поскольку каждой итерации нужна своя привязка ради корректной семантики различных значений, V8 аллоцирует свежий Context на каждую итерацию — n итераций, n кучевых объектов, n вещей для трассировки GC — тогда как эквивалентный захваченный var делит одну привязку и один Context. with и прямой нестрогий eval тоже вынуждают Context и вдобавок ломают оптимизацию. Чтобы оставаться дешёвым на горячих путях: определяйте замыкания один раз вне циклов, когда состояние на итерацию не нужно, передавайте данные аргументами вместо захвата и оставляйте захват на итерацию там, где семантика этого по-настоящему требует. Escape-анализ V8 иногда может убрать эти аллокации, но ненадёжен, поэтому измеряйте профилем аллокаций. Сокращение захватов также готовит следующий урок — меньше и единообразнее замыкания держат точки вызова monomorphic. Теперь, когда профиль горячего цикла показывает неожиданно высокое давление на аллокации, первым делом посчитайте замыкания, создаваемые на итерацию, — захваченный let часто и есть весь ответ.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.