Цикл событий Node.js
Что такое цикл событий?
Цикл событий позволяет Node.js выполнять неблокирующие операции ввода-вывода — несмотря на то, что по умолчанию используется один поток JavaScript — путем перегрузки операций на системное ядро, когда это возможно.
Поскольку большинство современных ядер многопоточные, они могут обрабатывать несколько операций, выполняющихся в фоновом режиме. Когда одна из этих операций завершается, ядро сообщает об этом Node.js, чтобы соответствующий обратный вызов мог быть добавлен в очередь опроса для последующего выполнения. Мы подробно объясним это позже в этой теме.
Пояснение цикла событий
Когда Node.js запускается, он инициализирует цикл событий, обрабатывает предоставленный входной скрипт (или переходит в REPL, который не рассматривается в этом документе), который может выполнять асинхронные вызовы API, планировать таймеры или вызывать process.nextTick(), а затем начинает обработку цикла событий.
Следующая диаграмма показывает упрощенное представление порядка операций цикла событий.
┌───────────────────────────┐
┌─>│ таймеры │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ отложенные коллбэки │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ бездействие, подготовка │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ входящие: │
│ │ опрос │<─────┤ подключения, │
│ └─────────────┬─────────────┘ │ данные и т.д. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ проверка │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ коллбэки закрытия │
└───────────────────────────┘
TIP
Каждый блок будет называться «фазой» цикла событий.
Каждая фаза имеет очередь FIFO обратных вызовов для выполнения. Хотя каждая фаза особенна по-своему, в целом, когда цикл событий переходит в заданную фазу, он выполняет любые операции, специфичные для этой фазы, затем выполняет обратные вызовы в очереди этой фазы, пока очередь не будет исчерпана или не будет выполнено максимальное количество обратных вызовов. Когда очередь исчерпана или достигнут предел обратных вызовов, цикл событий переходит к следующей фазе и так далее.
Поскольку любая из этих операций может планировать больше операций, а новые события, обработанные на этапе опроса, помещаются в очередь ядром, события опроса могут помещаться в очередь во время обработки событий опроса. В результате длительные обратные вызовы могут позволить фазе опроса работать гораздо дольше, чем порог таймера. См. разделы о таймерах и опросе для получения более подробной информации.
TIP
Существует небольшое несоответствие между реализациями Windows и Unix/Linux, но это не важно для данной демонстрации. Самые важные части здесь. На самом деле существует семь или восемь шагов, но те, которые нас интересуют — те, которые фактически использует Node.js — это те, которые указаны выше.
Обзор фаз
- таймеры: на этой фазе выполняются коллбэки, запланированные с помощью
setTimeout()
иsetInterval()
. - отложенные коллбэки: выполняет коллбэки ввода-вывода, отложенные до следующей итерации цикла.
- бездействие, подготовка: используется только внутри.
- опрос: извлекает новые события ввода-вывода; выполняет коллбэки, связанные с вводом-выводом (почти все, за исключением коллбэков закрытия, тех, которые запланированы таймерами, и
setImmediate()
); Node будет блокироваться здесь, когда это необходимо. - проверка: здесь вызываются коллбэки
setImmediate()
. - коллбэки закрытия: некоторые коллбэки закрытия, например,
socket.on('close', ...)
.
Между каждым проходом цикла событий Node.js проверяет, ожидает ли он каких-либо асинхронных операций ввода-вывода или таймеров, и корректно завершается, если таковых нет.
Фазы подробно
таймеры
Таймер указывает порог, после которого может быть выполнен предоставленный коллбэк, а не точное время, когда пользователь хочет, чтобы он был выполнен. Коллбэки таймеров будут запускаться как можно раньше после истечения указанного времени; однако, планирование операционной системы или выполнение других коллбэков могут задерживать их.
TIP
Технически, фаза опроса управляет тем, когда выполняются таймеры.
Например, предположим, что вы планируете таймаут для выполнения после достижения порога в 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) {
// ничего не делайте
}
})
Когда цикл событий переходит в фазу опроса, у него пустая очередь (fs.readFile()
не завершен), поэтому он будет ждать оставшееся количество мс, пока не будет достигнут порог ближайшего таймера. Пока он ждет, проходит 95 мс, fs.readFile()
завершает чтение файла, и его коллбэк, который занимает 10 мс для завершения, добавляется в очередь опроса и выполняется. Когда коллбэк завершается, в очереди больше нет коллбэков, поэтому цикл событий увидит, что порог ближайшего таймера достигнут, а затем вернется к фазе таймеров для выполнения коллбэка таймера. В этом примере вы увидите, что общая задержка между планированием таймера и выполнением его коллбэка составит 105 мс.
TIP
Чтобы предотвратить голодание цикла событий фазой опроса, libuv (библиотека C, которая реализует цикл событий Node.js и все асинхронные поведения платформы) также имеет жесткий максимальный предел (зависит от системы), прежде чем она прекратит опрос на наличие дополнительных событий.
Отложенные обратные вызовы
На этом этапе выполняются обратные вызовы для некоторых системных операций, таких как типы ошибок TCP. Например, если TCP-сокет получает ECONNREFUSED
при попытке подключения, некоторые *nix-системы хотят подождать, чтобы сообщить об ошибке. Это будет помещено в очередь для выполнения на этапе отложенных обратных вызовов.
poll
Этап poll имеет две основные функции:
- Вычисление того, как долго следует блокировать и опрашивать ввод/вывод, а затем
- Обработка событий в очереди poll.
Когда цикл событий переходит на этап poll, и таймеры не запланированы, произойдет одно из двух:
Если очередь poll не пуста, цикл событий будет итерироваться по своей очереди обратных вызовов, выполняя их синхронно, пока очередь не будет исчерпана или не будет достигнут зависящий от системы жесткий лимит.
Если очередь poll пуста, произойдет одно из двух:
Если скрипты были запланированы с помощью
setImmediate()
, цикл событий завершит этап poll и перейдет к этапу проверки для выполнения этих запланированных скриптов.Если скрипты не были запланированы с помощью
setImmediate()
, цикл событий будет ждать добавления обратных вызовов в очередь, а затем выполнит их немедленно.
Как только очередь poll опустеет, цикл событий проверит таймеры, пороговые значения времени которых были достигнуты. Если один или несколько таймеров готовы, цикл событий вернется к этапу таймеров для выполнения обратных вызовов этих таймеров.
check
Этот этап позволяет выполнять обратные вызовы сразу после завершения этапа poll. Если этап poll становится бездействующим, и скрипты были помещены в очередь с помощью setImmediate()
, цикл событий может перейти к этапу проверки, а не ждать.
setImmediate()
— это фактически специальный таймер, который работает на отдельном этапе цикла событий. Он использует API libuv, который планирует выполнение обратных вызовов после завершения этапа poll.
В общем, по мере выполнения кода цикл событий в конечном итоге достигнет этапа poll, где он будет ожидать входящего соединения, запроса и т. д. Однако, если обратный вызов был запланирован с помощью setImmediate()
, и этап poll становится бездействующим, он завершится и перейдет к этапу check, а не будет ждать событий poll.
Обработчики закрытия
Если сокет или дескриптор закрываются резко (например, socket.destroy()
), событие 'close'
будет отправлено на этом этапе. В противном случае оно будет отправлено через process.nextTick()
.
setImmediate()
против setTimeout()
setImmediate()
и setTimeout()
похожи, но ведут себя по-разному в зависимости от того, когда они вызываются.
setImmediate()
предназначен для выполнения скрипта после завершения текущей фазы poll.setTimeout()
планирует выполнение скрипта после истечения минимального порога в мс.
Порядок выполнения таймеров будет варьироваться в зависимости от контекста, в котором они вызываются. Если оба вызываются из основного модуля, то время будет ограничено производительностью процесса (на которую могут влиять другие приложения, работающие на компьютере).
Например, если мы запустим следующий скрипт, который не находится внутри цикла ввода-вывода (т. е. основной модуль), порядок выполнения двух таймеров недетерминирован, так как он ограничен производительностью процесса:
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
Однако, если вы переместите два вызова в цикл ввода-вывода, обработчик immediate
всегда выполняется первым:
// timeout_vs_immediate.js
const fs = require('node: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
будет обработана после завершения текущей операции, независимо от текущей фазы цикла событий. Здесь операция определяется как переход от базового обработчика C/C++ и обработка JavaScript-кода, который необходимо выполнить.
Возвращаясь к нашей диаграмме, каждый раз, когда вы вызываете process.nextTick()
на данном этапе, все обратные вызовы, переданные в process.nextTick()
, будут выполнены до продолжения цикла событий. Это может привести к некоторым нежелательным ситуациям, поскольку это позволяет вам «заморить голодом» ваш ввод/вывод, совершая рекурсивные вызовы process.nextTick()
, что препятствует достижению циклом событий фазы 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()
всегда запускает свой обратный вызов после остального кода пользователя и до того, как цикл событий сможет продолжиться. Для этого стеку вызовов JS разрешается разворачиваться, а затем немедленно выполняется предоставленный обратный вызов, что позволяет человеку совершать рекурсивные вызовы process.nextTick()
без достижения RangeError: Maximum call stack size exceeded from v8
.
Эта философия может привести к некоторым потенциально проблемным ситуациям. Возьмем, например, этот фрагмент:
let bar
// это имеет асинхронную сигнатуру, но вызывает обратный вызов синхронно
function someAsyncApiCall(callback) {
callback()
}
// обратный вызов вызывается до завершения `someAsyncApiCall`.
someAsyncApiCall(() => {
// поскольку someAsyncApiCall еще не завершен, bar не присвоено никакого значения
console.log('bar', bar) // undefined
})
bar = 1
Пользователь определяет someAsyncApiCall()
как имеющую асинхронную сигнатуру, но на самом деле она работает синхронно. Когда она вызывается, обратный вызов, предоставленный someAsyncApiCall()
, вызывается на той же фазе цикла событий, потому что someAsyncApiCall()
на самом деле ничего не делает асинхронно. В результате обратный вызов пытается сослаться на bar, даже если у него еще может не быть этой переменной в области видимости, потому что скрипт не смог завершиться.
Поместив обратный вызов в process.nextTick()
, скрипт по-прежнему может завершиться, что позволяет инициализировать все переменные, функции и т. д. до вызова обратного вызова. Он также имеет то преимущество, что не позволяет циклу событий продолжаться. Пользователю может быть полезно получить предупреждение об ошибке до того, как цикл событий сможет продолжиться. Вот предыдущий пример с использованием process.nextTick()
:
let bar
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()
У нас есть два вызова, которые похожи с точки зрения пользователей, но их названия сбивают с толку.
process.nextTick()
срабатывает немедленно на той же фазеsetImmediate()
срабатывает на следующей итерации или «тик» цикла событий
По сути, названия следует поменять местами. process.nextTick()
срабатывает быстрее, чем setImmediate()
, но это артефакт прошлого, который вряд ли изменится. Такая замена сломает большую часть пакетов на npm. Каждый день добавляется все больше новых модулей, а это значит, что чем дольше мы ждем, тем больше потенциальных поломок возникает. Хотя они и сбивают с толку, сами названия меняться не будут.
TIP
Мы рекомендуем разработчикам использовать setImmediate()
во всех случаях, поскольку с ним проще работать.
Зачем использовать process.nextTick()
?
Есть две основные причины:
Позволяет пользователям обрабатывать ошибки, очищать любые ненужные ресурсы или, возможно, повторить запрос, прежде чем цикл событий продолжится.
Иногда необходимо позволить обратным вызовам запускаться после того, как стек вызовов размотан, но до продолжения цикла событий.
Один из примеров — соответствие ожиданиям пользователя. Простой пример:
const server = net.createServer()
server.on('connection', conn => {})
server.listen(8080)
server.on('listening', () => {})
Допустим, listen()
запускается в начале цикла событий, но обратный вызов прослушивания помещается в setImmediate()
. Если имя хоста не передано, привязка к порту произойдет немедленно. Для продолжения цикла событий он должен достичь фазы опроса, а это означает, что существует ненулевая вероятность того, что соединение могло быть получено, что позволяет событию соединения срабатывать до события прослушивания.
Другой пример — расширение 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()
// use nextTick to emit the event once a handler is assigned
process.nextTick(() => {
this.emit('event')
})
}
}
const myEmitter = new MyEmitter()
myEmitter.on('event', () => {
console.log('an event occurred!')
})