Модель исполнения JavaScript по ECMAScript: стек, контексты и замыкания

Многие разработчики уверены, что понимают, как работает call stack в JavaScript: положил кадр, снял кадр, LIFO. Однако при обращении к спецификации ECMAScript (ECMA-262) выясняется, что за «стеком вызовов» стоит каркас из пяти сущностей, а половина привычных объяснений просто не совпадает с текстом

Модель исполнения JavaScript по ECMAScript: стек, контексты и замыкания

Как на самом деле работает выполнение JavaScript в соответствии со спецификацией ECMAScript

Многие разработчики уверены, что понимают, как работает call stack в JavaScript: положил кадр, снял кадр, LIFO. Однако при обращении к спецификации ECMAScript (ECMA-262) выясняется, что за «стеком вызовов» стоит каркас из пяти сущностей, а половина привычных объяснений просто не совпадает с текстом стандарта. Замыкание — не фича языка, а побочный эффект того, что окружение живёт отдельно от кадра. «Область видимости» — вообще не сущность: такого термина там нет. А слов event loop, microtask и task queue в ECMA-262 ровно ноль. Проверяется грепом за минуту. В этой статье разбираем модель исполнения по первоисточнику: агент, контекст, окружения, джобы. Каждое утверждение сверено с текстом стандарта, номера параграфов проставлены — можно перепроверять за мной.

Что такое модель исполнения в ECMAScript

Спецификация ECMAScript (ECMA-262) определяет модель исполнения как набор абстрактных сущностей, которые совместно описывают, как выполняется код. В отличие от многих учебных материалов, стандарт не оперирует понятиями «стек вызовов» в бытовом смысле, а вводит формальные структуры: агент (agent), контекст выполнения (execution context), лексическое окружение (lexical environment), окружение переменной (variable environment) и запись окружения (environment record). Эти сущности определены в разделах 9.2–9.4 спецификации. Агент представляет собой поток выполнения, который может быть главным (main agent) или воркером (worker agent). У каждого агента есть свой стек контекстов выполнения (execution context stack) — это и есть аналог call stack, но формально он называется именно так.

Почему стандарт не использует термин «call stack»?

В спецификации ECMAScript термин «call stack» не встречается. Вместо него используется «execution context stack» (стек контекстов выполнения). Это не просто замена названия: контекст выполнения — более сложная структура, чем просто кадр стека. Каждый контекст содержит не только адрес возврата и локальные переменные, но и ссылки на лексическое окружение, окружение переменной, область видимости приватных полей и другую метаинформацию. Таким образом, call stack — это лишь упрощённая модель для понимания, а реальный стек контекстов выполнения богаче по составу.

Как устроен стек контекстов выполнения

Стек контекстов выполнения (execution context stack) — это структура LIFO, хранящая контексты выполнения. Каждый раз, когда вызывается функция, создаётся новый контекст выполнения и помещается на вершину стека. Когда функция завершается, её контекст снимается. В спецификации это описано в разделе 9.4.1. Контекст выполнения содержит несколько полей, включая Function, Realm, ScriptOrModule, LexicalEnvironment, VariableEnvironment и PrivateEnvironment. Поле LexicalEnvironment ссылается на лексическое окружение, которое определяет, какие идентификаторы доступны в данном контексте. Поле VariableEnvironment — это окружение для переменных, объявленных через var и function declaration. Важно, что эти два окружения могут различаться (например, в функциях с блоками).

Что такое агент в ECMAScript?

Агент (agent) — это абстракция, представляющая поток выполнения. В среде браузера главный агент соответствует основному потоку, а воркер-агенты — веб-воркерам. Каждый агент имеет свой стек контекстов выполнения, очередь заданий (job queue) и набор связанных сущностей. Агенты взаимодействуют через разделяемую память (SharedArrayBuffer) и атомарные операции. В спецификации агенты описаны в разделе 9.2. Понимание агентов важно для анализа параллельного выполнения и асинхронности.

Лексические окружения и замыкания

Лексическое окружение (lexical environment) — это спецификационная сущность, которая связывает идентификаторы с их значениями. Каждое окружение имеет внешнюю ссылку (outer reference) на родительское окружение, образуя цепочку. Когда код обращается к переменной, движок ищет её в текущем окружении, затем во внешнем и так далее. Замыкание (closure) возникает, когда функция сохраняет ссылку на своё лексическое окружение даже после того, как контекст выполнения этой функции был удалён из стека. В спецификации нет специального термина «closure» — это просто побочный эффект того, что окружение живёт отдельно от контекста. Например, если функция возвращается из другой функции и используется позже, её лексическое окружение сохраняется в памяти, потому что на него остаётся ссылка. Это описано в разделе 9.2.12.

Как замыкания связаны с управлением памятью?

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

Как различаются лексическое окружение и окружение переменной?

В контексте выполнения есть два поля: LexicalEnvironment и VariableEnvironment. Первое используется для разрешения идентификаторов, объявленных через let, const и class, а также для формальных параметров функций. Второе — для переменных, объявленных через var и function declaration. В глобальном контексте и в контексте функции без блоков они совпадают. Однако при входе в блок (например, if или for) создаётся новое лексическое окружение для let и const, а VariableEnvironment остаётся прежним. Это объясняет, почему var «просачивается» за пределы блока, а let — нет. В спецификации это описано в разделе 9.4.2.

Что такое джобы и как они связаны с моделью исполнения?

Джобы (jobs) — это абстрактные единицы работы, которые помещаются в очередь заданий агента. В спецификации джобы описаны в разделе 9.5. Они используются для реализации асинхронных операций, таких как промисы и таймеры. Когда контекст выполнения завершается, агент извлекает следующий джоб из очереди и запускает его. Важно, что джобы выполняются в порядке очереди, но между ними могут быть вставлены другие джобы. Event loop, microtask и task queue — это детали реализации, которых нет в спецификации; вместо них используются абстрактные джобы и очереди.

Почему важно понимать модель исполнения?

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

Итог

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