awesome-everything EN
↑ Обратно к восхождению

Архитектура бэкенда

Event loop: один поток, упорядоченные фазы

Суть Event loop — это один поток, выполняющий очереди колбэков в фиксированных фазах: timers, poll, check, close — осушая между каждой более приоритетную очередь микрозадач. Этот порядок объясняет setTimeout vs setImmediate vs Promise и почему конкурентность кооперативная.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 13 min

Разработчик пишет setTimeout(fn, 0) рядом с setImmediate(fn) рядом с Promise.resolve().then(fn), ожидая, что они сработают в порядке исходника. Не сработают. Сначала бежит промис, потом — в зависимости от того, где стоит код — либо таймер, либо immediate. Это не баг и не гонка; это опубликованное расписание event loop. Цикл — не чёрный ящик, который «когда-нибудь выполнит асинхронщину». Это строгая упорядоченная машина, и как только видишь её фазы, тайминг асинхронности перестаёт быть фольклором.

Цикл — это последовательность фаз

Единственный поток из прошлого урока не выполняет колбэки в порядке прихода. Он крутит фиксированную последовательность фаз (в libuv, движке цикла Node), и у каждой фазы своя очередь колбэков, которую надо осушить перед переходом дальше. Те, что важны изо дня в день:

  • Timers — колбэки, у которых истёк таймаут setTimeout/setInterval.
  • Poll — сердце: получить новые I/O-события и выполнить их колбэки (пришёл HTTP-запрос, закончилось чтение файла). Если ничего не ждёт, цикл блокируется здесь, ожидая на epoll/kqueue — именно здесь проводится время простоя.
  • Check — колбэки, запланированные через setImmediate, выполняются сразу после poll.
  • Close — колбэки событий закрытия (socket.on('close')).

Один полный проход по фазам — это тик (tick) цикла. Внутри фазы колбэки выполняются по одному, до конца — без вытеснения.

Макрозадачи vs микрозадачи

Два этих слова прячут всю модель тайминга. Очереди фаз держат макрозадачи: колбэк таймера, I/O-колбэк, setImmediate. Отдельно от них — две очереди микрозадач с более высоким приоритетом:

  • Колбэки process.nextTick (специфика Node), осушаются первыми.
  • Реакции промисов (продолжения .then/await).

Правило: после завершения каждого отдельного колбэка цикл полностью осушает очереди микрозадач, прежде чем выполнить следующий колбэк или сменить фазу. Поэтому Promise.resolve().then(fn) обгоняет setTimeout(fn, 0) — реакция промиса есть микрозадача, выполняемая на ближайшем осушении, тогда как таймер ждёт фазы timers на более позднем тике.

Почему это работает

Зачем вообще отдельная, более приоритетная очередь микрозадач? Потому что цепочки промисов должны устаканиться прежде, чем программа вернётся к планировщику I/O, иначе порядок был бы непредсказуем между тиками. Микрозадачи позволяют единице синхронно выглядящей асинхронной работы (await разрешился, затем следующий await) завершиться как группа, прежде чем цикл сделает что-либо ещё. Опасность — зеркальная: микрозадача может запланировать другую микрозадачу, та — ещё одну, неограниченная цепочка (рекурсия process.nextTick или сбежавший цикл промисов) морит цикл голодом, потому что микрозадачи осушаются до пустоты прежде, чем выполнится любой I/O-колбэк. Так что микрозадачи дают плотный порядок, но потоп микрозадач способен заблокировать фазу poll так же жёстко, как синхронный цикл — цикл никогда не доберётся до своих сокетов.

setTimeout(0) vs setImmediate

Эти два — классическая путаница. setImmediate всегда бежит в фазе check, сразу после poll; setTimeout(fn, 0) зажат до минимума ~1 мс и бежит в фазе timers. Внутри I/O-колбэка (то есть уже в poll или сразу после) setImmediate надёжно срабатывает раньше следующей фазы timers, поэтому выигрывает. На верхнем уровне скрипта порядок не гарантирован — он зависит от того, сколько времени цикл потратил на запуск. Старший вывод не в мелочи, а в модели: «сразу после I/O» и «после задержки таймера» — это разные фазы, а не одна очередь.

Кооперативная, не параллельная

Поскольку один поток выполняет каждый колбэк до конца без вытеснения, конкурентность event loop кооперативная: каждый колбэк должен добровольно уступить — завершившись или наткнувшись на await, который отдаёт управление — чтобы выполнился любой другой колбэк. Два запроса никогда не выполняют JavaScript в один и тот же момент; они чередуются в точках уступки. В этом вся сила (никаких локов, никаких гонок данных по разделяемому состоянию внутри тика) и вся слабость (один колбэк, который никогда не уступает, замораживает всё), что следующий урок делает конкретным.

ПланировщикТип очередиФаза / таймингПриоритет
process.nextTick(fn)МикрозадачаПосле текущего колбэка, до промисовНаивысший
Promise.then / awaitМикрозадачаПосле текущего колбэка, после nextTickВысокий
setTimeout(fn, 0)МакрозадачаФаза timers, следующий годный тикОбычный
I/O-колбэкМакрозадачаФаза pollОбычный
setImmediate(fn)МакрозадачаФаза check, сразу после pollОбычный
Викторина

Внутри I/O-колбэка ты планируешь `setTimeout(a, 0)`, `setImmediate(b)` и `Promise.resolve().then(c)`. В каком порядке они выполнятся?

Викторина

Почему рекурсивный `process.nextTick` (или сбежавшая цепочка промисов) может застопорить I/O, хотя никакой синхронный цикл не выполняется?

Расставь шаги по порядку

Расставь по порядку один тик event loop по его основным фазам:

  1. 1 Фаза timers: выполнить истёкшие колбэки setTimeout/setInterval
  2. 2 Осушить микрозадачи (nextTick, затем промисы)
  3. 3 Фаза poll: выполнить готовые I/O-колбэки (или ждать на epoll/kqueue)
  4. 4 Фаза check: выполнить колбэки setImmediate
  5. 5 Фаза close: выполнить колбэки событий закрытия
Вспомните перед уходом
  1. 01
    Какие основные фазы у event loop и что делает каждая?
  2. 02
    В чём разница между макрозадачами и микрозадачами и каково правило осушения?
  3. 03
    Почему конкурентность event loop называют кооперативной, а не параллельной, и что из этого следует?
Итог

Event loop — это строгая упорядоченная машина, а не размытый ящик «выполнит асинхронщину позже». Один поток крутит фиксированные фазы — timers, poll, check, close — и внутри каждой фазы выполняет очереди колбэков по одному до конца. Poll — центр тяжести: он выполняет готовые I/O-колбэки и, простаивая, блокируется на epoll/kqueue. Поперёк фаз идут две более приоритетные очереди микрозадач, сначала process.nextTick, затем реакции промисов, и определяющее правило в том, что цикл осушает микрозадачи до пустоты после каждого колбэка прежде, чем продвинуться — именно поэтому разрешённый промис обгоняет setTimeout(0) и почему setImmediate (фаза check) и setTimeout(0) (фаза timers) не взаимозаменяемы. Поскольку колбэки выполняются до конца без вытеснения, конкурентность кооперативная: запросы чередуются только там, где уступают, давая безопасность без локов внутри тика, но делая цикл заложником любого колбэка, который отказывается уступать. Этот сценарий заложника — что именно блокирует цикл и как это увидеть — следующий урок.

Связанные уроки
встречается в185
Продолжить восхождение ↑Что блокирует цикл: CPU-работа и синхронные вызовы
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.