Event loop в JavaScript простыми словами: как работает асинхронность
JavaScript однопоточный — в любой момент времени выполняется только одна строка кода. И при этом он спокойно тянет сетевые запросы, таймеры и обработку кликов, не блокируя страницу. Кажется, что это противоречие, но разгадка не в магии, а в конкретном механизме — event loop. Разберёмся, как он устроен, из каких частей состоит и почему порядок выполнения асинхронного кода иногда удивляет даже опытных разработчиков.
Call stack: почему JavaScript может делать только одно дело за раз
У движка JavaScript есть call stack — стек вызовов функций. Когда вызывается функция, она кладётся на стек; когда функция возвращает значение, она снимается со стека. Пока стек не пуст, движок занят выполнением текущей цепочки вызовов и не может начать ничего нового. Именно это имеют в виду, когда говорят «JavaScript однопоточный»: есть один стек и один поток, который его обрабатывает.
Проблема в том, что многие операции — сетевой запрос, чтение файла, таймер — занимают время, и если бы движок ждал их прямо в стеке, страница бы «замерзала» на секунды. Решение: такие операции не выполняются в самом JavaScript-потоке. Они делегируются окружению — браузеру или Node.js, — а движок в это время свободен и может выполнять другой код.
Очередь задач и роль event loop
Когда делегированная операция завершается — таймер истёк, ответ сервера пришёл, — окружение не вклинивается в стек напрямую. Вместо этого callback-функция кладётся в очередь задач (task queue, также называемую macrotask queue). А event loop — это простой в своей идее механизм, который постоянно проверяет одно условие: пуст ли call stack. Как только он пуст, event loop берёт следующую задачу из очереди и кладёт её в стек на выполнение.
Отсюда и берётся эффект, который многих удивляет: setTimeout(fn, 0) не выполняется мгновенно. Ноль миллисекунд — это не «прямо сейчас», а «поставь задачу в очередь как можно раньше». Но выполнена она будет только после того, как весь текущий синхронный код отработает и стек освободится, даже если это происходит через доли миллисекунды.
Микрозадачи: почему промисы обгоняют таймеры
Помимо очереди обычных задач, у event loop есть вторая, более приоритетная очередь — очередь микрозадач (microtask queue). Туда попадают колбэки промисов (.then, .catch, .finally), а также вызовы queueMicrotask. Правило простое и важное: после каждой отдельной задачи из основной очереди, прежде чем взять следующую, event loop полностью опустошает очередь микрозадач — включая те, что были добавлены в процессе её выполнения.
Это значит, что микрозадачи всегда выполняются раньше, чем следующий таймер или следующее событие, даже если промис разрешился позже, чем сработал таймер с нулевой задержкой. Порядок вывода в классическом примере это хорошо показывает:
console.log('1: синхронный код');
setTimeout(() => console.log('2: таймер'), 0);
Promise.resolve().then(() => console.log('3: микрозадача'));
console.log('4: синхронный код');
// Вывод: 1, 4, 3, 2
Сначала выполняется весь синхронный код (1 и 4), затем — прежде чем event loop возьмёт из очереди задач таймер — полностью опустошается очередь микрозадач (3), и только после этого выполняется колбэк таймера (2).
Что из этого следует на практике
Понимание этого механизма пригождается не как теория для собеседований, а в конкретных ситуациях отладки:
- Долгий синхронный код блокирует всё. Если внутри цикла или тяжёлого вычисления нет ни одной точки, где стек освобождается, ни один таймер, ни один клик, ни один сетевой колбэк не обработается, пока цикл не закончится — интерфейс «зависает».
- Цепочка промисов может «съесть» весь кадр раньше, чем ожидается. Если один
.thenпорождает следующий, а тот — ещё один, они все выполнятся до того, как браузер успеет перерисовать кадр или обработать таймер, потому что очередь микрозадач опустошается целиком. - Порядок логов — не баг, а следствие приоритета очередей. Если в консоли асинхронный код выводится «не в том порядке», в котором написан, почти всегда дело в том, что часть колбэков — микрозадачи, а часть — обычные задачи.
- async/await — это синтаксический сахар поверх той же модели. Каждый
awaitфактически ставит продолжение функции в очередь микрозадач, поэтому код послеawaitвсегда выполняется асинхронно, даже если промис уже был разрешён к этому моменту.
Event loop не ускоряет JavaScript и не делает его многопоточным — он просто определяет порядок, в котором единственный поток берётся за уже готовые задачи, пока сам ничем не занят.
Итог
Асинхронность в JavaScript держится на трёх частях: call stack выполняет код по одному вызову за раз, окружение берёт на себя долгие операции и возвращает результат через очереди, а event loop решает, что и когда забрать из этих очередей — сначала полностью опустошая микрозадачи, и только потом переходя к следующей обычной задаче. Как только эта картинка есть в голове, поведение асинхронного кода перестаёт быть набором заученных правил и превращается в предсказуемую механику, которую легко объяснить самому себе при отладке.
← Все статьи