Как JavaScript выполняет ваш код: стек, куча, цикл событий и генераторы
Экскурсия по среде выполнения JavaScript: стек вызовов и фреймы, ссылки на кучу, цикл событий Node.js, DOM, обратные вызовы, обещания, async/await и генераторы.
Источники: JavaScript
Асинхронный JavaScript часто ведет себя так, что удивляет разработчиков: строка лога появляется «слишком рано», оператор await кажется пропускающим определенные этапы, генератор выводит только половину своих сообщений. Почти каждое из таких удивлений исчезает, как только у вас появляется четкая модель того, как среда выполнения обрабатывает код. В этой статье эта модель построется постепенно — от стека вызовов и кучи памяти через цикл событий Node.js и DOM до обещаний, синтаксиса async/await и генераторов, чтобы вы могли предсказать результат выполнения каждого фрагмента кода еще до его прочтения.
Компоненты среды выполнения
JavaScript работает на одном основном потоке, тем не менее программы могут ожидать таймеров, сетевых ответов и файлов без замедления работы, поскольку движок не действует в одиночку:
- Стек вызовов, отслеживающий функции, которые в данный момент выполняются
Ваш код выполняется в стеке вызовов, в то время как среда выполнения обрабатывает медленные операции. По завершении такой операции её обратный вызов добавляется в очередь, и цикл событий помещает его в стек, как только тот освобождается.
Краткий обзор цикла событий Node.js
На диаграмме показан цикл Node.js. Сначала выполняется синхронный код, затем микозадачи из очереди, после чего идут этапы цикла: таймеры (setTimeout, setInterval), ожидающие обратные вызовы, этап опроса (где выполняются большинство обратных вызовов, связанных с файлами и сетью), этап проверки (setImmediate) и обратные вызовы при закрытии.
┌───────────────────┐
│ CALL STACK │
│ JavaScript │
└─────────┬─────────┘
│
│ Synchronous code finishes
▼
┌───────────────────┐
│ MICROTASK QUEUE │
│ │
│ Promise.then() │
│ Promise.catch() │
│ queueMicrotask() │
└─────────┬─────────┘
│
▼
╔═════════════════════════════════════════════╗
║ EVENT LOOP ║
║ ║
║ ┌───────────────┐ ║
║ │ TIMERS │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ PENDING │ ║
║ │ CALLBACKS │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ POLL │ ║
║ │ I/O │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ CHECK │ ║
║ │ setImmediate │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ CLOSE │ ║
║ │ CALLBACKS │ ║
║ └───────┬───────┘ ║
║ └───────────────────────────────┐ ║
║ │ ║
╚══════════════════════════════════════════╪══╝
│
▼
┌───────────────────┐
│ CALL STACK │
│ Execute JS │
└───────────────────┘
Важны два аспекта. Микрозадачи обрабатываются не только один раз на цикл: Node очищает очередь микрозадач (а также очередь process.nextTick перед ней) после каждого обратного вызова, поэтому обработчик .then обещания выполняется раньше следующего обратного вызова таймера. Браузеры также следуют принципу обработки задач и микрозадач, но у них нет функции setImmediate или фаз libuv, к тому же они смешивают процессы отрисовки. Подробнее о специфике Node см. как взаимодействуют libuv, цикл событий и пул потоков.
Стек вызовов
Стек вызовов отслеживает, какая функция в настоящее время выполняется и какие функции ожидают её завершения. Это структура типа LIFO — последний добавленный, первый удаляется: функция, вызванная недавно, должна завершиться, прежде чем функция ниже в стеке сможет продолжить работу.
В следующем примере func1 вызывает func2, который в свою очередь вызывает func3, и тот записывает сообщение:
function func1() {
func2();
}
function func2() {
func3();
}
function func3() {
console.log("Hello");
}
func1();
Вызовы происходят в следующем порядке:
func1()
↓
func2()
↓
func3()
↓
console.log()
Как стопка тарелок, console.log выполняется в последнюю очередь, но первым выводит результат, затем поочередно возвращаются func3, func2 и func1. Коллбэки, находящиеся в очереди, могут выполниться только тогда, когда стек опустеет, именно поэтому длительный синхронный цикл блокирует всё остальное.
Хранилище
Хранилище — это область памяти для данных, срок жизни которых не связан с одним конкретным вызовом, таких как объекты, массивы и замыкания.
При создании литерала объекта он размещается именно там:
const user = {
name: "Rayane",
age: 20
};
Переменная хранит лишь ссылку на объект, сохранённый в хранилище:
CALL STACK HEAP
┌───────────────┐ ┌────────────────────┐
│ user variable │─────────▶│ { │
│ │ │ name: "Rayane", │
└───────────────┘ │ age: 20 │
│ } │
└────────────────────┘
При присваивании переменной user другой переменной копируется ссылка, а не сам объект, поэтому изменения в любой из них видны и в другой. Как только от объекта не остается доступных ссылок, коллектор мусора может освободить его; долгоживущая замыкание, захватывающая объект, сохраняет его в активном состоянии.
Что координирует цикл событий
Цикл событий связывает ваш код с завершением асинхронных операций. В Node.js код выполняется движком V8; вызов API Node, такой как чтение файла, передается в libuv, которая затем передаёт его операционной системе или её пулу потоков. После завершения операции кallback становится готовым, и цикл событий в конечном итоге выполняет его на стеке.
JavaScript code
│
▼
V8 Engine
│
▼
Call Stack
│
▼
Node.js APIs
│
▼
libuv
│
├───────────────┐
▼ ▼
OS Thread Pool
│ │
└───────┬───────┘
▼
Asynchronous operation
│
▼
Callback becomes ready
│
▼
Event Loop
│
▼
Callback Queue
│
▼
Call Stack
Маршрут зависит от типа операции: сокеты обычно обрабатываются операционной системой с помощью epoll или kqueue, в то время как работы с файловой системой, некоторые криптографические операции и запросы DNS выполняются с использованием пула потоков.
Стековые кадры
Каждый вызов добавляет в стек кадр, содержащий то, что необходимо функции во время её выполнения:
- ее параметры
- её локальные переменные
- адрес возврата, то есть место, где продолжается выполнение в функции-звонке
- другая информация для управления движком
Возьмём функцию, которая складывает два числа:
function add(a, b) {
const result = a + b;
return result;
}
add(2, 3);
Во время выполнения add(2, 3) её кадр содержит аргументы, локальный результат и ссылку на функцию-звонок:
CALL STACK
┌─────────────────────┐
│ stack frame: add │
│ │
│ a = 2 │
│ b = 3 │
│ result = 5 │
│ return → caller │
└─────────────────────┘
После возврата кадр удаляется:
CALL STACK
┌─────────────────────┐
│ frame removed │
└─────────────────────┘
Весь жизненный цикл происходит быстро:
Function call
↓
Stack Frame created
↓
Function executes
↓
Function returns
↓
Stack Frame removed
Рекурсия, идущая слишком глубоко, накапливает кадры до тех пор, пока движок не выдаст ошибку RangeError из-за превышения максимального размера стека вызовов.
DOM
Модель объектов документа — это динамическая структура узлов браузера, создаваемая на основе HTML-документа. С её помощью JavaScript может:
- читать элементы
- изменять их содержимое
- добавлять новые элементы
- удалять существующие
- модифицировать стили и атрибуты
DOM — это API браузера, а не часть языка, поэтому переменная document отсутствует в обычном Node.js.
Вот пример заголовка в HTML:
<h1 id="title">Hello</h1>
Скрипт может получить ссылку на этот элемент по его id:
const title = document.getElementById("title");
а затем заменить его текст:
title.textContent = "Hello Rayane!";
До изменения дочерний узел текста элемента содержал исходную строку:
document
│
▼
<h1 id="title">
│
▼
"Hello"
После выполнения той же операции присваивания:
title.textContent = "Hello Rayane!";
структура отражает новый текст:
document
│
▼
<h1 id="title">
│
▼
"Hello Rayane!"
Затем браузер перерисовывает содержимое. В реальном коде необходимо проверять значение null, которое возвращает метод getElementById при отсутствии соответствующего элемента; кроме того, для строк из ненадежных источников следует предпочитать использование textContent вместо innerHTML, поскольку последний никогда не парсит HTML.
Обратные вызовы
Обратный вызов — это функция, передаваемая другой функции для её последующего выполнения. В этом примере функция greet передаётся функции execute, которая вызывает её с указанием имени:
function greet(name) {
console.log(`Hello ${name}`);
}
function execute(callback) {
callback("Ajami");
}
execute(greet);
Программа выводит:
Hello Ajami
Внутри функции execute параметр с именем callback соответствует функции greet, и эта строка вызывает её:
callback("Ajami");
Обратный вызов по своей сути не является асинхронным; данный пример выполняется немедленно. Он становится асинхронным, когда функция-получатель вызывает его после какого-либо события, например, по истечении таймера.
Обещания
Обещание представляет собой окончательный результат асинхронной операции. Вы запускаете операцию, продолжаете выполнять другой код и обрабатываете результат, когда обещание достигнет своего состояния.
fetch — это типичный API, возвращающий обещание:
const promise = fetch("https://api.example.com/data");
Вы реагируете на результат, привязывая обработчики:
.then()— для успешного результата.catch()— при сбое.finally()— для операций очистки, которые выполняются в любом случае
fetch("https://api.example.com/data")
.then((response) => {
console.log("Request finished");
})
.catch((error) => {
console.log("Something went wrong:", error.message);
})
.finally(() => {
console.log("Request completed");
});
Запуск запроса не блокирует выполнение кода; обработчики выполняются позже в качестве микотасков. Обратите внимание, что fetch отклоняет обещание только при сетевых ошибках. Статусы 404 или 500 всё равно приводят к выполнению обещания, поэтому проверяйте значение response.ok самостоятельно.
Состояния обещания
Обещание всегда находится в одном из трёх состояний:
PENDING
│
├───────────────┐
▼ ▼
FULFILLED REJECTED
- В ожидании: операция ещё не завершена.
- Завершена: она успешно завершилась и имеет значение.
- Отклонена: она провалилась, и есть причина, обычно ошибка.
Обещание реализуется ровно один раз, и обработчики, добавленные позже, всё равно получают готовый результат.
Async/await
Функция async всегда возвращает обещание, а await позволяет записывать асинхронные шаги последовательно. Эта функция выводит лог, ждёт запроса и снова выводит лог, а после вызова — ещё один запись:
async function leetfinish() {
console.log("The function starts");
const res = await fetch("https://api.example.com/finish");
console.log("The request finished");
}
leetfinish();
console.log("Let's review our code");
Вероятный вывод будет следующим:
The function starts
Let's review our code
The request finished
Почему вторым выполняется внешний лог
Функция выводит своё первое сообщение и достигает этой строки:
const res = await fetch("https://api.example.com/finish");
fetch возвращает обещание с незавершенным выполнением, а await приостанавливает функцию и возвращает управление вызывающему коду, не замораживая поток. Вызывающий код выводит свое сообщение; функция возобновляет работу только после прибытия ответа. Следовательно:
The function starts
Let's review our code
The request finished
await приостанавливает функцию, а не поток. И «вероятно» имеет значение: если запрос проваливается, последняя строка так и не выводится, а незафиксированная ошибка превращается в неразрешенное отклонение.
Обработка ошибок с try, catch и finally
При использовании async/await применяются обычные конструкции try/catch/finally. В этом примере загружаются данные, проверяется ответ и бюджет, а также всегда сбрасывается флаг загрузки. В комментариях указаны два момента: HTTP-ошибки не приводят к отклонению, а response.json() также возвращает обещание.
let isLoading = true;
async function checkBudget() {
try {
// fetch() returns a Promise.
// await pauses this function until the Promise settles.
const response = await fetch(
"https://api.example.com/khouribga/business/budget"
);
// fetch() does NOT reject automatically for HTTP errors
// such as 404 or 500.
if (!response.ok) {
throw new Error("Failed to fetch business data");
}
// response.json() also returns a Promise.
const business = await response.json();
if (business.budget < 10) {
throw new Error("Budget is too low");
}
console.log("Budget is sufficient:", business.budget);
} catch (error) {
console.log("Something went wrong:", error.message);
} finally {
// This runs whether the request succeeds or fails.
isLoading = false;
console.log("Loading finished:", isLoading);
}
}
checkBudget();
Шаг за шагом:
fetch()отправляет запрос, аawaitприостанавливает выполнение функции.- Когда поступает ответ, функция возобновляет работу.
- Ошибки HTTP преобразуются в выбрасываемые исключения.
- Тело данных в формате JSON парсится, а бюджет проверяется.
- Любые ошибки в блоке
try, включая сетевые сбои, обрабатываются в блокеcatch. - Блок
finallyвсегда выполняется и сбрасывает состояние загрузки.
Практика: параллельное выполнение асинхронных функций
Хорошей задачей для тренировки является задание LeetCode 2721, в котором необходимо запустить несколько операций одновременно, вместо того чтобы ждать их по очереди; по сути, это создание собственной версии функции Promise.all.
Генераторы
Функция-генератор может приостанавливаться и возобновляться, постепенно генерируя значения. Она объявляется с помощью function*, а точки приостановки обозначаются ключевым словом yield:
function* generatorAjami() {
// Instructions ...
yield i;
// More instructions ...
yield i;
// More instructions ...
}
Сравните yield и return:
returnвозвращает значение и завершает функцию.yieldвозвращает значение и приостанавливает выполнение функции, сохраняя её состояние.
Выполнение генератора
Этот генератор выводит сообщение перед каждым из трёх операторов yield:
function* generatorAjami() {
console.log("First born in casa");
yield 10;
console.log("Second move to Khouribga");
yield 20;
console.log("Third live in Khouribga");
yield 30;
}
При вызове он не выполняет никакого кода из тела функции:
const generator = generatorAjami();
Вы получаете объект генератора — итератор, из которого можно брать значения с помощью .next():
console.log(generator.next());
Первый вызов выполняется до первого оператора yield, при этом выводится первое сообщение:
First born in casa
{ value: 10, done: false }
Выполнение останавливается на этом операторе:
yield 10;
и возвращает 10. Каждый результат вызова .next() имеет два свойства:
value— значение, которое было выдано генератором
done — указывает на то, завершил ли генератор работуВ результате получаем следующее:
{ value: 10, done: false }
это означает «здесь 10, и будет ещё».
Второй вызов .next():
console.log(generator.next());
приводит к:
Second move to Khouribga
{ value: 20, done: false }
Второе сообщение появляется именно сейчас, потому что генератор возобновляет работу сразу после предыдущего yield.
Третий вызов:
console.log(generator.next());
выводит третье сообщение и возвращает 30:
Third live in Khouribga
{ value: 30, done: false }
а четвертый:
console.log(generator.next());
сообщает, что больше ничего не осталось:
{ value: undefined, done: true }
При отсутствии явного return окончательное значение value равно undefined.
Что на самом деле такое генератор
Генератор не выполняется несколько раз: одна итерация начинается и заканчивается один раз, но может быть приостановлена и возобновлена для выдачи нескольких значений. Первый вызов выполняется до строки yield 10:
generator.next()
↓
Execute instructions
↓
yield 10
↓
Pause and return { value: 10, done: false }
Второй вызов возобновляется и останавливается на строке yield 20:
generator.next()
↓
Resume after yield 10
↓
yield 20
↓
Pause and return { value: 20, done: false }
Третий вызов возобновляется и останавливается на строке yield 30:
generator.next()
↓
Resume after yield 20
↓
yield 30
↓
Pause and return { value: 30, done: false }
Четвертый вызов не находит дальнейших инструкций и сообщает о завершении:
generator.next()
↓
No more instructions
↓
Return { value: undefined, done: true }
Синтаксис for...of и распространения автоматически вызывает метод .next() и останавливается при условии done: true. Для ознакомления с другими вариантами см. функции с возможностью приостановки и ленивая итерация с использованием генераторов.
Практика: бесконечный генератор Фибоначчи
Задача LeetCode 2648 идеально подходит для этого случая. Генератор возвращает текущее значение, затем обновляет оба хранимых значения. Цикл while (true) безопасен, поскольку каждая итерация останавливается на операции yield.
var fibGenerator = function* () {
let a = 0;
let b = 1;
while (true) {
yield a;
const next = a + b;
a = b;
b = next;
}
};
Состояние изменяется следующим образом:
a = 0, b = 1
↓
yield 0
↓
a = 1, b = 1
↓
yield 1
↓
a = 1, b = 2
↓
yield 1
↓
a = 2, b = 3
↓
yield 2
Значения вычисляются по мере необходимости, по одному при каждом вызове .next(); никогда не следует распространять или итерировать их без ограничений.
Сборка модели
Для Node.js: V8 выполняет ваш код с использованием стека и кучи, API Node передают медленные операции в libuv с его циклом событий и пулом потоков, а операционная система отвечает за фактическую сетевую связь.
┌─────────────────────────────────────────────┐
│ NODE.JS │
│ │
│ ┌─────────────┐ │
│ │ V8 │ │
│ │ │ │
│ │ Call Stack │ │
│ │ Heap │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ Node.js APIs │ │
│ └───────┬───────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ libuv │ │
│ │ │ │
│ │ ┌─────────┐ │ │
│ │ │ Event │ │ │
│ │ │ Loop │ │ │
│ │ └─────────┘ │ │
│ │ │ │
│ │ Thread Pool │ │
│ └───────┬───────┘ │
│ │ │
└───────────┼─────────────────────────────────┘
│
▼
┌───────────────┐
│ OS │
│ │
│ Networking │
│ Kernel │
│ epoll/kqueue │
└───────────────┘
Основные выводы
- Ничто из очереди не выполняется, пока стек вызовов не опустеет, поэтому длительные синхронные операции блокируют всё остальное.
- Переменные хранят ссылки на объекты кучи; недоступные объекты удаляются.
fetch отклоняет запросы только при сетевых ошибках, поэтому необходимо проверять значение response.ok.await приостанавливает выполнение функции, а не потока.yield и возобновляют работу при вызове .next(), что позволяет создавать последовательности с задержкой выполнения, включая бесконечные.