Event Loop в Node.js
Node.js — это однопоточная событийно-управляемая платформа, способная выполнять неблокирующее асинхронное программирование. Эти функциональные возможности Node делают его эффективным в плане памяти.
Асинхронная и неблокирующая функция Node.js в первую очередь организована циклом событий. В этой статье вы изучите цикл событий Node.js, чтобы использовать его асинхронные API для создания эффективных приложений Node.js. Знание того, как работает цикл событий внутри, не только поможет вам писать надежный и производительный код Node.js, но и научит вас эффективно отлаживать проблемы производительности.
Что такое Event Loop в Node.js?
Event Loop (цикл событий) Node.js — это непрерывно работающий полубесконечный цикл. Он выполняется до тех пор, пока есть ожидающая асинхронная операция. Запуск процесса Node.js с помощью node
команды выполняет ваш код JavaScript и инициализирует цикл событий. Если Node.js сталкивается с асинхронной операцией, такой как таймеры, файлы и сетевой ввод-вывод во время выполнения скрипта, он выгружает операцию в собственную систему или пул потоков.
Большинство операций ввода-вывода, таких как чтение и запись в файл, шифрование и расшифровка файлов и работа в сети, требуют много времени и вычислительных затрат. Поэтому, чтобы избежать блокировки основного потока, Node.js выгружает эти операции в собственную систему. Там запущен процесс Node, поэтому система обрабатывает эти операции параллельно.
Большинство современных ядер операционных систем являются многопоточными по своей конструкции. Таким образом, операционная система может обрабатывать несколько операций одновременно и уведомлять Node.js о завершении этих операций. Цикл событий отвечает за выполнение асинхронных обратных вызовов API. Он состоит из шести основных фаз:
- Timers фазы для обработки
setTimeout
иsetInterval
- Pending callbacks для выполнения отложенных обратных вызовов
- Idle, Prepare , которую цикл событий использует для внутренних нужд
- Poll для опроса и обработки событий, таких как файловый и сетевой ввод-вывод
- Check фаза
setImmediate
для выполнения обратных вызовов - Close фаза для обработки определенных событий закрытия
Хотя приведенный выше список является линейным, цикл событий является циклическим и итеративным, как показано на диаграмме ниже:
После последней фазы цикла событий начинается следующая итерация цикла событий, если все еще есть ожидающие события или асинхронные операции. В противном случае он завершается, и процесс Node.js завершается.
Мы подробно рассмотрим каждую фазу цикла событий в следующих разделах. Перед этим давайте рассмотрим «следующий тик» и очереди микрозадач, которые появляются в центре цикла событий на приведенной выше диаграмме. Технически они не являются частью цикла событий.
Очередь микрозадач в Node.js
Promise queueMicrotask
, и process.nextTick
являются частью асинхронного API в Node.js. Когда обещания завершаются, queueMicrotask
и .then
обратные вызовы .catch
, и .finally
добавляются в очередь микрозадач.
С другой стороны, process.nextTick
обратные вызовы относятся к очереди «следующий тик». Давайте используем пример ниже, чтобы проиллюстрировать, как обрабатываются очереди микрозадач и «следующий тик»:
const fs = require("fs"); let counter = 0; fs.readFile("path/to/file", { encoding: "utf8" }, () => { console.log(`Inside I/O, counter = ${++counter}`); setImmediate(() => { console.log(`setImmediate 1 from I/O callback, counter = ${++counter}`); }); setTimeout(() => { console.log(`setTimeout from I/O callback, counter = ${++counter}`); }, 0); setImmediate(() => { console.log(`setImmediate 2 from I/O callback, counter = ${++counter}`); }); });
Предположим, что три таймера выше истекают одновременно. Когда цикл событий входит в фазу таймеров, он добавляет истекшие таймеры в очередь обратного вызова таймеров и выполняет их от первого до последнего:
В нашем примере кода выше, при выполнении первого обратного вызова в очереди таймеров, обратные вызовы .then
, .catch
, и queueMicrotask
добавляются в очередь микрозадач. Аналогично, process.nextTick
обратный вызов добавляется в очередь, которую мы будем называть очередью «следующий тик». Имейте в виду, console.log
является синхронным.
Когда возвращается первый обратный вызов из очереди таймеров, обрабатывается очередь «следующий тик». Если при обработке обратных вызовов в очереди «следующий тик» генерируется больше «следующих тиков», они добавляются в конец очереди «следующий тик» и также выполняются.
Когда очередь «следующий тик» пуста, следующей обрабатывается очередь микрозадач. Если микрозадачи генерируют больше микрозадач, они также добавляются в конец очереди микрозадач и выполняются.
Когда и очередь «следующего тика», и очередь микрозадач пусты, цикл событий выполняет второй обратный вызов в очереди таймеров. Тот же процесс продолжается до тех пор, пока очередь таймеров не опустеет:
Описанный выше процесс не ограничивается фазой таймеров. Очередь «следующего тика» и очередь микрозадач обрабатываются аналогично, когда цикл событий выполняет JavaScript во всех других основных фазах.
Фазы Event Loop Node.js
Как объяснялось выше, Event Loop Node.js — это полубесконечный цикл с шестью основными фазами. Он имеет больше фаз, но цикл событий использует некоторые из фаз для внутренних нужд. Они не оказывают прямого влияния на код, который вы пишете. Поэтому мы не будем рассматривать их здесь.
Каждая основная фаза в цикле событий имеет очередь обратных вызовов по принципу «первым пришел — первым вышел». Например, операционная система будет запускать запланированные таймеры до тех пор, пока они не истекут. После этого истекшие таймеры добавляются в очередь обратных вызовов таймеров.
Цикл событий затем выполняет обратные вызовы в очереди таймеров до тех пор, пока очередь не опустеет или не будет достигнуто максимальное количество обратных вызовов. Мы рассмотрим основные фазы цикла событий в разделах ниже.
Фаза Timers
Как и браузер, Node.js имеет API таймеров для планирования функций, которые будут выполняться в будущем. API таймеров в Node.js аналогичен API в браузере. Однако есть некоторые небольшие различия в реализации.
API таймеров состоит из функций setTimeout
, setInterval
и setImmediate
. Все три таймера асинхронны. Фаза таймеров цикла событий отвечает только за обработку setTimeout
и setInterval
.
С другой стороны, фаза проверки отвечает за setImmediate
функцию. Мы рассмотрим фазу проверки позже. Оба setTimeout
и setInterval
имеют следующую сигнатуру функции:
setTimeout(callback[, delay[, ...args]]) setInterval(callback[, delay[, ...args]])
callback
это функция, которая вызывается по истечении таймераdelay
это количество миллисекунд ожидания перед вызовомcallback
. По умолчанию это одна миллисекундаargs
являются необязательными аргументами, передаваемымиcallback
С setTimeout
, callback
вызывается один раз, когда delay
истекает. С другой стороны, setInterval
планирует callback
запуск каждые delay
миллисекунды.
На схеме ниже показан цикл событий после удаления всех фаз, кроме фазы таймеров:
Для простоты возьмем три запланированных события setTimeout
, которые истекают одновременно. Шаги ниже описывают, что происходит, когда цикл событий входит в фазу таймера:
- Три истекших таймера добавляются в очередь таймеров.
- Цикл событий выполняет первый
setTimeout
обратный вызов. Если при выполнении первого обратного вызова генерируются «следующие тики» или микрозадачи, они добавляются в соответствующую очередь - Когда возвращается первый
setTimeout
обратный вызов, обрабатывается очередь «следующий тик». Если при обработке очереди «следующий тик» генерируется больше «следующих тиков», они добавляются в конец очереди «следующий тик» и обрабатываются немедленно. Если генерируются микрозадачи, они добавляются в очередь микрозадач - Когда очередь «следующий тик» пуста, обрабатывается очередь микрозадач. Если генерируется больше «микрозадач», они добавляются в конец очереди микрозадач и обрабатываются немедленно
- Если и очередь «следующего тика» и очередь микрозадач пусты, цикл событий выполнит второй обратный вызов в очереди таймеров. Шаги два-четыре повторяются для второго и третьего обратных вызовов
- После выполнения всех истекших обратных вызовов таймера или максимального количества обратных вызовов цикл событий переходит к следующей фазе.
В шагах выше мы использовали очередь из трех истекших таймеров. Однако на практике это не всегда так. Цикл событий будет обрабатывать очередь таймеров до тех пор, пока она не опустеет или не будет достигнуто максимальное количество обратных вызовов, прежде чем перейти к следующей фазе.
Цикл событий блокируется при выполнении обратных вызовов JavaScript. Если обратный вызов выполняется долго, цикл событий будет ждать, пока он не вернется. Поскольку Node.js в основном работает на стороне сервера, блокировка цикла событий приведет к проблемам с производительностью.
Аналогично, delay
аргумент, который вы передаете функциям таймера, не всегда является точным временем ожидания перед выполнением обратного вызова setTimeout
или setInterval
. Это минимальное время ожидания. Длительность, которую оно занимает, зависит от того, насколько загружен цикл событий и используемого системного таймера.
Pending callbacks
Во время фазы опроса, которую мы вскоре рассмотрим, цикл событий опрашивает такие события, как операции ввода-вывода файлов и сети. Цикл событий обрабатывает некоторые из опрошенных событий в фазе опроса и откладывает определенные события на фазу ожидания в следующей итерации цикла событий.
В фазе ожидания цикл событий добавляет отложенные события в очередь ожидающих обратных вызовов и выполняет их. События, обрабатываемые в фазе ожидающих обратных вызовов, включают определенные ошибки сокета TCP, выдаваемые системой. Например, некоторые операционные системы откладывают обработку ECONNREFUSED
событий ошибок на эту фазу.
Idle, Prepare
Цикл событий использует фазу Idle, Prepare, подготовки для внутренних операций по обслуживанию. Он не оказывает прямого влияния на код Node.js, который вы пишете. Хотя мы не будем подробно его рассматривать, необходимо знать, что он существует.
Фаза Poll
Фаза Poll имеет две функции. Первая — обработка событий в очереди опроса и выполнение их обратных вызовов. Вторая функция — определение того, как долго блокировать цикл событий и опрос для событий ввода-вывода.
Когда цикл событий входит в фазу Poll, он ставит в очередь ожидающие события ввода-вывода и выполняет их до тех пор, пока очередь не опустеет или не будет достигнут системно-зависимый предел. В промежутке между выполнением обратных вызовов JavaScript очереди «следующего тика» и микрозадач опустошаются, как и в других фазах.
Разница между фазой Poll и другими фазами заключается в том, что цикл событий иногда блокирует цикл событий на некоторое время и опрашивает события ввода-вывода до истечения времени ожидания или после достижения максимального предела обратного вызова.
Цикл событий учитывает несколько факторов при принятии решения о том, следует ли блокировать цикл событий и на какой срок. Некоторые из этих факторов включают доступность ожидающих событий ввода-вывода и другие фазы цикла событий, такие как фаза таймеров:
Check
Цикл событий выполняет setImmediate
обратный вызов на этапе проверки сразу после событий ввода-вывода. setImmediate
Имеет следующую сигнатуру функции:
setImmediate(callback[, ...args])
callback
это функция для вызоваargs
являются необязательными аргументами, передаваемымиcallback
Цикл событий выполняет несколько setImmediate
обратных вызовов в том порядке, в котором они были созданы. В примере ниже цикл событий выполнит fs.readFile
обратный вызов в фазе Poll, поскольку это операция ввода-вывода. После этого он setImmediate
немедленно выполняет обратные вызовы в фазе проверки в той же итерации цикла событий. С другой стороны, он обрабатывает setTimeout
в фазе таймеров в следующей итерации цикла событий.
При вызове setImmediate
функции из обратного вызова ввода-вывода, как в примере ниже, цикл событий гарантирует, что она будет запущена на этапе проверки в той же итерации цикла событий:
const fs = require("fs"); let counter = 0; fs.readFile("path/to/file", { encoding: "utf8" }, () => { console.log(`Inside I/O, counter = ${++counter}`); setImmediate(() => { console.log(`setImmediate 1 from I/O callback, counter = ${++counter}`); }); setTimeout(() => { console.log(`setTimeout from I/O callback, counter = ${++counter}`); }, 0); setImmediate(() => { console.log(`setImmediate 2 from I/O callback, counter = ${++counter}`); }); });
Все микрозадачи и «следующие тики», сгенерированные из setImmediate
обратных вызовов на этапе проверки, добавляются в очередь микрозадач и очередь «следующих тиков» соответственно и немедленно очищаются, как и на других этапах.
Close
Close фаза — это то, где Node.js выполняет обратные вызовы close
событий и завершает заданную итерацию цикла событий. Когда сокет закрывается, цикл событий будет обрабатывать close
событие в этой фазе. Если в этой фазе генерируются «следующие тики» и микрозадачи, они обрабатываются так же, как и в других фазах цикла событий.
Стоит подчеркнуть, что вы можете завершить цикл событий на любой фазе, вызвав process.exit
метод. Процесс Node.js завершится, а цикл событий проигнорирует ожидающие асинхронные операции.
Event Loop Node.js на практике
Как было указано выше, важно понимать цикл событий Node.js, чтобы писать производительный, неблокирующий асинхронный код. Использование асинхронных API в Node.js запустит ваш код параллельно, но ваш обратный вызов JavaScript всегда будет выполняться в одном потоке.
Таким образом, вы можете непреднамеренно заблокировать цикл событий во время выполнения обратного вызова JavaScript. Поскольку Node.js — это серверный язык, блокировка цикла событий замедлит работу вашего сервера и сделает его неотзывчивым, что снизит вашу пропускную способность.
В примере ниже я намеренно запускаю while
цикл примерно на минуту, чтобы имитировать длительную операцию. Когда вы достигнете /blocking
конечной точки, цикл событий выполнит app.get
обратный вызов в фазе опроса цикла событий:
const longRunningOperation = (duration = 1 * 60 * 1000) => { const start = Date.now(); while (Date.now() - start < duration) {} }; app.get("/blocking", (req, res) => { longRunningOperation(); res.send({ message: "blocking route" }); }); app.get("/non-blocking", (req, res) => { res.send({ message: "non blocking route" }); });
Поскольку обратный вызов выполняет длительную операцию, цикл событий блокируется на время выполнения задачи. Любой запрос к /non-blocking
маршруту также будет ждать, пока цикл событий сначала разблокируется. В результате ваше приложение перестанет отвечать. Ваши запросы из интерфейса станут медленными и в конечном итоге прекратятся по времени. Для выполнения таких ресурсоемких операций можно воспользоваться рабочим потоком.
Аналогично не используйте синхронные API для следующих модулей на стороне сервера, поскольку они потенциально могут заблокировать цикл событий:
crypto
zlib
fs
child_process
Часто задаваемые вопросы о Event Loop
Является ли Node.js многопоточным?
Как объяснялось выше, Node.js запускает код JavaScript в одном потоке. Однако, у него есть рабочие потоки для параллелизма . Если быть точным, в дополнение к основному потоку, у Node есть пул потоков, состоящий из четырех потоков по умолчанию.
Выполняются ли Promises в отдельном потоке?
Node promises не запускаются в отдельном потоке. Обратные вызовы .then
, .catch
, и .finally
добавляются в очередь микрозадач. Как объяснялось выше, обратные вызовы в очереди микрозадач выполняются в одном и том же потоке во всех основных фазах цикла событий.
Почему Event Loop важен в Node.js?
Цикл событий организует асинхронную и неблокируемую функцию Node. Он отвечает за мониторинг клиентских запросов и реагирование на запросы на стороне сервера.
Если обратный вызов JavaScript блокирует цикл событий, ваш сервер станет медленным и не будет отвечать на запросы клиентов. Без цикла событий Node.js не был бы таким мощным, как сейчас, а серверы Node.js были бы мучительно медленными.
Как работает асинхронное программирование в Node.js?
Node.js имеет несколько встроенных синхронных и асинхронных API. Синхронные API блокируют выполнение кода JavaScript до тех пор, пока операция не будет завершена.
В примере ниже мы используем fs.readFileSync
для чтения содержимого файла. fs.readFileSync
является синхронным. Поэтому он заблокирует выполнение остальной части кода JavaScript до тех пор, пока процесс чтения файла не будет завершен, прежде чем перейти к следующей строке кода:
const fs = require("fs"); const path = require("path"); console.log("At the top"); try { const data = fs.readFileSync(path.join(__dirname, "notes.txt"), { encoding: "utf8", }); console.log(data); } catch (error) { console.error(error); } console.log("At the bottom");
С другой стороны, неблокирующие асинхронные API выполняют операции параллельно, выгружая их в пул потоков или собственную систему, на которой работает Node.js. Когда операция завершается, цикл событий планирует и выполняет обратный вызов JavaScript.
Например, асинхронная форма модуля fs
использует пул потоков для записи или чтения содержимого файла. Когда содержимое файловой операции готово к обработке, цикл событий выполняет обратный вызов JavaScript в фазе опроса.
В примере ниже fs.readFile
— асинхронный и неблокируемый. Цикл событий выполнит переданный ему обратный вызов после завершения операции чтения файла. Остальная часть кода будет выполняться без ожидания завершения операции файла:
const fs = require("fs"); console.log("At the top"); fs.readFile("path/to/file", { encoding: "utf8" }, (err, data) => { if (err) { console.error("error", err); return; } console.log("data", data); }); console.log("At the bottom");
Когда выполняются микрозадачи в Node.js?
Микрозадачи выполняются между операциями во всех основных фазах цикла событий. Каждая основная фаза цикла событий выполняет очередь обратных вызовов JavaScript. Между выполнениями последовательных обратных вызовов JavaScript в очереди фаз есть контрольная точка микрозадач, где очередь микрозадач опустошается.
Как выйти из Event Loop Node.js?
Event Loop Node.js выполняется до тех пор, пока есть ожидающие события для обработки. Если нет ожидающей работы, цикл событий завершается после отправки exit
события, и возвращается обратный вызов слушателя выхода.
Вы также можете выйти из цикла событий, явно используя process.exit
метод. Вызов process.exit
немедленно прервет работающий процесс Node.js. Любые ожидающие или запланированные события в цикле событий будут отменены:
process.on("exit", (code) => { console.log(`Exiting with exit code: ${code}`); }); process.exit(1);
Вы можете прослушивать событие exit
. Однако ваша функция прослушивания должна быть синхронной, поскольку процесс Node.js завершится немедленно после возврата функции прослушивания.
Заключение
Среда выполнения Node имеет API для написания неблокирующего кода. Однако, поскольку весь ваш код JavaScript выполняется в одном потоке, возможно непреднамеренное блокирование цикла событий. Глубокие знания цикла событий помогут вам писать надежный, безопасный и производительный код и эффективно устранять проблемы производительности.
Цикл событий имеет около шести основных фаз. Эти шесть фаз — timers, pending, idle and prepare, poll, check и close. Каждая фаза имеет очередь событий, которые цикл событий обрабатывает, пока она не опустеет или не достигнет жесткого системно-зависимого предела.
При выполнении обратного вызова цикл событий блокируется. Поэтому убедитесь, что ваши асинхронные обратные вызовы не блокируют цикл событий надолго, иначе ваш сервер станет медленным и не будет отвечать на запросы клиентов. Вы можете использовать пул потоков для выполнения длительных или ресурсоемких задач.