Однопоточность, но без строгой последовательности
JavaScript работает в одном потоке. Стек вызовов у него один, и в каждый конкретный момент выполняется ровно одна функция. Две строчки вашего кода никогда не выполняются параллельно в рамках одного realm.
Звучит как серьёзное ограничение — ровно до тех пор, пока не вспомнишь, чем на самом деле занимается JavaScript: тянет данные по сети, ждёт кликов пользователя, читает файлы. По сути, бо́льшая часть «работы» — это ожидание. И как раз event loop — тот самый приём, который делает ожидание практически бесплатным: ваш код отдаёт задачу браузеру или Node, идёт заниматься другими делами, а когда результат готов — получает уведомление.
первое и второе выводятся по порядку. третье появляется позже — хотя таймаут равен 0. Именно этот зазор и есть работа event loop, и понять, почему так происходит, — главная цель этой статьи.
Стек вызовов (call stack)
Каждый вызов функции кладёт новый фрейм на стек вызовов. Когда функция возвращает значение, её фрейм снимается со стека. Стек — это просто стек: последним положили — первым забрали.
Когда вызывается outer(), Node кладёт в стек outer, затем inner, после возврата "done" снимает inner, а потом и outer. Стек снова пуст. Именно этот момент «пустого стека» и отслеживает event loop.
Синхронный код выполняется на стеке от начала и до конца — прервать его ничем нельзя. Напишете while (true) — стек никогда не освободится, и страница зависнет: ни кликов, ни таймеров, ни колбэков промисов. event loop просто не получит свой ход и будет простаивать.
Где на самом деле живёт асинхронность
Сам JavaScript не умеет ни делать сетевые запросы, ни ждать 100 миллисекунд. Эти API предоставляет хост — браузер или Node. Когда вы вызываете setTimeout(fn, 100), происходит следующее:
- Таймер регистрируется на стороне хоста.
setTimeoutсразу возвращает управление. Стек продолжает работать.- Спустя 100 мс хост кладёт
fnв очередь задач. - Как только стек опустеет, event loop забирает
fnиз очереди и запускает.
Колбэк таймера не сможет выполниться, пока не отработают цикл for и console.log("конец"), — ведь стек ещё не пуст. Задержка у таймеров — это минимум, а не гарантия.
Две очереди: задачи и микрозадачи
На самом деле очередь не одна, а две — и именно это различие объясняет большинство сюрпризов event loop в JavaScript.
- Очередь задач (её ещё называют очередью макрозадач):
setTimeout,setInterval, колбэки ввода-вывода, события UI. - Очередь микрозадач: колбэки промисов (
.then,.catch,.finally), продолжения послеawaitи всё, что поставлено черезqueueMicrotask.
Event loop работает по такому правилу:
- Взять одну задачу из очереди задач и выполнить её.
- Полностью опустошить очередь микрозадач — включая те, что добавились прямо во время обработки.
- При необходимости отрисовать кадр (в браузере).
- Вернуться к шагу 1.
Микрозадачи всегда выполняются раньше следующей задачи. Именно поэтому следующий пример многих удивляет:
Порядок вывода: sync 1, sync 2, promise, timeout. Сначала выполняется синхронный код. Затем стек очищается. После этого event loop полностью опустошает очередь микрозадач (promise). И только потом берётся за задачу из таймера (timeout).
Как микрозадачи могут «заморить» обычные задачи
Поскольку очередь микрозадач вычерпывается до конца перед тем, как будет взята следующая задача, микрозадача, которая бесконечно планирует новые микрозадачи, навсегда заблокирует очередь задач:
Таймер никогда не сработает: каждая микрозадача тут же ставит в очередь ещё одну, и цикл просто не даёт очереди опустеть. С цепочками промисов такой проблемы нет — каждый .then планирует ровно одно продолжение. А вот самописные циклы на микрозадачах — классические грабли, о которых полезно знать.
await — это синтаксический сахар над микрозадачей
Когда вы пишете await перед промисом, функция приостанавливается, а её «хвост» ставится в очередь микрозадач и выполнится, как только промис зарезолвится. Никакой магии — под капотом всё тот же .then.
Результат: A, C, B. Оператор await возвращает управление вызывающему коду. console.log("C") выполняется в рамках текущего стека. Затем очередь микрозадач опустошается, функция demo продолжает работу с того места, где остановилась, и выводит B.
Держите это в голове, когда читаете асинхронный код. await ничего не блокирует — он просто уступает управление.
Разбираем пример: в каком порядке что выполняется
Соберём всё вместе в одном примере:
Порядок выполнения:
1: sync— выполняется в стеке.6: sync— тоже в стеке.- Стек опустел. Начинает разгребаться очередь микрозадач:
3: promise,5: microtask, затем4: nested microtask(её добавили во время разгребания, но она всё равно попадёт в ту же порцию). - Дальше берётся следующая задача:
2: timeout.
Итоговый вывод: 1, 6, 3, 5, 4, 2. Если смогли проследить за этим примером — значит, event loop в JavaScript вы уже понимаете.
В Node.js фаз больше
Event loop в Node — это расширенная версия браузерной модели. У него есть отдельные фазы: timers, pending I/O callbacks, poll, check, close, — и между каждой фазой очередь микрозадач разгребается полностью. setImmediate срабатывает в фазе check, а process.nextTick выполняется раньше обычных микрозадач (у него своя очередь с ещё более высоким приоритетом).
Заучивать схему фаз с первого дня не нужно. Главный вывод ровно тот же, что и в браузере: сначала до конца выполняется синхронный код, потом разгребается очередь микрозадач, и только после этого цикл берёт следующий колбэк из очереди задач.
Зачем вообще это знать
Когда модель укладывается в голове, много асинхронного кода перестаёт казаться магией:
- Долгий цикл
forподвешивает интерфейс, потому что event loop не может получить ход. setTimeout(fn, 0)— это способ отложить работу до момента, когда закончатся текущая задача и все микрозадачи.- Колбэк в
.then, навешенный на уже зарезолвленный промис, всё равно дождётся окончания текущего синхронного кода, хоть и «срабатывает сразу». awaitвнутри цикла делает работу последовательной, потому что каждая итерация отдаёт управление очереди микрозадач, прежде чем продолжить.
Отладка асинхронного кода в основном сводится к вопросу: «что сейчас в стеке и что в очередях?». Event loop и есть ответ.
Дальше: колбэки
До появления промисов и async/await единственным инструментом для асинхронной работы в JavaScript был колбэк — функция, которую вы передаёте в API, чтобы она была вызвана позже. Колбэки до сих пор встречаются повсюду (обработчики событий, базовые API в Node), и без понимания того, как они работают, дальше в этой главе делать нечего.
Часто задаваемые вопросы
Что такое event loop в JavaScript?
Это механизм, благодаря которому однопоточный JavaScript умеет выполнять асинхронные операции, не блокируя поток. Event loop постоянно следит за call stack: как только стек пустеет — берёт следующий колбэк из очереди и запускает его. Таймеры, I/O, продолжения промисов — всё это складывается в очереди, которые event loop разбирает по одной задаче за раз.
Почему JavaScript однопоточный?
В спецификации языка прописан один call stack на realm, поэтому ваш код всегда выполняется в одном потоке. А параллельность появляется за счёт хоста (браузера или Node.js): он отдаёт тяжёлую работу фоновым API — таймерам, сети, файловой системе — и, когда те заканчивают, ставит колбэк в очередь. Двух одновременно работающих кусков JS в одном контексте вы не увидите никогда.
Чем отличаются микрозадачи от макрозадач?
Микрозадачи (microtasks) приходят от промисов (.then, await) и queueMicrotask. Макрозадачи (macrotasks) — это setTimeout, setInterval, I/O и события UI. Фишка в том, что после каждой макрозадачи event loop разгребает всю очередь микрозадач целиком и только потом берёт следующую макрозадачу. Поэтому Promise.resolve().then(...), запланированный в тот же момент, что и setTimeout(..., 0), всегда выполнится раньше.
Почему setTimeout с 0 мс не срабатывает мгновенно?
setTimeout(fn, 0) — это не «запусти прямо сейчас», а «поставь fn в очередь макрозадач, не раньше чем через 0 мс». Сначала должен доработать текущий синхронный код, потом event loop очистит очередь микрозадач, и только после этого дойдёт очередь до вашего колбэка. Так что 0 — это нижняя граница задержки, а не гарантия немедленного вызова.