На этой странице

Event loop — это то, что позволяет Node.js выполнять неблокирующие операции ввода-вывода (I/O), несмотря на то что по умолчанию используется один поток JavaScript, — за счёт передачи операций ядру системы, когда это возможно.

Поскольку большинство современных ядер многопоточны, они могут обрабатывать множество операций, выполняющихся в фоне. Когда одна из этих операций завершается, ядро сообщает об этом Node.js, чтобы соответствующий колбэк можно было добавить в очередь poll и в конечном итоге выполнить. Мы объясним это подробнее далее в этой теме.

Когда Node.js стартует, он инициализирует event loop, обрабатывает предоставленный входной скрипт (или переходит в REPL, что в этом документе не рассматривается), который может делать асинхронные вызовы API, планировать таймеры или вызывать process.nextTick(), а затем начинает обрабатывать event loop.

Следующая диаграмма показывает упрощённый обзор порядка операций event loop.

   ┌───────────────────────────┐
   │           timers          │
   └─────────────┬─────────────┘
                 │
                 v
   ┌───────────────────────────┐
┌─>│     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │      close callbacks      │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤           timers          │
   └───────────────────────────┘

Каждый блок будем называть «фазой» event loop.

У каждой фазы есть FIFO-очередь колбэков для выполнения. Хотя каждая фаза по-своему особенная, в общем случае, когда event loop входит в заданную фазу, он выполняет все специфичные для этой фазы операции, а затем выполняет колбэки в очереди этой фазы, пока очередь не будет исчерпана или пока не выполнится максимальное число колбэков. Когда очередь исчерпана или достигнут лимит колбэков, event loop перейдёт к следующей фазе и так далее.

Поскольку любая из этих операций может запланировать ещё операции, а новые события, обрабатываемые в фазе poll, ставятся в очередь ядром, poll-события могут ставиться в очередь, пока обрабатываются polling-события. В результате долго выполняющиеся колбэки могут позволить фазе poll работать намного дольше порога таймера. Подробнее см. разделы timers и poll.

Между реализациями для Windows и Unix/Linux есть небольшое расхождение, но для этой демонстрации это не важно. Самые важные части здесь. На самом деле шагов семь или восемь, но те, что нас волнуют, — те, что Node.js действительно использует, — перечислены выше.

  • pending callbacks: выполняет I/O-колбэки, отложенные на следующую итерацию цикла.
  • idle, prepare: используется только внутренне.
  • poll: получает новые I/O-события; выполняет связанные с I/O колбэки (почти все, за исключением close-колбэков, колбэков, запланированных таймерами, и setImmediate()); node будет блокироваться здесь, когда это уместно.
  • check: здесь вызываются колбэки setImmediate().
  • close callbacks: некоторые close-колбэки, например socket.on('close', ...)
  • timers: эта фаза выполняет колбэки, запланированные setTimeout() и setInterval(). Кроме того, эти колбэки могут выполниться до входа в event loop. Иногда так происходит для setTimeout(() => ..., 0) вне цикла I/O.

Между каждым прогоном event loop Node.js проверяет, ждёт ли он какого-либо асинхронного I/O или таймеров, и чисто завершается, если их нет.

Начиная с libuv 1.45.0 (Node.js 20), таймеры выполняются после фазы poll в каждой итерации event loop. В более ранних версиях таймеры выполнялись до polling. Для обратной совместимости libuv 1.45.0 всё ещё запускает таймеры один раз перед входом в event loop. Это изменение может влиять на тайминг колбэков setImmediate() и на то, как они взаимодействуют с таймерами в определённых сценариях.

Эта фаза выполняет колбэки для некоторых системных операций, таких как определённые типы ошибок TCP. Например, если TCP-сокет получает ECONNREFUSED при попытке подключиться, некоторые *nix-системы хотят подождать, прежде чем сообщить об ошибке. Это будет поставлено в очередь на выполнение в фазе pending callbacks.

У фазы poll две основные функции:

  1. Вычислить, на сколько ей стоит блокироваться и опрашивать (poll) I/O, а затем
  2. Обработать события в очереди poll.

Когда event loop входит в фазу poll и нет запланированных таймеров, произойдёт одно из двух:

  • Если очередь poll не пуста, event loop переберёт свою очередь колбэков, выполняя их синхронно, пока либо очередь не будет исчерпана, либо не будет достигнут жёсткий лимит, зависящий от системы.

  • Если очередь poll пуста, произойдёт ещё одно из двух:

    • Если скрипты были запланированы через setImmediate(), event loop завершит фазу poll и перейдёт к фазе check, чтобы выполнить эти запланированные скрипты.

    • Если скрипты не были запланированы через setImmediate(), event loop будет ждать, пока колбэки добавятся в очередь, а затем немедленно выполнит их.

Как только очередь poll опустеет, event loop проверит таймеры, чьи временные пороги были достигнуты. Если один или несколько таймеров готовы, event loop вернётся к фазе timers, чтобы выполнить колбэки этих таймеров.

Эта фаза позволяет event loop выполнять колбэки сразу после того, как завершилась фаза poll. Если фаза poll становится простаивающей и скрипты были поставлены в очередь через setImmediate(), event loop может перейти к фазе check, а не ждать.

setImmediate() на самом деле является особым таймером, работающим в отдельной фазе event loop. Он использует API libuv, который планирует выполнение колбэков после завершения фазы poll.

Как правило, по мере выполнения кода event loop в конце концов достигает фазы poll, где будет ждать входящего соединения, запроса и т. д. Однако, если колбэк был запланирован через setImmediate() и фаза poll становится простаивающей, она завершится и перейдёт к фазе check, вместо того чтобы ждать poll-событий.

Если сокет или handle закрывается резко (например, socket.destroy()), событие 'close' будет эмитировано в этой фазе. Иначе оно будет эмитировано через process.nextTick().

Таймер задаёт порог, по истечении которого предоставленный колбэк может быть выполнен, а не точное время, когда человек хочет, чтобы он был выполнен. Колбэки таймеров выполнятся при первой возможности, когда их можно запланировать после того, как прошёл указанный промежуток времени; однако планирование операционной системой или выполнение других колбэков могут их задержать.

Технически то, когда выполняются таймеры, контролирует фаза poll.

Например, допустим, вы планируете таймаут на выполнение после порога в 100 мс, а затем ваш скрипт начинает асинхронно читать файл, что занимает 95 мс:

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

function someAsyncOperation(callback) {
  // Предположим, это занимает 95 мс
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);

// выполняем someAsyncOperation, которая занимает 95 мс
someAsyncOperation(() => {
  const startCallback = Date.now();

  // делаем что-то, что займёт 10 мс...
  while (Date.now() - startCallback < 10) {
    // ничего не делаем
  }
});

Когда event loop входит в фазу poll, у него пустая очередь (fs.readFile() ещё не завершился), поэтому он будет ждать количество мс, оставшихся до достижения ближайшего порога таймера. Пока он ждёт, проходит 95 мс, fs.readFile() заканчивает чтение файла, и его колбэк, выполнение которого занимает 10 мс, добавляется в очередь poll и выполняется. Когда колбэк завершается, в очереди больше нет колбэков, поэтому event loop увидит, что порог ближайшего таймера достигнут, и вернётся к фазе timers, чтобы выполнить колбэк таймера. В этом примере вы увидите, что общая задержка между планированием таймера и выполнением его колбэка составит 105 мс.

Чтобы фаза poll не «морила голодом» event loop, у libuv (C-библиотеки, реализующей event loop Node.js и всё асинхронное поведение платформы) также есть жёсткий максимум (зависящий от системы), после которого она прекращает опрашивать новые события.

setImmediate() и setTimeout() похожи, но ведут себя по-разному в зависимости от того, когда они вызваны.

  • setImmediate() спроектирован так, чтобы выполнить скрипт, как только завершится текущая фаза poll.
  • setTimeout() планирует запуск скрипта после того, как истечёт минимальный порог в мс.

Порядок, в котором выполняются таймеры, будет варьироваться в зависимости от контекста, в котором они вызваны. Если оба вызваны из главного модуля, тайминг будет ограничен производительностью процесса (на которую могут влиять другие приложения, работающие на машине).

Например, если запустить следующий скрипт, который не находится в цикле I/O (то есть в главном модуле), порядок, в котором выполняются два таймера, недетерминирован, так как он ограничен производительностью процесса:

// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});

Однако, если переместить оба вызова внутрь цикла I/O, immediate-колбэк всегда выполняется первым:

// timeout_vs_immediate.js
const fs = require('node:fs');

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

Главное преимущество использования setImmediate() перед setTimeout() в том, что setImmediate() всегда выполнится раньше любых таймеров, если запланирован внутри цикла I/O, независимо от того, сколько таймеров присутствует.

process.nextTick(): void

Вы могли заметить, что process.nextTick() не был показан на диаграмме, хотя и является частью асинхронного API. Так происходит потому, что process.nextTick() технически не является частью event loop. Вместо этого nextTickQueue будет обработана после завершения текущей операции, независимо от текущей фазы event loop. Здесь операция определяется как переход от нижележащего C/C++-обработчика к обработке JavaScript, который нужно выполнить.

Возвращаясь к нашей диаграмме: каждый раз, когда вы вызываете process.nextTick() в заданной фазе, все колбэки, переданные в process.nextTick(), будут разрешены до того, как event loop продолжится. Это может создавать плохие ситуации, потому что позволяет «заморить голодом» ваш I/O, делая рекурсивные вызовы process.nextTick(), что не даёт event loop достичь фазы poll.

Почему нечто подобное включено в Node.js? Отчасти это философия проектирования, согласно которой API всегда должен быть асинхронным, даже когда это не обязательно. Возьмём для примера такой фрагмент кода:

function apiCall(arg, callback) {
  if (typeof arg !== 'string') {
    return process.nextTick(
      callback,
      new TypeError('argument should be string')
    );
  }
}

Фрагмент проверяет аргумент и, если он некорректен, передаст ошибку в колбэк. API относительно недавно обновили, чтобы можно было передавать аргументы в process.nextTick(), позволяя ему брать любые аргументы, переданные после колбэка, и пробрасывать их как аргументы колбэка, чтобы вам не пришлось вкладывать функции друг в друга.

Мы возвращаем пользователю ошибку, но только после того, как позволили выполниться остальному пользовательскому коду. Используя process.nextTick(), мы гарантируем, что apiCall() всегда выполняет свой колбэк после остального пользовательского кода и до того, как event loop получит возможность продолжиться. Чтобы этого достичь, стеку вызовов JS позволяют раскрутиться, а затем немедленно выполнить предоставленный колбэк, что позволяет человеку делать рекурсивные вызовы process.nextTick(), не упираясь в RangeError: Maximum call stack size exceeded от v8.

Эта философия может приводить к потенциально проблемным ситуациям. Возьмём для примера этот фрагмент:

let bar = null;

// это имеет асинхронную сигнатуру, но вызывает колбэк синхронно
function someAsyncApiCall(callback) {
  callback();
}

// колбэк вызывается до того, как `someAsyncApiCall` завершится.
someAsyncApiCall(() => {
  // поскольку someAsyncApiCall не завершился, bar ещё не присвоено никакое значение
  console.log('bar', bar); // null
});

bar = 1;

Пользователь определяет someAsyncApiCall() с асинхронной сигнатурой, но на самом деле она работает синхронно. Когда её вызывают, колбэк, переданный в someAsyncApiCall(), вызывается в той же фазе event loop, потому что someAsyncApiCall() на самом деле не делает ничего асинхронного. В результате колбэк пытается обратиться к bar, хотя этой переменной может ещё не быть в области видимости, потому что скрипт не успел выполниться до конца.

Поместив колбэк в process.nextTick(), скрипт всё же получает возможность выполниться до конца, позволяя всем переменным, функциям и т. д. инициализироваться до вызова колбэка. Также это даёт преимущество не позволять event loop продолжиться. Пользователю может быть полезно узнать об ошибке до того, как event loop получит возможность продолжиться. Вот предыдущий пример с использованием process.nextTick():

let bar = null;

function someAsyncApiCall(callback) {
  process.nextTick(callback);
}

someAsyncApiCall(() => {
  console.log('bar', bar); // 1
});

bar = 1;

Вот ещё один пример из реального мира:

const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});

Когда передаётся только порт, порт привязывается немедленно. Так что колбэк 'listening' мог бы быть вызван немедленно. Проблема в том, что колбэк .on('listening') к тому моменту ещё не будет установлен.

Чтобы это обойти, событие 'listening' ставится в очередь через nextTick(), чтобы дать скрипту выполниться до конца. Это позволяет пользователю установить любые нужные ему обработчики событий.

У нас есть два вызова, которые с точки зрения пользователей похожи, но их названия сбивают с толку.

  • process.nextTick() срабатывает немедленно, в той же фазе
  • setImmediate() срабатывает на следующей итерации, или «тике», event loop

По сути, названия следовало бы поменять местами. process.nextTick() срабатывает более немедленно, чем setImmediate(), но это артефакт прошлого, который вряд ли изменится. Такая замена сломала бы большой процент пакетов на npm. Каждый день добавляются новые модули, а значит, каждый день ожидания увеличивает число потенциальных поломок. Пусть названия и запутанны, сами они не изменятся.

Мы рекомендуем разработчикам использовать setImmediate() во всех случаях, потому что о нём проще рассуждать.

Есть две основные причины:

  1. Позволить пользователям обработать ошибки, освободить ставшие ненужными ресурсы или, возможно, повторить запрос до того, как event loop продолжится.

  2. Порой необходимо позволить колбэку выполниться после того, как стек вызовов раскрутился, но до того, как event loop продолжится.

Один пример — соответствовать ожиданиям пользователя. Простой пример:

const server = net.createServer();
server.on('connection', conn => {});

server.listen(8080);
server.on('listening', () => {});

Допустим, listen() выполняется в начале event loop, но listening-колбэк помещён в setImmediate(). Если только не передано имя хоста, привязка к порту произойдёт немедленно. Чтобы event loop продолжился, он должен достичь фазы poll, а значит, существует ненулевая вероятность, что соединение могло быть получено, что позволило бы событию connection сработать до события listening.

Другой пример — расширение EventEmitter и эмит события изнутри конструктора:

const EventEmitter = require('node:events');

class MyEmitter extends EventEmitter {
  constructor() {
    super();
    this.emit('event');
  }
}

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});

Вы не можете эмитировать событие из конструктора немедленно,

потому что скрипт не дойдёт до момента, где пользователь присваивает колбэк этому событию. Поэтому внутри самого конструктора можно использовать process.nextTick(), чтобы установить колбэк, который эмитирует событие после того, как конструктор завершится, что даёт ожидаемый результат:

const EventEmitter = require('node:events');

class MyEmitter extends EventEmitter {
  constructor() {
    super();

    // используем nextTick, чтобы эмитировать событие после того, как назначен обработчик
    process.nextTick(() => {
      this.emit('event');
    });
  }
}

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});