Skip to content

Цикл событий Node.js

Что такое цикл событий?

Цикл событий — это то, что позволяет Node.js выполнять неблокирующие операции ввода-вывода — несмотря на то, что по умолчанию используется один поток JavaScript — путем перекладывания операций на системное ядро, когда это возможно.

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

Объяснение цикла событий

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

На следующей диаграмме показан упрощенный обзор порядка операций цикла событий.

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

TIP

Каждый блок будет называться "фазой" цикла событий.

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

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

TIP

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

Обзор фаз

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

Между каждым проходом цикла событий Node.js проверяет, ожидает ли он какой-либо асинхронный ввод-вывод или таймеры, и завершает работу, если их нет.

Фазы подробно

timers

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

TIP

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

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

js
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);
// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();
  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

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

TIP

Чтобы предотвратить истощение цикла событий фазой опроса, libuv (библиотека C, которая реализует цикл событий Node.js и все асинхронные поведения платформы) также имеет жесткий максимум (зависит от системы), прежде чем он прекратит опрос для получения дополнительных событий.

отложенные обратные вызовы

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

опрос (poll)

Фаза опроса имеет две основные функции:

  1. Вычисление, как долго следует блокировать и опрашивать ввод/вывод, затем
  2. Обработка событий в очереди опроса.

Когда цикл событий входит в фазу опроса и таймеры не запланированы, произойдет одно из двух:

  • Если очередь опроса не пуста, цикл событий будет перебирать очередь обратных вызовов, выполняя их синхронно, пока очередь не будет исчерпана или не будет достигнут аппаратно заданный предел.

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

    • Если скрипты были запланированы с помощью setImmediate(), цикл событий завершит фазу опроса и перейдет к фазе check для выполнения этих запланированных скриптов.

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

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

проверка (check)

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

setImmediate() на самом деле является специальным таймером, который работает в отдельной фазе цикла событий. Он использует API libuv, который планирует выполнение обратных вызовов после завершения фазы опроса.

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

Обратные вызовы close

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

setImmediate() против setTimeout()

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

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

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

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

js
// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);
setImmediate(() => {
  console.log('immediate');
});
bash
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout

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

js
// timeout_vs_immediate.js
const fs = require('node:fs');
fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
bash
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout

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

process.nextTick()

Понимание process.nextTick()

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

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

Почему это разрешено?

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

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

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

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

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

js
let bar;
// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback) {
  callback();
}
// the callback is called before `someAsyncApiCall` completes.
someAsyncApiCall(() => {
  // since someAsyncApiCall hasn't completed, bar hasn't been assigned any value
  console.log('bar', bar); // undefined
});
bar = 1;

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

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

js
let bar;
function someAsyncApiCall(callback) {
  process.nextTick(callback);
}
someAsyncApiCall(() => {
  console.log('bar', bar); // 1
});
bar = 1;

Вот еще один реальный пример:

js
const server = net.createServer(() => {}).listen(8080);
server.on('listening', () => {});

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

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

process.nextTick() против setImmediate()

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

  • process.nextTick() запускается немедленно на той же фазе
  • setImmediate() запускается на следующей итерации или 'tick' цикла событий

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

TIP

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

Зачем использовать process.nextTick()?

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

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

  2. Иногда необходимо позволить обратной функции запуститься после того, как стек вызовов раскрутился, но до продолжения цикла событий.

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

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

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

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

js
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(), чтобы установить обратную функцию для генерации события после завершения конструктора, что дает ожидаемые результаты:

js
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!');
});