Spec-Zone.ru › JavaScript

Модель выполнения JavaScript

Эта страница знакомит с базовой инфраструктурой среды выполнения JavaScript. Модель в значительной степени теоретическая и абстрактная, без каких-либо специфических для платформы или реализации деталей. Современные движки JavaScript в значительной степени оптимизируют описанную семантику.

Эта страница является справочной. Предполагается, что вы уже знакомы с моделью выполнения других языков программирования, таких как C и Java. В ней широко используются существующие концепции операционных систем и языков программирования.

Движок и хост

Выполнение JavaScript требует сотрудничества двух программных компонентов: движка JavaScript и хост-среды.

Движок JavaScript реализует язык ECMAScript (JavaScript), предоставляя основную функциональность. Он принимает исходный код, анализирует его и выполняет. Однако, чтобы взаимодействовать с внешним миром, например, для вывода какого-либо осмысленного результата, взаимодействия с внешними ресурсами или реализации механизмов, связанных с безопасностью или производительностью, нам нужны дополнительные специфичные для среды механизмы, предоставляемые хост-средой. Например, HTML DOM является хост-средой при выполнении JavaScript в веб-браузере. Node.js — это другая хост-среда, которая позволяет запускать JavaScript на стороне сервера.

Хотя в этом справочнике мы в основном фокусируемся на механизмах, определенных в ECMAScript, мы будем иногда говорить о механизмах, определенных в спецификации HTML, которая часто имитируется другими хост-средами, такими как Node.js или Deno. Таким образом, мы можем дать согласованную картину модели выполнения JavaScript, используемой в вебе и за его пределами.

Модель выполнения агента

В спецификации JavaScript каждый автономный исполнитель JavaScript называется агентом, который поддерживает свои средства для выполнения кода:

  • Куча (Heap) (объектов): это просто название, обозначающее большой (в основном неструктурированный) регион памяти. Он заполняется по мере создания объектов в программе. Обратите внимание, что в случае разделяемой памяти каждый агент имеет свою собственную кучу со своей версией объекта SharedArrayBuffer, но базовая память, представленная буфером, разделяется.
  • Очередь (заданий): это известно в HTML (а также в общем случае) как цикл событий, который обеспечивает асинхронное программирование в JavaScript, будучи однопоточным. Он называется очередью, потому что обычно работает по принципу "первый пришел — первый ушел": более ранние задания выполняются перед более поздними.
  • Стек (контекстов выполнения): это то, что известно как стек вызовов и позволяет передавать управление путем входа и выхода из контекстов выполнения, таких как функции. Он называется стеком, потому что работает по принципу "последний пришел — первый ушел". Каждое задание начинается с помещения новой рамки в (пустой) стек и завершается опустошением стека.

Это три различных структуры данных, которые отслеживают различную информацию. Мы подробнее рассмотрим очередь и стек в следующих разделах. Чтобы узнать больше о том, как выделяется и освобождается память в куче, см. управление памятью.

Каждый агент аналогичен потоку (обратите внимание, что базовая реализация может быть реальным потоком операционной системы или нет). Каждый агент может владеть несколькими реамами (которые 1-к-1 соотносятся с глобальными объектами), которые могут синхронно получать доступ друг к другу, и поэтому должен работать в одном потоке выполнения. Агент также имеет единую модель памяти, указывающую, является ли он little-endian, может ли он быть синхронно заблокирован, являются ли атомарные операции бесблокировочными и т. д.

Агент в вебе может быть одним из следующих:

  • Агент окна с одинаковым источником (Similar-origin window agent), который содержит различные Window объекты, которые потенциально могут достигать друг друга, напрямую или с использованием document.domain. Если окно ориентировано на источник, то только окна с одинаковым источником могут достигать друг друга.
  • Агент выделенного рабочего (Dedicated worker agent), содержащий единственный DedicatedWorkerGlobalScope.
  • Агент общего рабочего (Shared worker agent), содержащий единственный SharedWorkerGlobalScope.
  • Агент служебного рабочего (Service worker agent), содержащий единственный ServiceWorkerGlobalScope.
  • Агент рабочего модуля (Worklet agent), содержащий единственный WorkletGlobalScope.

Другими словами, каждый рабочий создает свой собственный агент, в то время как одно или несколько окон могут находиться в одном и том же агенте — обычно это основной документ и его iframe с одинаковым источником. В Node.js доступна аналогичная концепция под названием рабочие потоки.

Диаграмма ниже иллюстрирует модель выполнения агентов:

A diagram consisting of two agents: one HTML page and one worker. Each has its own stack containing execution contexts, heap containing objects, and queue containing jobs.

Реалмы

Каждый агент владеет одним или несколькими реамами. Каждый фрагмент кода JavaScript ассоциируется с реамом при загрузке, что остается неизменным даже при вызове из другого реама. Реалм состоит из следующей информации:

  • Список встроенных объектов, таких как Array, Array.prototype и т. д.
  • Глобально объявленные переменные, значение globalThis и глобальный объект
  • Кэш массивов шаблонных литералов, поскольку оценка одного и того же выражения с тегированным шаблонным литералом всегда приводит к тому, что тег получает тот же объект массива

В вебе реалм и глобальный объект соотносятся 1-к-1. Глобальным объектом является либо Window, либо WorkerGlobalScope, либо WorkletGlobalScope. Таким образом, например, каждый iframe выполняется в другом реаме, хотя он может находиться в том же агенте, что и родительское окно.

Реалмы обычно упоминаются при обсуждении идентификаторов глобальных объектов. Например, нам нужны такие методы, как Array.isArray() или Error.isError(), потому что массив, созданный в другом реаме, будет иметь другой объект прототипа, чем объект Array.prototype в текущем реаме, поэтому instanceof Array ошибочно вернет false.

Стек и контексты выполнения

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

  • Состояние оценки кода
  • Модуль или скрипт, функция (если применимо) и текущий выполняющийся генератор, содержащий этот код
  • Текущий реалм
  • Привязки, включая:
    • Переменные, определенные с помощью var, let, const, function, class и т. д.
    • Приватные идентификаторы, такие как #foo, которые действительны только в текущем контексте
    • this ссылка

Представьте программу, состоящую из одного задания, определяемого следующим кодом:

function foo(b) {
  const a = 10;
  return a + b + 11;
}

function bar(x) {
  const y = 3;
  return foo(x * y);
}

const baz = bar(7); // assigns 42 to baz
  1. Когда задание начинается, создается первая рамка, в которой определены переменные foo, bar и baz. Она вызывает bar с аргументом 7.
  2. Создается вторая рамка для вызова bar, содержащая привязки для параметра x и локальной переменной y. Сначала выполняется умножение x * y, затем вызывается foo с результатом.
  3. Создается третья рамка для вызова foo, содержащая привязки для параметра b и локальной переменной a. Сначала выполняется сложение a + b + 11, затем возвращается результат.
  4. Когда foo возвращается, верхний элемент стека извлекается из стека, а выражение вызова foo(x * y) разрешается в возвращаемое значение. Затем выполнение продолжается, что означает просто возврат этого результата.
  5. Когда bar возвращается, верхний элемент стека извлекается из стека, а выражение вызова bar(7) разрешается в возвращаемое значение. Это инициализирует baz возвращаемым значением.
  6. Мы достигаем конца исходного кода задания, поэтому стековая рамка для точки входа извлекается из стека. Стек пуст, поэтому задание считается завершенным.

Генераторы и повторный вход

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

function* gen() {
  console.log(1);
  yield;
  console.log(2);
}

const g = gen();
g.next(); // logs 1
g.next(); // logs 2

В этом случае вызов gen() сначала создает контекст выполнения, который приостановлен — код внутри gen еще не выполняется. Генератор g сохраняет этот контекст выполнения внутри себя. Текущий выполняющийся контекст остается точкой входа. При вызове g.next() контекст выполнения для gen помещается в стек, и код внутри gen выполняется до выражения yield. Затем контекст выполнения генератора приостанавливается и удаляется из стека, возвращая управление обратно точке входа. При повторном вызове g.next() контекст выполнения генератора снова помещается в стек, и код внутри gen возобновляется с того места, где он остановился.

Хвостовые вызовы

Одним из механизмов, определенных в спецификации, является собственный хвостовой вызов (PTC). Вызов функции является хвостовым вызовом, если вызывающий объект после вызова ничего не делает, кроме как возвращает значение:

function f() {
  return g();
}

В этом случае вызов g является хвостовым вызовом. Если вызов функции находится в хвостовой позиции, движок обязан отбросить текущий контекст выполнения и заменить его контекстом хвостового вызова, вместо того чтобы помещать новую рамку для вызова g(). Это означает, что хвостовая рекурсия не подвержена ограничениям размера стека:

function factorial(n, acc = 1) {
  if (n <= 1) return acc;
  return factorial(n - 1, n * acc);
}

В реальности отбрасывание текущей рамки вызывает проблемы при отладке, потому что если g() генерирует ошибку, f больше не находится в стеке и не появится в трассировке стека. В настоящее время только Safari (JavaScriptCore) реализует PTC, и они изобрели некоторую специальную инфраструктуру для решения проблемы отлаживаемости.

Замыкания

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

let f;
{
  let x = 10;
  f = () => x;
}
console.log(f()); // logs 10

Очередь заданий и цикл событий

Агент — это поток, что означает, что интерпретатор может обрабатывать только одно выражение за раз. Когда код полностью синхронный, это нормально, потому что мы всегда можем продвигаться вперед. Но если код требует выполнения асинхронного действия, то мы не можем продвигаться, пока это действие не будет завершено. Однако, если это остановит всю программу, это нанесет ущерб пользовательскому опыту — природа JavaScript как языка веб-скриптинга требует, чтобы он был никогда не блокирующим. Следовательно, код, обрабатывающий завершение этого асинхронного действия, определяется как обратный вызов. Этот обратный вызов определяет задание, которое помещается в очередь заданий — или, в терминологии HTML, цикл событий — после завершения действия.

Каждый раз агент извлекает задание из очереди и выполняет его. При выполнении задания могут быть созданы новые задания, которые добавляются в конец очереди. Задания также могут быть добавлены через завершение асинхронных механизмов платформы, таких как таймеры, ввод-вывод и события. Задание считается выполненным, когда стек пуст; затем из очереди извлекается следующее задание. Задания могут извлекаться с неравномерным приоритетом — например, циклы событий HTML разделяют задания на две категории: задачи и микрозадачи. Микрозадачи имеют более высокий приоритет, и очередь микрозадач опустошается первой, прежде чем будет извлечена очередь задач. Для получения дополнительной информации ознакомьтесь с руководством по микрозадачам HTML. Если очередь заданий пуста, агент ожидает добавления новых заданий.

"Выполнить до завершения"

Каждое задание обрабатывается полностью до обработки любого другого задания. Это дает некоторые приятные свойства при рассуждении о вашей программе, включая тот факт, что всякий раз, когда выполняется функция, она не может быть прервана и будет выполняться полностью перед выполнением любого другого кода (и может изменять данные, с которыми работает функция). Это отличается, например, от C, где если функция выполняется в потоке, она может быть остановлена в любой момент системой времени выполнения для выполнения некоторого другого кода в другом потоке.

Например, рассмотрим следующий пример:

const promise = Promise.resolve();
let i = 0;
promise.then(() => {
  i += 1;
  console.log(i);
});
promise.then(() => {
  i += 1;
  console.log(i);
});

В этом примере мы создаем уже разрешенный промис, что означает, что любой присоединенный к нему обратный вызов будет немедленно запланирован как задания. Два обратных вызова кажутся вызывающими условие гонки, но на самом деле вывод полностью предсказуем: 1 и 2 будут выведены по порядку. Это происходит потому, что каждое задание выполняется до завершения перед выполнением следующего, поэтому общий порядок всегда i += 1; console.log(i); i += 1; console.log(i);, а не i += 1; i += 1; console.log(i); console.log(i);.

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

Никогда не блокируется

Еще одна важная гарантия, предоставляемая моделью цикла событий, заключается в том, что выполнение JavaScript никогда не блокируется. Обработка ввода-вывода обычно выполняется через события и обратные вызовы, поэтому, когда приложение ожидает возврата запроса IndexedDB или запроса fetch(), оно все равно может обрабатывать другие вещи, такие как ввод пользователя. Код, который выполняется после завершения асинхронного действия, всегда предоставляется в виде функции обратного вызова (например, обработчика then() промиса, функции обратного вызова в setTimeout() или обработчика событий), которая определяет задание для добавления в очередь заданий после завершения действия.

Конечно, гарантия "никогда не блокируется" требует, чтобы API платформы был изначально асинхронным, но существуют некоторые устаревшие исключения, такие как alert() или синхронный XHR. Считается хорошей практикой избегать их, чтобы обеспечить отзывчивость приложения.

Кластеры агентов и совместное использование памяти

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

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

  • Объект Window и выделенный рабочий, который он создал.
  • Рабочий (любого типа) и выделенный рабочий, который он создал.
  • Объект Window A и объект Window того же источника элемента iframe, который создал A.
  • Объект Window и тот же источник объекта Window, который его открыл.
  • Объект Window и рабочий модуль, который он создал.

Следующие пары глобальных объектов не находятся в одном кластере агентов и, следовательно, не могут совместно использовать память:

  • Объект Window и общий рабочий, который он создал.
  • Рабочий (любого типа) и общий рабочий, который он создал.
  • Объект Window и служебный рабочий, который он создал.
  • Объект Window A и объект Window элемента iframe, который A создал, и который не может иметь тот же источник, что и A.
  • Любые два объекта Window без отношения открывателя или предка. Это справедливо, даже если два объекта Window имеют одинаковый источник.

Для точного алгоритма проверьте спецификацию HTML.

Межагентская связь и модель памяти

Как уже упоминалось, агенты взаимодействуют через совместное использование памяти. В вебе память совместно используется через метод postMessage(). Руководство использование веб-рабочих дает обзор этого. Обычно данные передаются только по значению (через структурированное клонирование), и поэтому не включает никаких осложнений, связанных с параллелизмом. Для совместного использования памяти необходимо отправить объект SharedArrayBuffer, к которому могут одновременно получать доступ несколько агентов. Как только два агента совместно используют доступ к одной и той же памяти через SharedArrayBuffer, они могут синхронизировать выполнение с помощью объекта Atomics.

Существует два способа доступа к общей памяти: через обычный доступ к памяти (который не является атомарным) и через атомарный доступ к памяти. Последний является последовательно согласованным (что означает, что существует строгий полный порядок событий, согласованный всеми агентами в кластере), в то время как первый является неупорядоченным (что означает, что порядок отсутствует); JavaScript не предоставляет операций с другими гарантиями упорядочивания.

Спецификация предоставляет следующие рекомендации для программистов, работающих с общей памятью:

Мы рекомендуем, чтобы программы были свободны от гонок данных, т. е. сделать так, чтобы было невозможно одновременное неатомарное обращение к одной и той же ячейке памяти. Программы без гонок данных имеют семантику чередования, где каждый шаг в семантике оценки каждого агента чередуется с другими. Для программ без гонок данных нет необходимости понимать детали модели памяти. Детали вряд ли помогут развить интуицию, которая поможет лучше писать ECMAScript.

В более общем плане, даже если программа не свободна от гонок данных, она может иметь предсказуемое поведение, если атомарные операции не участвуют в каких-либо гонках данных, а операции, которые участвуют в гонках, имеют одинаковый размер доступа. Самый простой способ обеспечить, чтобы атомарные операции не участвовали в гонках, — это убедиться, что атомарные и неатомарные операции используют разные ячейки памяти, а атомарные доступы разных размеров не используются для одновременного доступа к одним и тем же ячейкам. Фактически, программа должна трактовать общую память как строго типизированную, насколько это возможно. По-прежнему нельзя полагаться на упорядочивание и время неатомарных доступов, которые участвуют в гонках, но если память рассматривается как строго типизированная, гоночные доступы не будут "рваться" (биты их значений не будут смешиваться).

Параллелизм и обеспечение прогресса

Когда несколько агентов сотрудничают, гарантия никогда не блокируется не всегда выполняется. Агент может быть заблокирован или приостановлен при ожидании выполнения другим агентом некоторого действия. Это отличается от ожидания промиса в том же агенте, потому что это останавливает весь агент и не позволяет выполняться какому-либо другому коду тем временем — другими словами, он не может обеспечить прогресс.

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

  • Каждый разблокированный агент с выделенным потоком выполнения в конечном итоге обеспечивает прогресс.
  • В наборе агентов, которые совместно используют поток выполнения, один агент в конечном итоге обеспечивает прогресс.
  • Агент не вызывает блокировку другого агента, кроме как через явные API, которые обеспечивают блокировку.
  • Заблокированы могут быть только определенные агенты. В вебе это включает выделенные рабочие и общие рабочие, но не окна с одинаковым источником или служебные рабочие.

Кластер агентов обеспечивает некоторый уровень целостности активности своих агентов в случае внешних пауз или завершений:

  • Агент может быть приостановлен или возобновлен без его ведома или согласия. Например, переход с окна может приостановить выполнение кода, но сохранить его состояние. Однако кластеру агентов не разрешается быть частично деактивированным, чтобы избежать голодания агента из-за деактивации другого агента. Например, общие рабочие никогда не находятся в одном кластере агентов с окном-создателем или другими выделенными рабочими. Это связано с тем, что время жизни общего рабочего независимо от документов: если документ деактивируется, а его выделенный рабочий удерживает блокировку, общий рабочий не сможет получить блокировку до тех пор, пока выделенный рабочий не будет снова активирован, если это вообще произойдет. Между тем другие рабочие, пытающиеся получить доступ к общему рабочему из других окон, будут голодать.
  • Аналогично, агент может быть завершен внешними для кластера факторами. Например, операционные системы или пользователи, завершающие процесс браузера, или браузер, принудительно завершающий агент из-за чрезмерного использования ресурсов. В этом случае завершаются все агенты кластера. (Спецификация также допускает вторую стратегию, которая представляет собой API, позволяющий по крайней мере одному оставшемуся члену кластера идентифицировать завершение и завершенного агента, но это не реализовано в вебе.)

Спецификации

Спецификация
Спецификация языка ECMAScript® 2027
Спецификация языка ECMAScript® 2027
HTML

См. также

  • Циклы событий в стандарте HTML
  • Что такое цикл событий? в документации Node.js

© 2005–2025 MDN contributors.
Licensed under the Creative Commons Attribution-ShareAlike License v2.5 or later.
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Execution_model

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API