Node.js Event Loop
Node.js Event Loop
Источники
- https://exploringjs.com/nodejs-shell-scripting/ch_nodejs-overview.html > the-node.js-event-loop
Phases
- Phase “timers” invokes timed tasks that were added to its queue
- Phase “poll” retrieves and processes I/O events and runs I/O-related tasks from its queue
- Phase “check” (the “immediate phase”) executes tasks scheduled
Each phase runs until its queue is empty or until a maximum number of tasks was processed. Except for “poll”, each phase waits until its next turn before it processes tasks that were added during its run
Phase “poll”
- If the poll queue is not empty, the poll phase will go through it and run its tasks.
- Once the poll queue is empty:
- If there are
setImmediate()
tasks, processing advances to the “check” phase. - If there are timer tasks that are ready, processing advances to the “timers” phase
- Otherwise, this phase blocks the whole main thread and waits until new tasks are added to the poll queue (or until this phase ends, see below). These are processed immediately
- If there are
If this phase takes longer than a system-dependent time limit, it ends and the next phase runs.
Next-tick tasks and microtasks
After each invoked task, a “sub-loop” runs that consists of two phases:
The sub-phases handle:
- Next-tick tasks, as enqueued via
process.nextTick()
. - Microtasks, as enqueued via
queueMicrotask()
, Promise reactions, etc.
Next-tick tasks are Node.js-specific, Microtasks are a cross-platform web standard (see https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask > browser_compatibility).
This sub-loop runs until both queues are empty. Tasks added during its run, are processed immediately – the sub-loop does not wait until its next turn.
Comparing different ways of directly scheduling tasks
We can use the following functions and methods to add callbacks to one of the task queues:
- Timed tasks (phase “timers”)
setTimeout()
(web standard)setInterval()
(web standard)
- Untimed tasks (phase “check”)
setImmediate()
(Node.js-specific)
- Tasks that run immediately after the current task:
process.nextTick()
(Node.js-specific)queueMicrotask()
: (web standard)
Next-tick tasks and microtasks vs. normal tasks
Consider the following code:
function enqueueTasks() {
Promise.resolve().then(() => console.log('Promise reaction 1'));
queueMicrotask(() => console.log('queueMicrotask 1'));
process.nextTick(() => console.log('nextTick 1'));
setImmediate(() => console.log('setImmediate 1')); // (A)
setTimeout(() => console.log('setTimeout 1'), 0);
Promise.resolve().then(() => console.log('Promise reaction 2'));
queueMicrotask(() => console.log('queueMicrotask 2'));
process.nextTick(() => console.log('nextTick 2'));
setImmediate(() => console.log('setImmediate 2')); // (B)
setTimeout(() => console.log('setTimeout 2'), 0);
}
setImmediate(enqueueTasks);
We use setImmediate()
to avoid a pecularity of ESM modules: They are executed in microtasks, which means that if we enqueue microtasks at the top level of an ESM module, they run before next-tick tasks. As we’ll see next, that’s different in most other contexts.
This is the output of the previous code:
nextTick 1
nextTick 2
Promise reaction 1
queueMicrotask 1
Promise reaction 2
queueMicrotask 2
setTimeout 1
setTimeout 2
setImmediate 1
setImmediate 2
Observations:
- All next-tick tasks are executed immediately after
enqueueTasks()
. - They are followed by all microtasks, including Promise reactions.
- Phase “timers” comes after the immediate phase. That’s when the timed tasks are executed.
- We have added immediate tasks during the immediate (“check”) phase (line A and line B). They show up last in the output, which means that they were not executed during the current phase, but during the next immediate phase.
Enqueuing next-tick tasks and microtasks during their phases
The next code examines what happens if we enqueue a next-tick task during the next-tick phase and a microtask during the microtask phase:
setImmediate(() => {
setImmediate(() => console.log('setImmediate 1'));
setTimeout(() => console.log('setTimeout 1'), 0);
process.nextTick(() => {
console.log('nextTick 1');
process.nextTick(() => console.log('nextTick 2'));
});
queueMicrotask(() => {
console.log('queueMicrotask 1');
queueMicrotask(() => console.log('queueMicrotask 2'));
process.nextTick(() => console.log('nextTick 3'));
});
});
This is the output:
nextTick 1
nextTick 2
queueMicrotask 1
queueMicrotask 2
nextTick 3
setTimeout 1
setImmediate 1
Observations:
- Next-tick tasks are executed first.
- “nextTick 2” in enqueued during the next-tick phase and immediately executed. Execution only continues once the next-tick queue is empty.
- The same is true for microtasks.
- We enqueue “nextTick 3” during the microtask phase and execution loops back to the next-tick phase. These subphases are repeated until both their queues are empty. Only then does execution move on to the next global phases: First the “timers” phase (“setTimeout 1”). Then the immediate phase (“setImmediate 1”).
libuv
Как libuv
работает с асинхронным I/O
Сетевые I/O - асинхронные и не блокируют поток. Они включают:
- TCP
- UDP
- Terminal I/O
- Pipes (Unix domain sockets, Windows named pipes, etc.)
Для работы с асинхронным I/O, libuv
использует нативное API и подписывается на I/O эвенты (epoll
в Linux; kqueue
в BSD Unix и macOS; event ports
в SunOS; IOCP
в Windows)
Как libuv
работает с блокирующим I/O
Блокирующие I/O:
- file I/O
- Некоторые DNS сервисы
libuv
вызывает эти API из потоков в тред-пуле (“worker pool”). Это позволяют главному потоку вызывать их асинхронно
libuv
вне I/O
Использование libuv
в Node.js так же включает в себя:
- Выполнение тасок в тред-пуле
- Обработка сигналов
- High resolution clock (?)
- Мультипоточность и примитивы синхронизации
Разгрузка основного потока в коде
Если мы хотим, чтобы Node.js реагировал на события I/O, мы должны избегать тяжелых вычислений в основном потоке. Для этого есть 2 варианта:
- Разделение. Делим вычисления на мелкие части и запускаем через
setImmediate
. Недостаток в том, что это замедляет event loop - Разгрузка. Выполнение в отдельном потоке или процессе. Так мы не замедляем event loop, но появляется сложность в коммуникации между процессами
Worker threads
Worker Threads реализуют https://developer.mozilla.org/en-US/docs/Web/API/Worker > browser_compatibility с некоторыми различиями:
- Worker Threads импортируются из модуля, к Web Workers обращаются из глобальной переменной
- В воркере прослушивание и отправка сообщений сделаны через глобальные методы браузера. В Node.js, мы импортируем
parentPort
. - Мы можем использовать почти весь Node.js API в воркерах. В браузере выбор ограничен сильнее (например, нельзя использовать DOM)
- В Node.js больше передаваемых типов объектов все объекты, чьи классы наследуются от внутреннего класса
JSTransferable
)
С одной стороны, Worker Threads - это действительно потоки: они легче, чем процессы и запускаются в одном процессе с основным потоком.
С другой стороны:
- Каждый воркер имеет свой event loop
- Каждый воркер имеет свой инстанс JavaScript engine и свой инстанс Node.js - включая собственные глобальные переменные. Конкретно, каждый воркер - это V8 isolate со своим JavaScript heap, но при этом делит system heap с другими потоками.
- Обмен данными между потоками ограничен:
- Бинарными данными/числами через
SharedArrayBuffers
Atomics
- атомарными операциями и примитивами синхронизации, которые помогают при использованииSharedArrayBuffers
.- The Channel Messaging API позволяет пересылать данные через двусторонние каналы. Данные либо копируются (cloned), либо перемещаются (transferred). Последнее более эффективно, но поддержано https://developer.mozilla.org/en-US/docs/Glossary/Transferable_objects > supported_objects.
- Бинарными данными/числами через
For more information, see the Node.js documentation on worker threads.
Clusters
Кластеры - специфичное Node.js API. Оно позволяет запускать кластеры Node.js процессов, которые можно использовать для распределения нагрузки. Процессы полностью изолированы, за исключением общего сетевого порта. Они могут общаться передачей JSON'ов через каналы связи.
Если изолированность процессов не нужна, то лучше брать более легковесные Worker Threads.
Child processes (Дочерние процессы)
Дочерние процессы - еще одно специфичное Node.js API. Позволяет спавнить новые процессы, которые запускают нативные команды (часто через нативные шеллы). Этот API описан тут: §12 “Running shell commands in child processes”.