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

Области видимости, контексты и scope chain

Лексическая область — это разрешение идентификаторов вдоль цепочки. V8 описывает области через ScopeInfo на этапе компиляции и материализует лишь захваченные — как кучевые Context, связанные outer-указателями; большинство чтений — это пара (глубина, слот).

JSE Middle ◷ 13 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Вы читаете переменную с именем total внутри глубоко вложенного колбэка. Движок должен найти, какой именно total вы имеете в виду — и сделать это быстро, на горячем пути, миллионы раз. Наивный ответ — «искать в окружающих функциях во время работы». Настоящий ответ — V8 уже вычислил точное место на этапе компиляции, и во время работы это обычно одна индексированная загрузка из кучевого объекта. До тех пор, пока вы не напишете with или нестрогий eval — и тогда вся схема рушится.

Лексическая область: цепочка, заданная тем, где вы написали код

JavaScript использует лексическую область видимости: смысл идентификатора задаётся текстовой вложенностью функций и блоков, а не тем, кто кого вызвал во время работы. Когда вы ссылаетесь на total, язык задаёт поиск: смотрим в текущей области, затем в охватывающей, затем в следующей, вплоть до глобальной. Побеждает первое найденное объявление. Эта упорядоченная последовательность охватывающих областей и есть scope chain (цепочка областей видимости).

Слово «поиск» наносит большой вред в ментальной модели большинства людей. Линейный поиск именованных переменных во время работы — словарный lookup на каждый доступ — сделал бы каждое чтение переменной в замыкании мучительно медленным. В типичном случае V8 так не делает. Он разбивает работу на две половины: анализ на этапе компиляции, который вычисляет, где живёт каждая переменная, и рантайм-структуру, которая хранит лишь те переменные, которым действительно нужно пережить кадр.

ScopeInfo: чертёж этапа компиляции

Прежде чем вы сможете избегать медленного пути, нужно понять, что V8 строит при компиляции — и почему именно этот чертёж делает большинство чтений переменных дешёвыми. Когда парсер и байт-код-компилятор V8 (Ignition) обрабатывают функцию, они строят объект ScopeInfo для каждой области. ScopeInfo — это чистая метаинформация этапа компиляции, прикреплённая к SharedFunctionInfo функции. Он фиксирует:

  • какие переменные объявляет область (имена),
  • какие из них context-allocated — должны жить в куче, потому что их захватывает внутренняя функция — против stack/register-allocated (чисто локальные),
  • индекс слота, который занимает каждая context-allocated переменная,
  • вид области (function, block, eval, module, with) и ссылку на охватывающий ScopeInfo.

Главное: компилятор обычно может разрешить ссылку на переменную в фиксированную пару — (глубина контекста, индекс слота). «Переменная total находится на две области наружу, в слоте 3». Это разрешение запекается в байт-код инструкцией вида LdaContextSlot. Во время работы поиска по имени не происходит — это индексация массива в известный объект на известной глубине.

Во что обходится разрешение, по видам
Локальная в регистре/стеке
0 загрузок (в рег.)
Слот контекста, глубина 0
1 индекс. загрузка
Каждая лишняя область глубины
+1 hop указателя
Глобальная / script-context
загрузка cell (кэш.)
По-настоящему динамич. (with / sloppy eval)
поиск по имени
ScopeInfo живёт на
SharedFunctionInfo

Context: scope chain во время работы

Во время работы области, которые должны пережить свой кадр стека, материализуются как объекты Context. Context — это небольшой кучевой объект (внутри — массив фиксированного размера, разновидность FixedArray), который хранит context-allocated переменные одной области плюс указатель на свой outer Context. Эта цепочка outer-указателей и есть scope chain во время работы.

Отсюда следуют три вещи, и они управляют всем в этом юните:

  1. В Context попадают только захваченные переменные. Если компилятор может доказать, что переменная чисто локальна — объявлена, использована и ни одной внутренней функцией не упоминается — она остаётся в регистре CPU или на стеке и никогда не аллоцируется в куче. Она исчезает, когда кадр возвращается. Ни одно замыкание её не достанет, потому что ни одного над ней не создавалось.
  2. Context индексируется по слоту, а не по имени. Чтение total на два уровня наружу — это: разыменовать outer, разыменовать outer ещё раз, загрузить слот 3. Фиксированная работа, без хеширования.
  3. Глобальная область особая. Это глобальный объект (globalThis) плюс script context, который хранит top-level let/const. Top-level var и объявления функций становятся свойствами глобального объекта; top-level let/const живут в script context как cell’ы. Поэтому глобальные чтения идут через cell или загрузку свойства, а не через слот контекста, и кэшируются inline cache так же, как чтения свойств объектов.

Вместе правила 1 и 2 означают: кучевую цену платят только те переменные, которые внутренняя функция реально наблюдает — всё остальное бесплатно. Правило 3 объясняет, почему код модуля верхнего уровня имеет немного иную стоимость доступа, чем код внутри функции — это стоит знать, если вы переносите горячие чтения на уровень модуля в ожидании ускорения.

Почему быстрый путь существует — и что его разрушает

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

  • with (obj) внедряет объект, чьи свойства неизвестны до момента работы, поэтому любой идентификатор внутри может разрешиться в свойство obj. Компилятор не может предвычислить слот; он обязан выпустить динамический lookup, который сначала проверяет объект. Это отравляет область для оптимизации.
  • Нестрогий eval может объявить новые переменные в вызывающей области (например, eval строки с var x = 1). Поскольку компилятор не может знать, какие имена внесёт eval, он обязан держать область динамической — переменные больше нельзя разрешить в фиксированные слоты, и охватывающую функцию часто нельзя хорошо оптимизировать.

Strict mode нейтрализует случай eval: eval в strict mode получает собственную область и не может протекать объявлениями наружу, поэтому окружающая область остаётся статически анализируемой. Это одна из конкретных причин по производительности, ради которых существует strict mode.

function fast(rows) {
  let total = 0;                 // захвачена стрелкой -> context-allocated
  rows.forEach(r => total += r); // 'total' разрешается в (глубина 1, слот 0)
  return total;
}

function slow(rows, cfg) {
  with (cfg) {                   // каждый идентификатор ниже теперь динамический
    let total = 0;               // V8 не может доказать, откуда берётся 'limit'
    rows.forEach(r => { if (total < limit) total += r; });
    return total;
  }
}
Викторина

Функция объявляет локальную `n`, инкрементирует её, возвращает и не создаёт внутренних функций. Где V8 хранит `n`?

Викторина

Во что V8 обычно разрешает ссылку на внешнюю переменную на этапе компиляции?

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

Расставьте по порядку, как V8 разрешает чтение внешней переменной на горячем пути — от этапа компиляции до рантайм-загрузки.

  1. 1 Парсер строит дерево областей и ScopeInfo на каждую область
  2. 2 Компилятор помечает захваченные переменные как context-allocated и назначает каждой индекс слота
  3. 3 Ссылка кодируется как фиксированная (глубина контекста, индекс слота) в байт-коде
  4. 4 Во время работы прыгнуть по outer-указателю 'глубина' раз, затем загрузить этот слот из Context
Почему это работает

Почему бы для простоты не класть каждую переменную в Context? Потому что объекты Context — это кучевые аллокации, которые GC обязан отслеживать, а загрузка из кучи куда дороже регистра. Вся стратегия V8 — держать переменные в регистрах/стеке по умолчанию и платить за Context только тогда, когда захват к этому вынуждает. Следующие два урока — ровно о том, когда эта плата возникает и что она удерживает.

Вспомните перед уходом
  1. 01
    Что такое ScopeInfo и чем он отличается от Context?
  2. 02
    Какие переменные попадают в Context, а какие остаются в регистрах или на стеке?
  3. 03
    Почему `with` и нестрогий `eval` делают разрешение областей медленным и как помогает strict mode?
Итог

JavaScript использует лексическую область видимости: смысл идентификатора задан текстовой вложенностью, и разрешение проходит упорядоченную цепочку охватывающих областей вплоть до глобальной. V8 делит это на этап компиляции и время работы. На этапе компиляции парсер строит ScopeInfo на каждую область — фиксируя, какие переменные есть, какие context-allocated из-за захвата внутренней функцией, и индекс слота каждой — так что большинство ссылок компилируются в фиксированную (глубина контекста, индекс слота). Во время работы захваченные области материализуются как объекты Context: небольшие кучевые массивы слотов, связанные outer-указателями, которые и образуют реальную scope chain. Чтение внешней переменной — это несколько hop’ов по указателям плюс одна индексированная загрузка, а не поиск по имени. Переменные, которые компилятор доказывает локальными, остаются в регистрах или на стеке и освобождаются с кадром. Глобальная область — это глобальный объект плюс script context для top-level let/const. Быстрый путь зависит от того, что компилятор знает расположение статически, и именно это разрушают with и нестрогий eval, вынуждая динамический поиск по имени — strict mode сохраняет его для eval. Теперь, когда профайлер покажет неожиданно медленное чтение в горячей внутренней функции, первый вопрос — есть ли где-то в этой scope chain with или нестрогий eval: именно они обрушивают быстрый путь (глубина, слот) в поиск по имени во время работы.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.