Как устроен event loop и какие у него фазы

Первый вопрос почти любого собеседования по Node.js: «Расскажите, как работает event loop». Разбираем, что именно хочет услышать интервьюер.

Event loop — это бесконечный цикл в libuv, который по очереди проходит несколько фаз и на каждой фазе выполняет накопившиеся колбэки. Пока колбэк выполняется, цикл стоит: JavaScript в Node выполняется в одном потоке.

Что на самом деле проверяет интервьюер

Ему не нужен пересказ статьи из документации. Он проверяет три вещи: понимаете ли вы, что ваш код и I/O живут в разных местах; можете ли вы объяснить, почему один медленный синхронный вызов кладёт весь сервер; и умеете ли вы предсказывать порядок выполнения асинхронных операций. Если человек умеет это, он не будет писать JSON.parse по десятимегабайтному телу запроса в обработчике и не удивится, почему при 500 RPS отваливаются health-check'и.

Развёрнутый ответ: фазы цикла

Каждая итерация цикла (её называют tick) проходит фазы в фиксированном порядке. У каждой фазы своя очередь колбэков.

ФазаЧто выполняется
timersколбэки setTimeout и setInterval, у которых истёк срок
pending callbacksотложенные системные колбэки, например часть ошибок TCP
idle, prepareслужебные, только для внутренних нужд libuv
pollглавная фаза: здесь Node ждёт новые события ввода-вывода и выполняет их колбэки (чтение файла, входящий HTTP-запрос, ответ от базы)
checkколбэки setImmediate
close callbacksсобытия закрытия: socket.on('close') и подобные

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

Ключевой пример: setTimeout против setImmediate

Это любимая практическая проверка на интервью. Один и тот же код ведёт себя по-разному в зависимости от того, где он вызван.

// главный модуль: порядок НЕ гарантирован
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

Здесь возможен любой из двух порядков, и это не баг. setTimeout(fn, 0) на самом деле означает «примерно через 1 мс». Если запуск процесса занял чуть больше миллисекунды, к моменту входа в фазу timers таймер уже созрел и сработает первым. Если нет — цикл дойдёт до фазы check и первым отработает setImmediate.

А теперь тот же код внутри I/O-колбэка:

const fs = require('node:fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});

Вывод (всегда одинаковый):

immediate
timeout

Объяснение простое и его ждут дословно: колбэк readFile выполняется в фазе poll. Следующая фаза после pollcheck, поэтому setImmediate отработает в этой же итерации. А до фазы timers цикл доберётся только на следующем витке. Отсюда практическое правило: внутри I/O-колбэка setImmediate всегда быстрее setTimeout(fn, 0).

Почему это важно на практике

Фаза poll — это то место, где ваш сервер принимает запросы. Если в одном из колбэков вы запустите синхронный цикл на 300 мс, цикл не выйдет из этого колбэка все 300 мс: не сработают таймеры, не примутся новые соединения, не отправятся ответы уже обработанным запросам. Один пользователь с тяжёлым запросом задерживает всех остальных. Именно поэтому в Node так болезненны синхронные функции: fs.readFileSync, crypto.pbkdf2Sync, разбор гигантского JSON, сортировка ста тысяч объектов в памяти.

Типичные ошибки кандидатов

  • Говорят «event loop — это очередь колбэков». Очередь не одна: у каждой фазы своя, плюс две внеочередные для микрозадач.
  • Утверждают, что setTimeout(fn, 0) выполнится «сразу же». Минимальная задержка — 1 мс, и колбэк ждёт своей фазы.
  • Считают, что event loop живёт в V8. Нет: V8 исполняет JavaScript, а цикл и асинхронный ввод-вывод — это libuv.
  • Не могут ответить, почему в примере с readFile порядок вдруг стал детерминированным.

Как ответить кратко

Event loop — цикл в libuv, который крутится по фазам: timers, pending callbacks, poll, check, close. В каждой фазе выполняются свои колбэки, а между ними полностью опустошаются очереди process.nextTick и микрозадач промисов. JavaScript при этом выполняется в одном потоке, поэтому любой долгий синхронный код блокирует весь сервер. Внутри I/O-колбэка setImmediate срабатывает раньше setTimeout(fn, 0), потому что фаза check идёт сразу за poll, а до timers цикл дойдёт только на следующей итерации.

Проверьте себя
1. В какой фазе event loop выполняются колбэки setImmediate?
Atimers
Bpoll
Ccheck
Dclose callbacks
2. Почему внутри колбэка fs.readFile setImmediate всегда срабатывает раньше setTimeout(fn, 0)?
AПотому что setImmediate имеет более высокий приоритет в очереди микрозадач
BПотому что колбэк readFile выполняется в фазе poll, а check идёт сразу за ней, тогда как до timers цикл дойдёт лишь на следующей итерации
CПотому что setTimeout всегда ждёт минимум 100 мс
DПорядок и там не гарантирован, просто обычно так получается
3. Что произойдёт, если в обработчике HTTP-запроса выполнить синхронный цикл на 300 миллисекунд?
ANode вынесет цикл в thread pool, остальные запросы не пострадают
BEvent loop остановится на 300 мс: новые запросы, таймеры и отправка ответов будут ждать
CV8 автоматически разобьёт цикл на части между фазами
DЗапрос обработается в отдельном процессе кластера