Цикл событий Node.js, таймеры и process.nextTick()

Andrey Melikhov
devSchacht
Published in
9 min readMay 10, 2017

--

Перевод официальной документации Node.js

Что такое Event Loop?

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

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

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

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

Расположенная ниже диаграмма упрощённо показывает порядок выполнения операций в цикле событий.

   ┌───────────────────────┐
┌─>│ таймеры │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O коллбэки │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ ожидание, подготовка │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ входящие: │
│ │ опрос │<─────┤ соединения, │
│ └──────────┬────────────┘ │ данные, итд. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ проверка │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ коллбэки `close` │
└───────────────────────┘

примечание: каждый прямоугольник будет рассматриваться как «фаза» в цикле событий.

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

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

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

Обзор фаз

  • таймеры: в этой фазе выполняются коллбэки, запланированные setTimeout() и setInterval();
  • I/O коллбэки: выполняются почти все коллбэки, за исключением событий close, таймеров и setImmediate();
  • ожидание, подготовка: используется только для внутренних целей;
  • опрос: получение новых событий ввода/вывода. Node.js может блокироваться на этом этапе;
  • проверка: коллбэки, вызванные setImmediate(), вызываются на этом этапе;
  • коллбэки события close: например, socket.on('close', ...);

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

Фазы в деталях

таймеры

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

Примечание: Технически фаза опроса контролирует выполнение таймеров.

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

var fs = require('fs');function someAsyncOperation (callback) {
// Предположим, это завершится через 95мс
fs.readFile('/path/to/file', callback);
}
var timeoutScheduled = Date.now();setTimeout(function () { var delay = Date.now() - timeoutScheduled; console.log(delay + "ms have passed since I was scheduled");
}, 100);
// выполнить someAsyncOperation, требующую 95мс для завершения
someAsyncOperation(function () {
var startCallback = Date.now(); // выполнить что-то, что займёт 10мс...
while (Date.now() - startCallback < 10) {
; // ничего не делать
}
});

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

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

I/O коллбэки (коллбэки ввода/вывода)

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

опрос

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

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

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

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

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

проверка

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

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

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

коллбэки событий close

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

setImmediate() vs setTimeout()

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

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

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

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

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

Однако, если вы перемещаете оба вызова в цикл ввода/вывода, коллбэк setImmediate всегда выполняется первым:

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

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

process.nextTick()

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

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

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

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

Почему что-то вроде этого должно быть включено в 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() всегда запускает коллбэк после остальной части кода пользователя и до того, как цикл событий перейдёт на следующий цикл. Для этого стек вызовов JS разрешается раскрывать, а затем немедленно выполнять предоставленный коллбэк, это позволяет человеку делать рекурсивные вызовы process.nextTick(), не достигая RangeError: Maximum call stack size exceeded from v8.

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

// эта функция имеет асинхронную сигнатуру, но вызывает коллбэк синхронно
function someAsyncApiCall (callback) { callback(); };
// коллбэк вызывается до того как `someAsyncApiCall` будет закончена.
someAsyncApiCall(() => {
// пока someAsyncApiCall не закончится, bar не будет иметь никакого значения
console.log('bar', bar); // undefined
});var bar = 1;

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

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

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

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

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

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

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

process.nextTick() vs setImmediate()

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

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

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

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

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

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

  1. Разрешить пользователям обрабатывать ошибки, очищать любые ненужные ресурсы или, возможно, повторять попытку до продолжения цикла обработки событий.
  2. Иногда необходимо разрешить выполнение коллбэка после разбора стека вызовов, но до продолжения цикла событий.

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

var server = net.createServer();
server.on('connection', function(conn) { });
server.listen(8080);
server.on('listening', function() { });

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

Другим примером является запуск конструктора функции, который должен быть, например, отнаследован от EventEmitter, и хочет вызвать событие внутри конструктора:

const EventEmitter = require('events');
const util = require('util');
function MyEmitter() {
EventEmitter.call(this);
this.emit('event');
}
util.inherits(MyEmitter, EventEmitter);
const myEmitter = new MyEmitter();
myEmitter.on('event', function() {
console.log('an event occurred!');
});

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

const EventEmitter = require('events');
const util = require('util');
function MyEmitter() {
EventEmitter.call(this);
// use nextTick to emit the event once a handler is assigned
process.nextTick(function () {
this.emit('event');
}.bind(this));
}
util.inherits(MyEmitter, EventEmitter);
const myEmitter = new MyEmitter();
myEmitter.on('event', function() {
console.log('an event occurred!');
});

Слушайте наш подкаст в iTunes и SoundCloud, читайте нас на Medium, контрибьютьте на GitHub, общайтесь в группе Telegram, следите в Twitter и канале Telegram, рекомендуйте в VK и Facebook.

Статья на GitHub

--

--

Andrey Melikhov
devSchacht

Web-developer in big IT company Перевожу всё, до чего дотянусь. Иногда (но редко) пишу сам.