Event loop в Node.js это цикл, который решает, какой callback выполнить следующим, после того как ваш синхронный код закончил работу. Он проходит фазы по постоянному кругу: timers (setTimeout, setInterval), pending callbacks, poll (ввод-вывод, например чтение файлов и данные из сети), check (setImmediate) и close callbacks. После каждого callback'а Node сначала опустошает очередь process.nextTick, а затем очередь промисов, поэтому они всегда выполняются раньше, чем цикл пойдёт дальше.
Программа выводит sync, nextTick, promise, setTimeout, то есть в обратном порядке по сравнению с тем, как строки написаны. Синхронный код всегда заканчивается первым, потом Node опустошает очередь nextTick, потом очередь промисов, и только после этого цикл запускается и доходит до фазы timers.
Фазы event loop
Цикл Node взят из libuv, библиотеки на C, через которую Node работает с вводом-выводом. Перед первым оборотом он выполняет таймеры, время которых уже пришло, а затем каждый оборот проходит эти фазы по порядку:
- Pending callbacks: несколько системных callback'ов, отложенных с прошлого оборота, например некоторые сетевые ошибки.
- Poll: новые события ввода-вывода и их callback'и (чтение файла закончилось, на сокет пришли данные). Если больше ничего не запланировано, цикл ждёт здесь.
- Check: callback'и
setImmediate. - Close callbacks: события
'close', например у уничтоженного сокета. - Timers: callback'и
setTimeoutиsetInterval, время которых пришло.
Во многих руководствах timers стоят первыми в каждом обороте: так Node работал до версии 20 (libuv 1.45). В обоих случаях callback'и выполняются в одном и том же порядке: за таймерами одного оборота идёт фаза poll следующего.
Две очереди находятся вне фаз: очередь nextTick и очередь микрозадач (callback'и промисов и queueMicrotask). Node опустошает обе после каждого отдельного callback'а в любой фазе, сначала nextTick. Цикл крутится, пока есть что ждать (таймер, открытый файл или сокет, сервер), и процесс завершается, когда ничего не осталось.
Это собственный цикл Node. В браузере та же идея задач и микрозадач, но нет фаз, нет setImmediate и нет process.nextTick; картину на уровне языка описывает страница про event loop в JavaScript.
process.nextTick и промисы
Обе очереди выполняются до того, как цикл продолжит работу, но они не перемешиваются. Node выполняет всю очередь nextTick, включая tick'и, добавленные во время её выполнения, и только потом очередь промисов. Угадайте порядок:
Порядок такой: tick 1, tick 2, tick from tick, promise 1, promise 2. Tick, запланированный внутри другого tick'а, попадает в ту же очередь и всё равно выполняется раньше любого промиса, хотя промис был создан первым.
На верхнем уровне ES-модуля (файла, который использует import) они меняются местами: код самого модуля выполняется внутри задачи промиса, поэтому его callback'и промисов заканчиваются раньше, чем Node доберётся до очереди nextTick. Внутри callback'ов правило «сначала nextTick» действует в обеих системах модулей.
setImmediate и setTimeout(fn, 0)
setTimeout(fn, 0) на самом деле значит «не раньше чем через 1 мс, в фазе timers». setImmediate(fn) значит «в следующей фазе check». Что выполнится первым, зависит от того, где вы их вызываете.
Внутри callback'а ввода-вывода порядок фиксирован. Callback выполняется в фазе poll, сразу за ней идёт фаза check, а фаза timers наступает только после этого:
Программа каждый раз выводит nextTick, setImmediate, setTimeout. Поэтому setImmediate подходит, когда нужно «выполнить это после текущей работы с вводом-выводом».
В главном модуле, вне любых callback'ов, порядок этих двух не фиксирован:
// Order depends on how fast the process started
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
Если к старту цикла прошла хотя бы 1 мс, таймер уже готов и выводит строку первым; иначе побеждает setImmediate. Не пишите код, который зависит от любого из этих исходов.
| Выполняется | Для чего | |
|---|---|---|
process.nextTick(fn) | Сразу после текущей операции, до промисов | Отправить событие или ошибку после того, как вызывающий код подключил слушателей |
Promise.then, queueMicrotask | Когда очередь nextTick пуста | Обычный асинхронный код |
setImmediate(fn) | Фаза check, после callback'ов ввода-вывода | Отдать управление циклу между порциями работы |
setTimeout(fn, 0) | Фаза timers, не раньше чем через 1 мс | Задержки, где точный момент не важен |
Блокировка event loop
Поток JavaScript один, поэтому callback, который работает долго, останавливает всё остальное: таймеры, callback'и ввода-вывода и, на сервере, все остальные запросы. Задержка таймера это минимум, а не гарантия.
Сначала выводится loop finished, затем true: таймер был готов через 10 мс, но не мог выполниться, пока занятый цикл не отдал поток. На сервере такой же цикл внутри обработчика маршрута замораживает все остальные запросы на полсекунды.
Долгую работу нужно разбить на части и между ними давать циклу ход через setImmediate:
Таймер успевает выполниться после части 1. Замените setImmediate(() => work(part + 1)) на work(part + 1) и запустите снова: все три части выполнятся за один раз, и timer выведется последним.
Для работы, которая тяжёлая от начала до конца, например обработки изображений, разбиения мало: вынесите её из главного потока с помощью worker_threads.
Частые ошибки
Рекурсивный вызов process.nextTick. Callback nextTick, который всё время планирует следующий nextTick, не даёт циклу продолжить работу, и таймеры с вводом-выводом голодают. Для повторяющейся работы используйте setImmediate.
Синхронные API на сервере. fs.readFileSync, crypto.pbkdf2Sync и большие вызовы JSON.parse блокируют цикл для всех пользователей. Внутри обработчиков запросов используйте асинхронные версии.
Ожидание, что setTimeout(fn, 0) выполнится сразу. Он выполняется после всего синхронного кода, каждого nextTick и каждого callback'а промиса, и только когда цикл дойдёт до фазы timers.
Часто задаваемые вопросы
Node.js однопоточный?
Ваш JavaScript выполняется в одном потоке, поэтому два callback'а никогда не работают одновременно. Сам Node использует больше потоков: libuv держит пул (по умолчанию четыре потока) для работы с файловой системой, DNS-запросов и части криптографии и возвращает результаты в цикл.
Когда программа на Node.js завершается?
Когда event loop больше нечего ждать: нет таймеров, незавершённого ввода-вывода, открытых серверов и сокетов. Работающий setInterval или слушающий сервер не дают процессу завершиться, пока вы их не очистите или не закроете.
Event loop в Node.js такой же, как в браузере?
Идея та же, один поток выполняет callback'и из очереди, но детали отличаются. В браузере нет setImmediate и process.nextTick, и он встраивает отрисовку между задачами, а Node проходит фазы libuv. Общую часть на уровне языка описывает страница про event loop в JavaScript.
Как понять, что event loop заблокирован?
Измерьте, насколько он опаздывает: monitorEventLoopDelay() из perf_hooks записывает задержку, и среднее значение в десятки миллисекунд значит, что какой-то callback слишком долго держит поток. Запустите Node с --cpu-prof, чтобы увидеть, какая это функция.