Как работает JavaScript Event Loop?
setTimeout() поставили первым. Promise.then() - вторым. Но результат получается обратным:
//javascript
console.log("start");
setTimeout(() => {
console.log("timeout");
}, 0);
Promise.resolve().then(() => {
console.log("promise");
});
console.log("end");В консоли:
//text
start
end
promise
timeoutЕсли раньше с Event Loop не приходилось разбираться всерьёз, такой результат кажется странным. Таймер зарегистрирован раньше Promise. Задержка у него вообще нулевая. Почему тогда promise появляется раньше?
Потому что Event Loop не является одной очередью, из которой JavaScript просто берёт задачи по порядку поступления.
У него есть как минимум две принципиально разные категории работы - tasks и microtasks. И у microtasks есть особенность, из-за которой они получают возможность выполниться раньше следующей обычной задачи.
Именно на этом месте обычно и ломается слишком простая картинка «есть Call Stack, есть очередь, есть Event Loop».
Сначала выполняется то, что уже запущено
Начнём с более скучного примера:
//javascript
console.log("1");
console.log("2");
console.log("3");Здесь никакого Event Loop почти не видно. JavaScript выполняет инструкции последовательно:
//text
1
2
3Выполнение текущего кода нельзя просто прервать посередине, чтобы внезапно запустить другую функцию.
Например:
//javascript
function first() {
console.log("A");
second();
console.log("B");
}
function second() {
console.log("C");
}
first();Результат предсказуем:
//text
A
C
BКогда second() начала выполняться, JavaScript не переключился куда-то ещё посреди этой функции. Сначала она завершилась, затем управление вернулось в first().
Это свойство называют run-to-completion: текущая работа должна завершиться прежде, чем начнётся следующая. В модели JavaScript это одна из причин, по которой можно довольно точно рассуждать о порядке выполнения кода.
Проблема начинается, когда программе приходится ждать того, чего JavaScript сам контролировать не может.
Например, ответа сервера.
JavaScript не ждёт сервер
Допустим, приложение отправляет запрос:
//javascript
const response = await fetch("/api/users");На уровне исходного кода кажется, будто выполнение остановилось на await.
Но это не означает, что JavaScript-поток сидит и ничего не делает, пока сервер готовит ответ.
Сетевой запрос выполняется через API среды выполнения. После того как браузер получил результат, код, который должен продолжить работу с этим результатом, будет запланирован для последующего выполнения.
Поэтому:
//javascript
console.log("До");
fetch("/api/users");
console.log("После");выведет:
//text
До
ПослеА обработчик результата выполнится позже.
Это одна из главных причин, почему асинхронная модель вообще нужна браузеру. Если бы ожидание каждого сетевого ответа блокировало выполнение JavaScript, интерфейс становился бы гораздо менее отзывчивым. Модель выполнения JavaScript как раз предполагает, что завершение асинхронной операции приводит к постановке дальнейшей работы в очередь, пока основной код может продолжать выполняться.
Но здесь появляется следующий вопрос: в какую именно очередь попадёт эта работа?
У Event Loop нет одной большой очереди
Для объяснения Event Loop часто рисуют что-то вроде:
//text
Call Stack
↓
Queue
↓
Event LoopДля первого знакомства схема удобная. Для реального кода - слишком грубая.
В браузере есть task queues и отдельная microtask queue. Более того, HTML Standard специально не описывает task queues как простую единую FIFO-очередь: у разных источников задач могут быть свои очереди, а выбор очереди зависит от правил среды выполнения.
На практике разработчику особенно важно различать две вещи.
setTimeout() приводит к постановке callback в область обычных задач.
Promise.then() и queueMicrotask() работают через microtask queue.
И вот здесь возвращается наш первый пример.
//javascript
console.log("start");
setTimeout(() => {
console.log("timeout");
}, 0);
Promise.resolve().then(() => {
console.log("promise");
});
console.log("end");Сначала JavaScript выполняет синхронный код:
//text
start
endПосле этого текущая работа заканчивается.
В microtask queue уже находится callback от Promise. Поэтому он выполняется:
//text
promiseИ только затем Event Loop переходит к следующей подходящей обычной задаче:
//text
timeoutПолучаем:
//text
start
end
promise
timeoutВот почему 0 в setTimeout(..., 0) не означает «выполнить раньше Promise».
Что на самом деле означает 0 в setTimeout
Это ещё одна причина, почему Event Loop часто понимают неправильно.
//javascript
setTimeout(() => {
console.log("hello");
}, 0);Ноль не означает:
«Запусти callback немедленно».
Он задаёт минимальную задержку перед тем, как таймер сможет привести к постановке соответствующей задачи. Когда эта задача действительно получит возможность выполниться, зависит от состояния Event Loop и других работ, которые уже находятся в системе.
Например:
//javascript
setTimeout(() => {
console.log("timeout");
}, 0);
for (let i = 0; i < 5_000_000_000; i++) {
// тяжёлая работа
}
Таймер не прервёт цикл через ноль миллисекунд.
Сначала должен закончиться текущий JavaScript-код.
И это логично: иначе любой внешний callback мог бы вмешаться в состояние программы посреди выполнения функции. Модель run-to-completion как раз гарантирует обратное - текущая работа заканчивается целиком.
Поэтому setTimeout(..., 0) правильнее воспринимать не как команду «сделай сейчас», а как просьбу выполнить эту работу настолько рано, насколько позволят правила планирования и уже выполняющийся код.
Почему Promise оказывается раньше таймера
Теперь можно посмотреть на тот же пример чуть внимательнее:
//javascript
setTimeout(() => {
console.log("A");
}, 0);
Promise.resolve().then(() => {
console.log("B");
});Получаем:
//text
B
APromise здесь не «быстрее таймера».
Это вообще не вопрос скорости.
Callback Promise попал в microtask queue, а callback таймера - в обычную task queue. После завершения текущей задачи среда выполняет накопившиеся microtasks прежде, чем перейти к следующей обычной задаче.
Разница небольшая на вид, но огромная по последствиям.
Представим:
//javascript
console.log("A");
queueMicrotask(() => {
console.log("B");
});
setTimeout(() => {
console.log("C");
}, 0);
console.log("D");Результат:
//text
A
D
B
CПричём queueMicrotask() здесь не создаёт отдельный поток. Это просто способ сказать среде: «выполни эту небольшую работу после завершения текущего JavaScript, но до перехода к следующей обычной задаче». Именно так этот механизм и описывается в документации платформы.
Самая неприятная особенность microtasks
С microtasks есть одна деталь, которая гораздо важнее красивой схемы с двумя очередями.
Microtask может добавить новую microtask.
//javascript
queueMicrotask(() => {
console.log("A");
queueMicrotask(() => {
console.log("B");
});
});
setTimeout(() => {
console.log("C");
}, 0);Результат:
//text
A
B
CПочему B не переносится после C?
Потому что после завершения текущей задачи Event Loop продолжает обрабатывать microtask queue, пока она не станет пустой. Если одна microtask добавила другую, новая тоже попадёт в этот процесс.
И это уже может стать проблемой.
Например:
//javascript
function again() {
queueMicrotask(again);
}
again();Здесь каждая microtask создаёт следующую.
Очередь не успевает опустеть.
В результате браузер может продолжать выполнять microtasks и не получить нормальной возможности перейти к другим задачам, включая работу, связанную с интерфейсом и rendering. HTML Standard отдельно предупреждает о том, что большое количество microtasks способно мешать браузеру выполнять собственную работу.
Получается любопытная вещь: для блокировки страницы необязательно писать очевидный while (true).
Можно создать проблему асинхронным на вид кодом.
Где здесь Call Stack
Теперь можно вернуться к знаменитому Call Stack.
Для такого кода:
//javascript
function one() {
two();
}
function two() {
console.log("Hello");
}
one();стек вызовов примерно выглядит так:
//text
two()
one()
global codeКогда two() заканчивается, она исчезает из стека.
Затем завершается one().
После этого заканчивается текущая работа.
Call Stack и очередь задач решают разные проблемы. Stack показывает, что выполняется прямо сейчас. Очереди содержат работу, которая должна быть выполнена позже. В модели JavaScript это отдельные части механизма выполнения.
И Event Loop нужен именно для того, чтобы связать эти состояния во времени.
Условно:
//text
выполняется JavaScript
│
▼
Call Stack
│
│ стек пуст
▼
Microtasks
│
│ очередь пуста
▼
следующая TaskЭто уже гораздо ближе к реальному поведению, чем картинка с одной абстрактной очередью.
А что происходит с интерфейсом?
Здесь Event Loop перестаёт быть просто теоретической конструкцией.
Представим обычную страницу. Пользователь нажимает кнопку:
//javascript
button.addEventListener("click", () => {
console.log("clicked");
});Нажатие пользователя должно привести к выполнению обработчика как части работы, которую браузер планирует через свою модель задач.
Но если обработчик сделать таким:
//javascript
button.addEventListener("click", () => {
const start = Date.now();
while (Date.now() - start < 5000) {
// пять секунд тяжёлой синхронной работы
}
});страница на эти пять секунд фактически перестанет нормально реагировать.
Проблема не в том, что Event Loop сломался.
Он как раз делает то, что должен: текущая задача выполняется до конца.
Проблема в том, что текущая задача слишком большая.
Пока она не закончится, другие задачи не получат возможности выполниться. MDN прямо связывает долгие задачи с невозможностью своевременно обрабатывать такие действия пользователя, как клики и прокрутка.
Поэтому Event Loop - это ещё и практическая причина, по которой фронтенд-разработчику приходится думать о размере синхронной работы.
Event Loop не делает JavaScript многопоточным
Здесь часто появляется другая ошибка.
Если JavaScript умеет одновременно ждать сеть, принимать события и выполнять callbacks, легко решить, что Event Loop каким-то образом превращает один поток в несколько.
Нет.
Асинхронность не равна параллельности.
Если выполнить:
//javascript
function calculate() {
for (let i = 0; i < 10_000_000_000; i++) {
// тяжёлая работа
}
}
calculate();Event Loop не сможет взять эту функцию и продолжить её выполнение параллельно с обработчиком клика.
Текущая JavaScript-работа должна завершиться.
Для настоящего параллельного выполнения в браузере существуют другие механизмы, например Web Workers. У worker есть собственный агент и собственный event loop.
При этом даже фраза «у каждого Event Loop свой поток» была бы слишком грубой. HTML Standard прямо указывает, что event loop не обязан соответствовать отдельному физическому потоку реализации.
Это хороший пример того, почему популярные картинки про Event Loop полезны только до определённого момента.
И всё-таки зачем всё это нужно?
Если отбросить термины, проблема очень простая.
Браузеру постоянно приходится совмещать несколько видов работы:
выполнять JavaScript;
реагировать на действия пользователя;
ждать завершения сетевых операций;
обрабатывать события;
обновлять интерфейс;
выполнять отложенные callbacks.
При этом нельзя позволить одному небольшому JavaScript-фрагменту навсегда занять возможность выполнять всё остальное.
Event Loop и связанные с ним очереди дают среде правила, по которым эта работа может продолжаться.
Сценарий выглядит примерно так:
//text
JavaScript-код
│
▼
текущая задача
│
▼
стек опустел
│
▼
обработка microtasks
│
▼
браузер получает возможность
продолжить другую работу
│
▼
следующая задачаЭто не буквальная схема внутреннего устройства Chrome, Firefox или Safari. Реальная модель сложнее: у Event Loop есть разные источники задач, правила выбора runnable-задач, microtask checkpoints и отдельные этапы, связанные с rendering. HTML Standard описывает эту модель значительно подробнее, чем принято показывать в учебных диаграммах.
Но для повседневной разработки из неё можно вынести несколько действительно полезных вещей.
setTimeout(fn, 0) не означает «выполнить fn сейчас».
Promise.then() выполняется через microtask queue и поэтому обычно оказывается раньше callback таймера, если оба запланированы в рамках одной текущей работы.
Microtasks выполняются до опустошения очереди, поэтому бесконтрольное добавление новых microtasks способно задержать другие задачи.
Долгий синхронный JavaScript блокирует обработку следующей работы, даже если весь остальной код написан с Promise, async/await и fetch.
И, пожалуй, самое полезное изменение в понимании Event Loop происходит именно здесь.
Это не механизм, который «делает JavaScript асинхронным».
JavaScript не начинает выполнять несколько строк одновременно.
Event Loop определяет, какая работа получит возможность выполниться после завершения текущей, а очереди и правила среды определяют, как эта работа будет организована.
Поэтому в следующий раз, когда setTimeout(..., 0) внезапно окажется после Promise.then(), искать причину в скорости выполнения уже не придётся.
Они просто оказались в разных очередях и получили разные условия для запуска.
Комментарии
Чтобы оставить комментарий, войдите в аккаунт.