Цикл событий — это механизм Node.js для обработки неблокирующих операций ввода-вывода (даже несмотря на то, что JavaScript является однопоточным) путем выгрузки операций на ядро системы, когда это возможно.
Поскольку сегодня большинство ядер являются многопоточными, они могут выполнять различные операции в фоновом режиме. Когда одна из операций завершена, ядро уведомляет Node.js о необходимости добавить соответствующую функцию обратного вызова в очередь опроса и дождаться возможности выполнения. Мы представим его подробно позже в этой статье.
. При запуске Node.js инициализирует цикл событий и обрабатывает предоставленный входной скрипт (или передает его в REPL, который не рассматривается в этой статье). Он может вызывать некоторые асинхронные API, планировать таймеры и т. д. или вызовите process.nextTick()
и затем начните обработку цикла событий.
На диаграмме ниже показан упрощенный обзор последовательности операций цикла событий.
┌────────────────────────────┐ ┌─>│ таймеры │ + + │ │ ожидающие обратные вызовы │ + + │ │ простаивать, готовиться │ │ └──────────────┬───────────┘ ┌───────────────┐ │ ┌─────────────┴────────────┐ │ входящие: │ │ │ опрос │<─────┤ связи, │ │ └─────────────┬─────────────┘ │ данные и т. д. │ + │ │ проверить │ + + └──┤ закрытие обратных вызовов │ └──────────────────────────────┘
Примечание. Каждое поле называется этапом механизма цикла событий.
На каждом этапе имеется очередь FIFO для выполнения обратных вызовов. Хотя каждый этап уникален, обычно, когда цикл событий переходит на данный этап, он выполняет любые операции, специфичные для этого этапа, а затем выполняет обратные вызовы в очереди этого этапа до тех пор, пока очередь не будет исчерпана или не будет выполнено максимальное количество обратных вызовов. Когда очередь исчерпана или достигнут предел обратного вызова, цикл событий переходит к следующей фазе и так далее.
Поскольку любая из этих операций может запланировать дополнительные операции и новые события, поставленные ядром в очередь для обработки на этапе опроса , события опроса могут быть поставлены в очередь во время обработки событий на этапе опроса. Таким образом, длительный обратный вызов может привести к тому, что фаза опроса будет длиться дольше, чем пороговое время таймера. Дополнительную информацию см. в разделе «Таймеры и опрос» .
Примечание. Между реализациями Windows и Unix/Linux есть небольшие различия, но это не важно для целей демонстрации. Самая важная часть здесь. На самом деле шагов семь или восемь, но нас волнует то, что Node.js действительно использует некоторые из описанных выше шагов.
ТаймерsetTimeout()
и setInterval()
.setImmediate()
), в других случаях узел будет блокироваться здесь, когда это необходимо.setImmediate()
.socket.on('close', ...)
.Между каждым запуском цикла событий Node.js проверяет, ожидает ли он каких-либо асинхронных операций ввода-вывода или таймеров, и если нет, полностью отключается.
Таймеры определяют порог , при котором может быть выполнен предоставленный обратный вызов, а не точное время, когда пользователь хочет, чтобы он был выполнен. По истечении указанного интервала обратный вызов таймера будет запущен как можно раньше. Однако они могут задерживаться из-за планирования операционной системы или других выполняемых обратных вызовов.
Примечание . Фаза опроса определяет время выполнения таймера.
Например, предположим, что вы запланировали таймер, срабатывающий через 100 миллисекунд, а затем ваш скрипт начинает асинхронное чтение файла, которое занимает 95 миллисекунд:
const fs = require('fs'); функция someAsyncOperation (обратный вызов) { // Предположим, что это занимает 95 мс. fs.readFile('/путь/к/файлу', обратный вызов); } const timeoutScheduled = Date.now(); setTimeout(() => { константная задержка = Date.now() - timeoutScheduled; console.log(`${delay}мс прошло с момента моего планирования`); }, 100); // выполняем someAsyncOperation, выполнение которой занимает 95 мс someAsyncOperation(() => { const startCallback = Date.now(); // делаем что-то, что займет 10 мс... while (Date.now() - startCallback <10) { // ничего не делать } });
Когда цикл событий переходит в фазу опроса , он имеет пустую очередь ( fs.readFile()
еще не завершился), поэтому он будет ждать оставшееся количество миллисекунд, пока не будет достигнут самый быстрый порог таймера. Когда он ожидает 95 миллисекунд, пока fs.readFile()
завершит чтение файла, его обратный вызов, выполнение которого занимает 10 миллисекунд, будет добавлен в очередь опроса и выполнен. Когда обратный вызов завершается, в очереди больше нет обратных вызовов, поэтому механизм цикла событий проверит таймер, который быстрее всего достиг порогового значения, а затем вернется к фазе таймера, чтобы выполнить обратный вызов таймера. В этом примере вы увидите, что общая задержка между планированием таймера и выполнением его обратного вызова составит 105 миллисекунд.
ПРИМЕЧАНИЕ. Чтобы предотвратить истощение цикла событий на этапе опроса , libuv (библиотека C, реализующая цикл событий Node.js и все асинхронное поведение платформы) также имеет жесткий максимум (зависит от системы).
На этом этапе выполняются обратные вызовы для определенных системных операций (например, типов ошибок TCP). Например, некоторые *nix-системы предпочитают ждать, чтобы сообщить об ошибке, если TCP-сокет получает ECONNREFUSED
при попытке подключения. Это будет поставлено в очередь для выполнения во время фазы ожидания обратного вызова .
Фаза опроса выполняет две важные функции:
расчет продолжительности блокировки и опроса ввода-вывода.
Затем обработайте события в очереди опроса .
Когда цикл событий переходит в фазу опроса и таймеры не запланированы, произойдет одно из двух:
если очередь опроса не пуста
, цикл событий будет перебирать очередь обратных вызовов и выполнять их синхронно, пока очередь не станет пустой. , или достигнут жесткий предел, связанный с системой.
Если очередь опроса пуста , происходят еще две вещи:
если сценарий запланирован с помощью setImmediate()
, цикл событий завершит фазу опроса и продолжит фазу проверки для выполнения запланированных сценариев.
Если сценарий не запланирован с помощью setImmediate()
, цикл событий будет ждать добавления обратного вызова в очередь, а затем немедленно выполнит его.
Когда очередь опроса пуста, цикл событий проверяет наличие таймера, достигшего своего порога времени. Если один или несколько таймеров готовы, цикл событий возвращается к фазе таймера для выполнения обратных вызовов для этих таймеров.
Эта фаза позволяет выполнить обратный вызов сразу после завершения фазы опроса. Если фаза опроса становится бездействующей и сценарий ставится в очередь после использования setImmediate()
, цикл событий может перейти к фазе проверки вместо ожидания.
setImmediate()
на самом деле представляет собой специальный таймер, который работает на отдельной фазе цикла событий. Он использует API libuv для планирования выполнения обратных вызовов после завершения фазы опроса .
Обычно при выполнении кода цикл событий в конечном итоге достигает фазы опроса, где он ожидает входящие соединения, запросы и т. д. Однако если обратный вызов был запланирован с помощью setImmediate()
и фаза опроса становится простой, она завершит эту фазу и перейдет к фазе проверки вместо того, чтобы продолжать ждать события опроса.
Если сокет или обработчик внезапно закрываются (например, socket.destroy()
), на этом этапе будет генерироваться событие 'close'
. В противном случае он будет отправлен через process.nextTick()
.
setImmediate()
и setTimeout()
очень похожи, но ведут себя по-разному в зависимости от того, когда они вызываются.
setImmediate()
предназначен для выполнения сценария после завершения текущей фазы опроса .setTimeout()
запускает сценарий после прохождения минимального порога (в мс).Порядок выполнения таймеров будет зависеть от контекста, в котором они вызываются. Если оба вызываются из основного модуля, таймер будет привязан к производительности процесса (на которую могут влиять другие запущенные приложения на компьютере).
Например, если вы запустите следующий сценарий, который не находится внутри цикла ввода-вывода (т. е. основного модуля), порядок выполнения двух таймеров будет недетерминированным, поскольку он ограничен производительностью процесса:
// timeout_vs_immediate.js setTimeout(() => { console.log('тайм-аут'); }, 0); setImmediate(() => { console.log('немедленно'); }); $ узел timeout_vs_immediate.js тайм-аут немедленный $ узел timeout_vs_immediate.js немедленный timeout
Однако, если вы поместите эти две функции в цикл ввода-вывода и вызовете их, setImmediate всегда будет вызываться первой:
// timeout_vs_immediate.js const fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => { console.log('тайм-аут'); }, 0); setImmediate(() => { console.log('немедленно'); }); }); $ узел timeout_vs_immediate.js немедленный тайм-аут $ узел timeout_vs_immediate.js немедленныйОсновное преимущество использования setImmediate() для
таймаута
setImmediate()
setTimeout()
заключается в том, что если setImmediate()
запланирован во время цикла ввода-вывода, он будет выполнен раньше любого таймера в нем, в зависимости от того, сколько таймеров не связано с
Возможно, вы заметили process.nextTick()
не показан на диаграмме, хотя он является частью асинхронного API. Это связано с тем, что process.nextTick()
технически не является частью цикла событий. Вместо этого он будет обрабатывать nextTickQueue
после завершения текущей операции, независимо от текущего этапа цикла событий. Операция здесь считается переходом от базового процессора C/C++ и обрабатывает код JavaScript, который необходимо выполнить.
Оглядываясь назад на нашу диаграмму, можно сказать, что каждый раз, когда на определенной фазе вызывается process.nextTick()
, все обратные вызовы, переданные в process.nextTick()
будут обработаны до продолжения цикла обработки событий. Это может создать некоторые неприятные ситуации, так как позволяет «морить» ваш ввод-вывод с помощью рекурсивных process.nextTick()
, не позволяя циклу событий достичь стадии опроса .
Почему что-то подобное включено в Node.js? Частично это философия дизайна, согласно которой API всегда должен быть асинхронным, даже если это не обязательно. Возьмем этот фрагмент кода в качестве примера:
function apiCall(arg, callback) { if (typeof arg !== 'строка') вернуть процесс.nextTick( перезвонить, новый TypeError('аргумент должен быть строкой') ); }
Фрагмент кода для проверки параметров. Если неверно, ошибка передается в функцию обратного вызова. Недавно API был обновлен, чтобы разрешить передачу аргументов в process.nextTick()
, что позволит ему принимать любой аргумент после позиции функции обратного вызова и передавать аргументы функции обратного вызова в качестве аргументов функции обратного вызова, поэтому вам не придется вложить функцию.
Мы передаем ошибку обратно пользователю, но только после того, как остальная часть пользовательского кода будет выполнена. Используяprocess.nextTick process.nextTick()
, мы гарантируем, что apiCall()
всегда выполняет свою функцию обратного вызова после остальной части пользовательского кода и до продолжения цикла событий. Для этого стек вызовов JS может развернуться, а затем немедленно выполнить предоставленный обратный вызов, что позволяет выполнять рекурсивные вызовы process.nextTick()
без попадания в RangeError: 超过V8 的最大调用堆栈大小
.
Этот принцип проектирования может привести к некоторым потенциальным проблемам. Возьмите этот фрагмент кода в качестве примера:
let bar; // это имеет асинхронную подпись, но вызывает обратный вызов синхронно функция someAsyncApiCall (обратный вызов) { перезвонить(); } // обратный вызов вызывается до завершения `someAsyncApiCall`. someAsyncApiCall(() => { // поскольку someAsyncApiCall завершился, bar не было присвоено никакого значения console.log('bar', bar // не определено); }); bar = 1;
Пользователь определяет someAsyncApiCall()
как имеющий асинхронную подпись, но на самом деле он выполняется синхронно. При его вызове обратный вызов, предоставленный someAsyncApiCall()
вызывается на той же фазе цикла событий, поскольку someAsyncApiCall()
фактически ничего не делает асинхронно. В результате функция обратного вызова пытается обратиться к bar
, но переменная может быть еще не в области видимости, поскольку выполнение скрипта еще не завершено.
Поместив обратный вызов в process.nextTick()
, скрипт по-прежнему имеет возможность работать до завершения, позволяя инициализировать все переменные, функции и т. д. до вызова обратного вызова. Он также имеет то преимущество, что не позволяет продолжать цикл событий, и подходит для предупреждения пользователя в случае возникновения ошибки, прежде чем позволить циклу событий продолжиться. Вот предыдущий пример использования process.nextTick()
:
let bar; функция someAsyncApiCall (обратный вызов) { процесс.nextTick(обратный вызов); } someAsyncApiCall(() => { console.log('бар', бар // 1); }); bar = 1;
Это еще один реальный пример:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
Только когда порт будет передан, порт будет привязан немедленно. Таким образом, обратный вызов 'listening'
может быть вызван немедленно. Проблема в том, что обратный вызов .on('listening')
не был установлен в этот момент.
Чтобы обойти эту проблему, событие 'listening'
ставится в очередь в nextTick()
чтобы позволить сценарию завершиться. Это позволяет пользователю устанавливать любые обработчики событий, которые он хочет.
Что касается пользователя, у нас есть два похожих вызова, но их имена сбивают с толку.
process.nextTick()
выполняется немедленно на том же этапе.setImmediate()
срабатывает на следующей итерации или «тике» цикла событий.По сути, эти два имени следует поменять местами, поскольку process.nextTick()
срабатывает быстрее, чем setImmediate()
, но это наследие прошлого и поэтому вряд ли изменится. Если вы поспешно смените имя, вы сломаете большинство пакетов в npm. Каждый день добавляется больше новых модулей, а это значит, что каждый день нам приходится ждать, тем больше может быть потенциального ущерба. Хоть эти названия и сбивают с толку, сами названия не изменятся.
Мы рекомендуем разработчикам использовать setImmediate()
во всех ситуациях, поскольку его легче понять.
Есть две основные причины:
дать пользователю возможность обрабатывать ошибки, очистить ненужные ресурсы или повторить запрос до продолжения цикла обработки событий.
Иногда необходимо запустить обратный вызов после развертывания стека, но до продолжения цикла событий.
Вот простой пример, отвечающий ожиданиям пользователя:
const server = net.createServer(); server.on('connection', (conn) => {}); сервер.прослушивать(8080); server.on('listening', () => {});
Предположим, что listen()
выполняется в начале цикла обработки событий, но обратный вызов прослушивания помещается в setImmediate()
. Если не будет передано имя хоста, порт будет привязан немедленно. Чтобы цикл событий продолжился, он должен попасть в фазу опроса , а это означает, что возможно, что соединение было получено и событие соединения было запущено до события прослушивания.
Другой пример запускает конструктор функции, наследуемый от EventEmitter
, и хочет вызвать конструктор:
const EventEmitter = require('events'); const util = require('util'); функция MyEmitter() { EventEmitter.call(это); this.emit('событие'); } util.inherits(MyEmitter, EventEmitter); const myEmitter = новый MyEmitter(); myEmitter.on('событие', () => { console.log('Произошло событие!'); });
Вы не можете вызвать событие немедленно из конструктора, поскольку сценарий еще не обработан до момента, когда пользователь назначает событию функцию обратного вызова. Таким образом, в самом конструкторе вы можете использовать process.nextTick()
для настройки обратного вызова, чтобы событие создавалось после завершения конструктора, что и ожидается:
const EventEmitter = require('events'); const util = require('util'); функция MyEmitter() { EventEmitter.call(это); // используем nextTick для генерации события после назначения обработчика процесс.nextTick(() => { this.emit('событие'); }); } util.inherits(MyEmitter, EventEmitter); const myEmitter = новый MyEmitter(); myEmitter.on('событие', () => { console.log('Произошло событие!'); });
Источник: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/.