Skip to content
Иван Ткаченко Блог
EN
Event Loop: как JavaScript делает несколько дел, будучи однопоточным — Иван Ткаченко ← К статьям
JavaScript

Event Loop: как JavaScript делает несколько дел, будучи однопоточным

Каждый раз в период поиска работы я освежаю знания по базовым вопросам, так как теоретическая часть со временем выветривается. Event loop почти всегда попадает в список тем для повторения, потому что это одна из наиболее вероятных тем на техническом собеседовании. В этот раз я решил не просто повторить, а написать статью. Может кому-то тоже пригодится.


JavaScript однопоточный

JavaScript выполняется в одном потоке и в каждый момент времени делает ровно одну вещь.

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

JavaScript работает похожим образом, он выполняет задачи по очереди одну за другой.

Но что если нужна асинхронная работа? Если JavaScript однопоточный, как он отправляет запрос к API и при этом не замораживает страницу?


Call Stack: стек вызовов

Всё, что JavaScript выполняет прямо сейчас, находится в Call Stack.

Он работает по принципу тарелки с блинчиками, которые повар приготовил до супа: верхний блинчик берётся первым.

function greet(name) {
  return `Привет, ${name}`;
}

function sayHello() {
  const message = greet("Вася");
  console.log(message);
}

sayHello();

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

  1. Вызов функции sayHello() попадает в стек
  2. Внутри sayHello вызывается функция greet('Вася'), её вызов ложится поверх первого вызова в стеке
  3. Функция greet вычисляет строку 'Привет, Вася', возвращает её и уходит из стека
  4. Вызов console.log(message) выводит строку в консоль и тоже уходит из стека
  5. Функции sayHello больше нечего делать, она завершает работу и выходит из стека. Все вызовы завершились, теперь стек пуст.

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


Браузерные API: кто делает асинхронную работу?

Вернёмся к аналогии с поваром. Поставив бульон вариться, он не стоит рядом с плитой и не ждёт, когда бульон закипит, а сразу идёт резать овощи. Как только бульон закипел, повар может отвлечься от нарезки овощей и пойти сбавить огонь, затем снова возвращается к овощам. Когда бульон готов, и овощи нарезаны, он загружает их в кастрюлю. Повар делегировал часть работы плите.

JavaScript устроен так же. Когда вы пишете setTimeout(callback, 1000), JavaScript не ждёт секунду. Он передаёт задачу браузеру и продолжает выполнять следующий код, пока браузер сам отсчитывает время на своих потоках.

То же самое с fetch: JavaScript не висит в ожидании ответа от сервера, а поручает браузеру сделать запрос и сообщить, когда придёт результат.

JavaScript не асинхронный сам по себе. Он делегирует асинхронную работу браузеру, который умеет работать параллельно. Пока браузер занимается сетевым запросом или таймером, JavaScript продолжает выполнять другой код.


Очереди: куда приходят результаты

Когда браузер заканчивает работу (таймер сработал, ответ от API пришёл), он кладёт callback в одну из двух очередей.

В macrotask queue попадают колбэки от setTimeout и setInterval, а также обработчики DOM-событий: клики, скролл, ввод с клавиатуры.

В microtask queue попадают колбэки от .then() и .catch() у Promise, а также задачи, добавленные через queueMicrotask().

У микрозадач приоритет выше, и это важно понимать, чтобы предсказывать порядок выполнения.


Event Loop: как всё это связано

Event Loop наблюдает за Call Stack и очередями и запускает задачи в строгом порядке:

  1. Выполняет весь синхронный код сверху вниз: каждая функция попадает в Call Stack, выполняется до конца и выходит, пока стек не опустеет
  2. Берёт микрозадачи из microtask queue по одной, помещает каждую в Call Stack и выполняет до конца, затем берёт следующую, и так до тех пор, пока очередь не опустеет, включая новые задачи, которые добавились в процессе
  3. Если прошло примерно 16ms и в DOM что-то изменилось, браузер рисует новый кадр и обновляет то, что видит пользователь на экране
  4. Берёт ровно одну макрозадачу из macrotask queue, помещает в Call Stack и выполняет до конца. В отличие от микрозадач, после одной макрозадачи Event Loop не берёт следующую сразу, а возвращается к шагу 2
  5. Возвращается к шагу 2: снова проверяет очереди и продолжает цикл

Синхронный код всегда выполняется первым, микрозадачи выполняются все до единой перед следующим шагом, и из macrotask queue за один цикл берётся только одна задача.


Классический вопрос с собеседования

Вот задача, которая встречается почти на каждом техническом собеседовании. Посмотрите на код и скажите, что и в каком порядке выведет консоль.

console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");

Разбираем по шагам:

  1. console.log('A'): синхронный вызов. Вывод: A
  2. setTimeout: передаётся браузеру. Колбэк с ‘B’ уйдёт в macrotask queue
  3. Promise.resolve().then(...): Promise уже resolved, колбэк с ‘C’ идёт в microtask queue
  4. console.log('D'): синхронный вызов. Вывод: D
  5. Стек пуст. Event Loop берёт microtask queue, колбэк с ‘C’. Вывод: C
  6. Microtask queue пуст. Берёт одну macrotask, колбэк с ‘B’. Вывод: B

Итог: A → D → C → B

setTimeout с задержкой 0 выполняется последним, потому что это макрозадача, а microtask queue очищается раньше.


Микрозадача внутри макрозадачи

А что будет, если промис создаётся прямо внутри setTimeout?

setTimeout(() => {
  console.log("macro 1");
  Promise.resolve().then(() => console.log("micro после macro 1"));
}, 0);

setTimeout(() => console.log("macro 2"), 0);
// macro 1 → micro после macro 1 → macro 2

После первой макрозадачи Event Loop проверяет microtask queue и находит там новый колбэк от Promise, затем выполняет его до того, как возьмётся за следующий setTimeout.

Это означает, что промисы, созданные внутри одного setTimeout, всегда отработают до следующего setTimeout.


Проблемные кейсы

Бесконечная цепочка микрозадач = зависание страницы

function loop() {
  Promise.resolve().then(loop);
}
loop(); // страница зависает

Event Loop никогда не выйдет из шага 2, потому что microtask queue постоянно пополняется. Браузер не рисует кадры, интерфейс не реагирует на клики.

Бесконечный setTimeout, в отличие от этого, безопасен:

function loop() {
  setTimeout(loop, 0);
}
loop(); // страница работает нормально

Каждый вызов setTimeout кладёт следующую итерацию в macrotask queue, а не в microtask queue. Event Loop не застревает на шаге 2: между каждой итерацией он успевает отрисовать кадр и обработать пользовательские события.


Долгий синхронный код блокирует всё

button.addEventListener("click", () => {
  const start = Date.now();
  while (Date.now() - start < 3000) {} // цикл крутится 3 секунды
});

Пока цикл while работает, Call Stack занят, и Event Loop не может взять ни одну задачу из очередей. Страница перестаёт реагировать на клики, браузер не рисует кадры, пользователь видит зависание.

Для тяжёлых вычислений есть два пути: разбивать задачу на части через setTimeout или выносить её в Web Worker, отдельный поток для JS-кода без доступа к DOM.


Рендеринг: где он в этой схеме

Браузер рисует экран примерно 60 раз в секунду, один кадр каждые 16ms. Рендеринг происходит между макрозадачами, после того как все микрозадачи выполнены.

Для визуальных обновлений, например анимаций, существует функция requestAnimationFrame: она принимает колбэк и выполняет его прямо перед следующим рендером, а не в произвольный момент как setTimeout. Если синхронный код или цепочка микрозадач занимают больше 16ms, браузер пропускает отрисовку кадра и в интерфейсе могут возникать подвисания, например в анимациях.


Почему это спрашивают на каждом собеседовании

Понимать event loop — значит понимать, как JavaScript работает в браузере. Не зная этого, можно годами писать код и сталкиваться с поведением, которое выглядит как магия.

Вот несколько ситуаций, где непонимание того, как работает event loop, может привести к проблемам:

  • Вы вызываете setState и сразу читаете обновлённое значение, но его ещё нет, потому что обновление происходит асинхронно
  • Тяжёлый синхронный цикл блокирует Call Stack, и пока он не завершится, Event Loop не может взять ни одну задачу из очередей и интерфейс перестаёт реагировать на клики
  • От кода с setTimeout(..., 0) ожидается выполнение вот-вот, но в действительности перед его запуском несколько Promise-цепочек уже могут успеть отработать и изменить состояние

Другое дело, что заучить эту теорию можно за вечер, не написав ни строчки асинхронного кода. Но это уже разговор про качество технических интервью, а не про event loop.