Event Loop - механизм, который позволяет однопоточному JavaScript выполнять асинхронный код. Движок выполняет синхронный код из стека вызовов, а когда стек пуст, сначала обрабатывает все микрозадачи (колбэки промисов, продолжения после await), затем браузер может обновить отрисовку, и только после этого берётся следующая макрозадача - например, колбэк setTimeout или обработчик события. Поэтому Promise.then всегда срабатывает раньше setTimeout с нулевой задержкой, запланированного в том же синхронном блоке.
Вопрос про Event Loop звучит почти на каждом собеседовании frontend- и backend-разработчика на JavaScript. Обычно он оформлен как задача: дан код с console.log, setTimeout, промисами и async-функциями, нужно назвать порядок вывода и объяснить его. Разберём механизм по частям и алгоритм, по которому такие задачи решаются без угадывания.
Однопоточность и стек вызовов
JavaScript-код выполняется в одном потоке: в каждый момент времени работает одна функция. Стек вызовов хранит цепочку вызванных функций. Когда функция вызывается, она помещается на вершину стека, когда завершается - снимается с него. Пока стек не пуст, ничего другого движок не выполняет: ни колбэки таймеров, ни обработчики кликов.
Отсюда следствие, которое важно понимать: долгая синхронная операция, например тяжёлый цикл, блокирует всё. Страница не реагирует на клики и не перерисовывается, пока стек не освободится. Асинхронность в JavaScript - не параллельное выполнение кода, а способ отложить работу, чтобы не держать стек занятым.
Кто выполняет асинхронную работу
Таймеры, сетевые запросы и события обрабатывает окружение - браузер или Node.js. Когда вы вызываете setTimeout или fetch, окружение берёт ожидание на себя, а JavaScript продолжает выполнять следующий код. Когда таймер истёк или ответ пришёл, окружение ставит соответствующий колбэк в очередь. Event Loop забирает задачи из очередей и выполняет их, когда стек пуст.
Макрозадачи и микрозадачи
Очередей несколько, и у них разный приоритет. Это главное, что нужно понимать для задач на собеседовании.
Макрозадачи
Макрозадачи (в спецификации - просто задачи): колбэки setTimeout и setInterval, обработчики событий пользователя, сообщения через postMessage и MessageChannel, выполнение основного скрипта. За один шаг цикла выполняется одна макрозадача.
Микрозадачи
Микрозадачи: колбэки then, catch и finally у промисов, продолжение async-функции после await, queueMicrotask и MutationObserver. Очередь микрозадач обрабатывается полностью после завершения каждой макрозадачи - включая микрозадачи, которые были добавлены во время этой обработки.
Один шаг цикла
- Выполнить одну макрозадачу - на первом шаге это сам скрипт.
- Выполнить все микрозадачи, пока очередь не опустеет.
- Браузер при необходимости обновляет отрисовку: выполняет колбэки requestAnimationFrame, пересчитывает стили и раскладку, рисует кадр.
- Перейти к следующей макрозадаче.
Из этого порядка следует, что бесконечно порождающие друг друга микрозадачи могут «подвесить» страницу не хуже синхронного цикла: браузер не дойдёт ни до отрисовки, ни до следующей задачи.
Промисы и async/await
Частая ловушка - считать, что всё, связанное с промисом, асинхронно. Функция-исполнитель, которую передают в new Promise, выполняется синхронно, сразу при создании промиса. Асинхронны только колбэки then, catch и finally: они попадают в очередь микрозадач, когда промис уже выполнен.
Async-функция до первого await работает как обычная синхронная функция. Встретив await, она приостанавливается, а её продолжение будет поставлено в очередь микрозадач, когда ожидаемый промис завершится. Если ждать нечего - например, await стоит перед обычным значением, - продолжение всё равно откладывается микрозадачей, а не выполняется сразу.
Поэтому в коде «async-функция выводит a, затем await, затем выводит b; вызываем её и сразу выводим c» порядок будет a, c, b. Буква a выводится синхронно при вызове, c - синхронно после возврата из функции, а b - микрозадачей, когда синхронный код закончился.
Как решать задачи на порядок вывода
Задачи выглядят запутанными, но решаются одним алгоритмом. Проговаривайте его вслух - интервьюеру важен ход рассуждения, а не только итоговая последовательность.
- Пройдите код сверху вниз как синхронный. Всё, что выполняется сразу, выпишите в порядке появления: console.log вне колбэков, тела исполнителей промисов, код async-функций до первого await.
- Отметьте, что ушло в очереди. Колбэки then и продолжения после await - в микрозадачи, setTimeout - в макрозадачи. Порядок внутри очереди - порядок постановки.
- Выполните все микрозадачи. Если микрозадача планирует новую - например, then возвращает следующий then, - новая становится в конец той же очереди и выполняется в этом же проходе.
- Возьмите следующую макрозадачу и снова обработайте все микрозадачи после неё.
Разбор классического примера
Пусть код выводит 1, затем ставит setTimeout с выводом 2, затем Promise.resolve().then с выводом 3, затем выводит 4. Синхронно выполняются выводы 1 и 4. В очереди микрозадач оказывается вывод 3, в очереди макрозадач - вывод 2. После синхронного кода выполняются микрозадачи, затем таймер. Итог: 1, 4, 3, 2.
Усложнённый вариант: внутри колбэка setTimeout создаётся промис с then. Этот then выполнится сразу после завершения колбэка таймера - раньше, чем следующий setTimeout, даже если тот был запланирован с той же нулевой задержкой. Причина в том, что после каждой макрозадачи очередь микрозадач обрабатывается полностью.
Две цепочки промисов
Ещё один частый вариант: две независимые цепочки, каждая из двух then. Первая выводит A1, затем A2, вторая - B1, затем B2. Результат - A1, B1, A2, B2. Каждый then ставит следующий колбэк в очередь только после своего выполнения, поэтому шаги двух цепочек чередуются, а не выполняются одна цепочка целиком за другой.
Ошибки в асинхронном коде
Исключение внутри колбэка промиса или async-функции не прерывает остальную программу, а переводит промис в состояние rejected. Если такой промис никто не обработал через catch или try/catch вокруг await, браузер генерирует событие unhandledrejection и пишет ошибку в консоль, а современные версии Node.js по умолчанию завершают процесс. Поэтому каждая асинхронная цепочка должна заканчиваться обработкой ошибки.
Отдельная ловушка - try/catch вокруг кода, который только планирует асинхронную работу. Если внутри try вызвать setTimeout, а ошибка произойдёт в его колбэке, catch её не поймает: к моменту выполнения колбэка блок try давно завершился и снят со стека. Ловить ошибку нужно там, где выполняется асинхронный код, - внутри колбэка или вокруг await.
Асинхронность в React
Понимание цикла событий помогает объяснить поведение React. Вызов функции обновления состояния не меняет значение переменной мгновенно: React планирует повторный рендер, а начиная с 18-й версии объединяет несколько обновлений в один рендер даже внутри промисов и таймеров. Поэтому сразу после вызова обновления в том же обработчике переменная состояния содержит старое значение.
useEffect обычно выполняется после того, как браузер отрисовал изменения, а useLayoutEffect - после изменения DOM, но до отрисовки. Отсюда практическое правило: измерять размеры элементов, чтобы избежать мигания, - в useLayoutEffect, а запросы и подписки - в useEffect. Вопросы на эту тему часто встречаются рядом с вопросами про Event Loop, подробнее - в статье вопросы на собеседовании по React.
Как не блокировать интерфейс
Если нужно обработать большой объём данных в браузере, есть три типовых подхода. Первый - разбить работу на части и выполнять их отдельными макрозадачами, например через setTimeout, чтобы между частями браузер успевал обработать клики и отрисовать кадр. Второй - вынести вычисления в Web Worker, который работает в отдельном потоке и общается с основным через сообщения. Третий - уменьшить саму работу: виртуализировать длинный список, отображая только видимые элементы, или перенести обработку на сервер.
Обратите внимание: разбиение через промисы не помогает. Микрозадачи выполняются подряд до опустошения очереди, поэтому браузер не получит возможности отрисовать кадр между частями, и интерфейс всё равно зависнет.
setTimeout, requestAnimationFrame и задержки
Задержка у setTimeout - минимальное время ожидания, а не точное. Колбэк не выполнится, пока занят стек и не обработаны микрозадачи, а у глубоко вложенных таймеров браузер может увеличивать минимальную задержку. Поэтому setTimeout не подходит для точных измерений времени и плавной анимации.
requestAnimationFrame планирует колбэк перед следующей отрисовкой кадра - это правильный инструмент для анимаций и чтения размеров перед перерисовкой. Он не относится ни к микрозадачам, ни к обычным макрозадачам, и его порядок относительно setTimeout в задачах на собеседовании обычно не спрашивают как однозначный.
Event Loop в Node.js
На backend-собеседованиях по Node.js вопрос обычно углубляют. Цикл событий в Node.js реализован библиотекой libuv и разделён на фазы: таймеры (setTimeout и setInterval), обработка отложенных системных колбэков, poll - ожидание и обработка ввода-вывода, check - колбэки setImmediate, и фаза закрытия соединений.
- process.nextTick - отдельная очередь, которая обрабатывается раньше очереди промисов. Злоупотребление ею может задерживать ввод-вывод.
- setImmediate выполняется в фазе check, после обработки ввода-вывода.
- setTimeout с нулевой задержкой и setImmediate в основном модуле могут сработать в любом порядке, а внутри колбэка ввода-вывода setImmediate всегда выполнится раньше.
- Тяжёлые вычисления блокируют весь сервер: для них используют worker_threads или выносят работу в отдельные сервисы.
Подробнее о том, что ещё нужно знать backend-разработчику, - в статье роадмап backend-разработчика на Node.js.
Что спрашивают на собеседовании
- Как JavaScript выполняет асинхронный код, если он однопоточный?
- Чем микрозадачи отличаются от макрозадач? Приведите примеры каждых.
- Какой порядок вывода у данного кода и почему?
- Почему setTimeout с нулевой задержкой срабатывает не сразу?
- Что происходит с async-функцией при встрече await?
- Как долгий синхронный код влияет на интерфейс и что с этим делать?
- Для backend: что такое process.nextTick и setImmediate, и в каком порядке они выполняются?
Как отвечать, чтобы вас запомнили
Начните с короткой модели: стек вызовов, очередь макрозадач, очередь микрозадач и правило «после каждой макрозадачи - все микрозадачи». Затем решайте задачу по шагам, проговаривая, куда уходит каждый колбэк. Если сомневаетесь в пограничном случае, скажите об этом прямо и объясните, как бы проверили поведение, - это ценится больше уверенной ошибки.
Хороший знак для интервьюера - связь теории с практикой: почему тяжёлые вычисления стоит разбивать на части или выносить в Web Worker, почему обновление состояния в React не применяется мгновенно, как избежать зависания интерфейса при обработке большого списка. Больше о том, как держаться во время решения задач, - в статье лайвкодинг на собеседовании.
Типичные ошибки в ответах
- «setTimeout с нулём выполняется сразу». Нет: колбэк ждёт окончания синхронного кода и всех микрозадач.
- «Всё внутри промиса асинхронно». Исполнитель new Promise выполняется синхронно, асинхронны только then, catch и finally.
- «await блокирует поток». Приостанавливается только текущая async-функция, остальной код продолжает выполняться.
- «Микрозадачи выполняются по одной между макрозадачами». После каждой макрозадачи очередь микрозадач обрабатывается целиком.
- «JavaScript многопоточный, раз есть асинхронность». Код выполняется в одном потоке; ожиданием таймеров и сети занимается окружение.
Как закрепить тему
- Напишите пять-шесть собственных задач на порядок вывода, сначала предскажите результат, потом проверьте в консоли.
- Объясните механизм вслух, как на собеседовании, и запишите себя - так видны паузы и неточности.
- Посмотрите, как асинхронность проявляется в вашем проекте: запросы, таймеры, обработчики событий.
Асинхронность и Event Loop - обязательная часть программы треков Frontend на React и Backend на Node.js: на менторстве эти темы отрабатываются на задачах и мок-собеседованиях, пока объяснение не станет уверенным.