JavaScript выполняет код в одном основном потоке. Это означает, что инструкции выполняются последовательно — одна за другой. Получил команду — выполнил. Но что делать интерпретатору, если он встречает код, который не может выполнить сразу? Например, обработчик события. Пока событие, допустим, клик на кнопку, не произошло, код внутри обработчика не выполнится. Такой код называют асинхронным. К асинхронным операциям относятся, например, таймеры (setTimeout), сетевые запросы или события интерфейса. Промисы (Promise) используются для обработки результатов таких операций. В такой ситуации на помощь интерпретатору JS приходит среда, в которой выполняется скрипт. Это может быть Node.js, мобильные среды или интерфейс, который предоставляет браузер — Web API (есть и другие). В отличие от JavaScript-движка, среда выполнения может использовать несколько потоков для обработки ввода-вывода, таймеров и сетевых операций.

Если сравнить выполнение скрипта с выступлением оркестра, то дирижёром, который отвечает, чтобы каждая функция «отыграла свою партию» в нужный момент, можно назвать Event Loop. Event Loop — это механизм среды выполнения, который управляет порядком выполнения задач. Он координирует работу JavaScript-кода, обработку событий и другие процессы браузера. Вопреки расхожему мнению, работа этого механизма не так сложна, как его часто описывают. В этой статье, проповедуя Фреймановскую истину — «Если не можешь объяснить что-то простыми словами, то ты не понимаешь этого» — автор попытается (для себя и для других) описать работу Event Loop в браузере. В среде Node.js концепция похожа, но вместо Web API используются другие механизмы ввода-вывода.

Поисковая выдача по запросу «как работает Event Loop в связке с JavaScript» содержит множество статей, в которых почти неизбежно появляются три «столпа»: стек вызовов, микротаски и макротаски — обычно со стрелочками между ними и объяснением в духе «сначала положили в одну кучку, потом в другую».

На практике такое объяснение часто только запутывает. Если заглянуть в спецификацию, то можно увидеть, что речь идёт всего о двух очередях: очереди задач (task queue) и очереди микрозадач (microtask queue). Call Stack при этом вообще не является очередью — это механизм выполнения кода, структура данных, в которой JavaScript хранит контексты выполняемых функций.

Выполнение исходного скрипта можно рассматривать как первую задачу (task) в Event Loop. Event Loop берёт задачу из очереди задач и помещает её в Call Stack. Разбираться со всеми синхронными контекстами интерпретатор будет по принципу LIFO (Last In, First Out), то есть последний добавленный контекст выполняется первым. Если код регистрирует асинхронную операцию, её выполнение передаётся среде выполнения. Когда операция завершится, соответствующий колбэк будет добавлен либо в очередь задач, а затем в Call Stack, либо в очередь микрозадач — в зависимости от типа операции.

После того как Call Stack становится пустым, Event Loop проверяет очередь микротасок (Microtask Queue) и выполняет все доступные микротаски. К микротаскам относятся Promise и MutationObserver. Важно: если в процессе выполнения микротасок создаются новые микротаски (например, в цепочке then промиса), они добавляются в ту же очередь и будут выполнены немедленно. Поскольку Event Loop выполняет все микротаски перед переходом к следующей задаче, большое количество микротасок может задерживать рендеринг страницы.

Затем Event Loop отслеживает, какую задачу взять следующей — в Web API поспела к выполнению ранее зарегистрированная таска? Давай её в Call Stack — будем выполнять. На случай, если в результате этого выполнения появились новые микротаски то Event Loop снова заглянет в очередь микротасок и выполнит всё, что накопилось. А затем снова перейдет к очереди задач и отправит в стек то, что уже готово к выполнению. Таким образом, Web API можно представить как систему, которая отслеживает асинхронные операции и сообщает Event Loop, когда их колбэки готовы к выполнению.

Вот как это выглядит по шагам:

1. Выполняется задача (Task) №1: Весь скрипт.

console.log('1');         // выполняется синхронно (часть текущей задачи)
setTimeout(() => {        // регистрируется в Web API
  console.log('макро');   // колбэк setTimeout станет отдельной следующей задачей №2
}, 0);
Promise.resolve().then(() => { // Promise ставит свой колбэк в очередь микрозадач
  console.log('микро');
});
console.log('2'); // // выполняется синхронно (часть текущей задачи)

2. Скрипт закончился (первая задача выполнена), стек пуст.

3. Event Loop проверяет очередь микротасок (Microtask Queue) и выполняет всё, что там есть**.

// выполняется then() промиса
console.log('микро');

4. Event Loop переходит к следующей задаче из очереди задач (Task Queue). Web API уже поместил туда колбэк от setTimeout, так как таймер истек.

// колбэк `setTimeout` (задача №2)
console.log('макро');

Event Loop продолжает работать на протяжении всей жизни страницы, ожидая появления новых задач (например, событий пользователя, сетевых ответов или таймеров). Короткая шпаргалка:

task
↓
выполнение кода (Call Stack)
↓
microtasks
↓
render
↓
next task

На схеме task → Call Stack → microtasks → render → next task шаг render происходит не всегда, а лишь когда браузер решает, что пора обновить интерфейс (примерно 60 раз в секунду). Однако важно понимать порядок: даже если рендеринг не происходит, микротаски всегда выполняются до того, как начнется следующая макрозадача из очереди.

Ещё пример:

console.log('Start'); // Синхронно (в текущей task)

setTimeout(() => {
  console.log('Timeout 1'); // Новая таска
}, 0);

Promise.resolve().then(() => {
  console.log('Promise 1'); // Микротаска
});

setTimeout(() => {
  console.log('Timeout 2'); // Новая таска
}, 0);

Promise.resolve().then(() => {
  console.log('Promise 2'); // Микротаска
});

console.log('End'); // Синхронно (в текущей таске)

// 1. Выполняется синхронный код: Start, End.
// 2. Очищается очередь микротасок: Promise 1, Promise 2.
// 3. Берется первая таска из очереди: Timeout 1.
// 5. Берется следующая таска: Timeout 2.

Вывод:

Start
End
Promise 1
Promise 2
Timeout 1
Timeout 2

И напоследок, хотя надо было с этого начать. Почему автор решил, что его объяснение тоже имеет право на существование? Ведь уже столько всего напис��но прекрасного про Event Loop.

Наверно, это немного тщеславия. Или то, чего когда-то не хватило автору — простого и понятного объяснения. Конечно, это упрощённый пересказ, но именно с него, как кажется, удобно начинать знакомство с этим механизмом.

Предполагается, что с таким подходом проще понять, как выполняется хитрый код со вложенными друг в друга колбэками, промисами и таймерами setTimeout. Для собеседований можно рисовать себе шпаргалку — в этом столбике у нас стек, тут — микрозадачи, а тут Web API держит в памяти задачи на будущее.

Для работы: понимание, что бесконечный while(true) блокирует стек и не дает запустить следующую микротаску, а бесконечный промис блокирует очередь микротасок, не давая запустить рендеринг, затем перейти к следующей задаче. Ситуация «я что-то написал и страница перестала реагировать» перестанет быть чем-то вроде злобных происков врагов.Таким образом, осознавая, хотя бы упрощённо, механизм работы Event Loop, вы приблизитесь к пониманию того, что такое утечки памяти и как их не допускать. Кстати, тоже интересная тема — но уже для новой статьи.


Ссылки для углубленного изучения: