Skip to content

Latest commit

 

History

History

event-loop-timers-and-nexttick

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 

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

Перевод официальной документации 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.

Статья на Medium