Как устроен 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. Следующая фаза после poll — check, поэтому 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 цикл дойдёт только на следующей итерации.