Node.js Event Loop

Node.js Event Loop

Источники

  • https://exploringjs.com/nodejs-shell-scripting/ch_nodejs-overview.html > the-node.js-event-loop

Phases

Pasted image 20221010012527.png

  • 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 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:

Pasted image 20221010012930.png

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”.