Изначально Node был создан для создания высокопроизводительных веб-серверов. Будучи серверной средой выполнения JavaScript, он имеет такие функции, как управляемый событиями, асинхронный ввод-вывод и однопоточность. Модель асинхронного программирования, основанная на цикле событий, позволяет Node поддерживать высокий уровень параллелизма и значительно повышает производительность сервера. В то же время, поскольку она поддерживает однопоточные характеристики JavaScript, Node не нужно решать такие проблемы, как синхронизация состояний и взаимоблокировка при многопоточности. Переключение контекста потока не приводит к снижению производительности. Основываясь на этих характеристиках, Node обладает такими преимуществами, как высокая производительность и высокая степень параллелизма, и на его основе можно создавать различные высокоскоростные и масштабируемые платформы сетевых приложений.
В этой статье мы подробно рассмотрим базовую реализацию и механизм выполнения асинхронного и событийного цикла Node. Надеюсь, она будет вам полезна.
Почему Node использует асинхронный режим в качестве базовой модели программирования?
Как упоминалось ранее, Node изначально был создан для создания высокопроизводительных веб-серверов. Если предположить, что в бизнес-сценарии необходимо выполнить несколько наборов несвязанных задач, существует два современных основных решения:
однопоточное последовательное выполнение.
Выполняется параллельно с несколькими потоками.
Однопоточное последовательное выполнение является моделью синхронного программирования. Хотя оно больше соответствует последовательному мышлению программиста и упрощает написание более удобного кода, поскольку оно выполняет ввод-вывод синхронно, оно может только обрабатывать ввод-вывод. В то же время одиночный запрос приведет к медленному ответу сервера и не может быть применен в сценариях приложений с высокой степенью параллелизма. Более того, поскольку он блокирует ввод-вывод, ЦП всегда будет ждать завершения ввода-вывода и не сможет этого сделать. другие вещи, которые будут ограничивать вычислительную мощность процессора. Полное использование этого в конечном итоге приведет к низкой эффективности,
а модель многопоточного программирования также доставит разработчикам головную боль из-за таких проблем, как синхронизация состояний и взаимоблокировки в программировании. Хотя многопоточность может эффективно улучшить загрузку процессора на многоядерных процессорах.
Хотя модель программирования однопоточного последовательного выполнения и многопоточного параллельного выполнения имеет свои преимущества, она также имеет недостатки с точки зрения производительности и сложности разработки.
Кроме того, исходя из скорости ответа на клиентские запросы, если клиент получает два ресурса одновременно, скорость ответа синхронного метода будет равна сумме скоростей ответа двух ресурсов и скорости ответа асинхронный метод будет средним из двух. Самый большой, преимущество в производительности по сравнению с синхронизацией очень очевидно. По мере увеличения сложности приложения этот сценарий будет развиваться в сторону одновременного ответа на n запросов, и будут выделены преимущества асинхронного подхода по сравнению с синхронизированным.
Подводя итог, Node дает свой ответ: используйте один поток, чтобы избежать многопоточных взаимоблокировок, синхронизации состояний и других проблем, используйте асинхронный ввод-вывод, чтобы избежать блокировки одного потока и лучше использовать ЦП; Вот почему Node использует асинхронный подход в качестве базовой модели программирования.
Кроме того, чтобы восполнить недостаток одного потока, который не может использовать многоядерные процессоры, Node также предоставляет подпроцесс, аналогичный веб-работникам в браузере, который может эффективно использовать ЦП посредством рабочих процессов.
После разговора о том, почему нам следует использовать асинхронный режим, как его реализовать?
Существует два типа асинхронных операций, которые мы обычно называем: один — операции, связанные с вводом-выводом, такие как файловый ввод-вывод и сетевой ввод-вывод, другой — операции, не связанные с вводом-выводом, такие как setTimeOut
и setInterval
; Очевидно, что асинхронный, который мы обсуждаем, относится к операциям, связанным с вводом-выводом, то есть асинхронному вводу-выводу.
Асинхронный ввод-вывод предлагается в надежде, что вызовы ввода-вывода не будут блокировать выполнение последующих программ, а исходное время ожидания завершения ввода-вывода будет выделено другим необходимым для выполнения процессам. Для достижения этой цели вам необходимо использовать неблокирующий ввод-вывод.
Блокировка ввода-вывода означает, что после того, как ЦП инициирует вызов ввода-вывода, он блокируется до тех пор, пока ввод-вывод не будет завершен. Зная блокирующий ввод-вывод, легко понять неблокирующий ввод-вывод. ЦП вернется сразу после инициирования вызова ввода-вывода, вместо того, чтобы блокировать и ждать. ЦП может обрабатывать другие транзакции до завершения ввода-вывода. Очевидно, что по сравнению с блокирующим вводом-выводом неблокирующий ввод-вывод обеспечивает больший прирост производительности.
Итак, поскольку используется неблокирующий ввод-вывод и ЦП может вернуться сразу после инициации вызова ввода-вывода, как он узнает, что ввод-вывод завершен? Ответ – опрос.
Чтобы вовремя получить статус вызовов ввода-вывода, ЦП будет постоянно неоднократно вызывать операции ввода-вывода, чтобы подтвердить, завершен ли ввод-вывод. Эта технология повторных вызовов для определения того, завершена ли операция, называется опросом. .
Очевидно, что опрос приведет к тому, что ЦП будет неоднократно выполнять оценку состояния, что является пустой тратой ресурсов ЦП. Более того, интервалом опроса сложно управлять. Если интервал слишком длинный, завершение операции ввода-вывода не получит своевременного ответа, что косвенно снижает скорость ответа приложения, если интервал слишком короткий; ЦП неизбежно будет расходоваться на опрос. Это занимает больше времени и снижает загрузку ресурсов ЦП.
Поэтому, хотя опрос и отвечает требованию, чтобы неблокирующий ввод-вывод не блокировал выполнение последующих программ, для приложения его все же можно рассматривать лишь как своего рода синхронизацию, поскольку приложению все равно необходимо дождаться ввода-вывода. О, чтобы вернуться полностью. Все еще провел много времени в ожидании.
Мы ожидаем, что идеальный асинхронный ввод-вывод должен состоять в том, чтобы приложение инициировало неблокирующий вызов. Нет необходимости постоянно запрашивать статус вызова ввода-вывода посредством опроса. Вместо этого следующая задача может быть обработана напрямую. Ввод-вывод завершен. Просто передайте данные приложению через семафор или обратный вызов.
Как реализовать этот асинхронный ввод-вывод? Ответ: пул потоков.
Хотя в этой статье всегда упоминалось, что Node выполняется в одном потоке, здесь один поток означает, что код JavaScript выполняется в одном потоке. Для таких частей, как операции ввода-вывода, которые не имеют ничего общего с основной бизнес-логикой. запуск в другой реализации в виде потоков не повлияет и не заблокирует работу основного потока. Напротив, это может повысить эффективность выполнения основного потока и реализовать асинхронный ввод-вывод.
Через пул потоков позвольте основному потоку выполнять только вызовы ввода-вывода, позвольте другим потокам выполнять блокирующий ввод-вывод или неблокирующий ввод-вывод плюс технологию опроса для завершения сбора данных, а затем используйте связь между потоками для завершения ввода-вывода. /O Передаются полученные данные, что легко реализует асинхронный ввод-вывод:
Основной поток выполняет вызовы ввода-вывода, в то время как пул потоков выполняет операции ввода-вывода, завершает сбор данных, а затем передает данные в основной поток посредством связи между потоками для завершения вызова ввода-вывода, а основной поток reuses Функция обратного вызова предоставляет данные пользователю, который затем использует их для выполнения операций на уровне бизнес-логики. Это полный асинхронный процесс ввода-вывода в Node. Пользователям не нужно беспокоиться о громоздких деталях реализации базового уровня. Им нужно только вызвать асинхронный API, инкапсулированный Node, и передать функцию обратного вызова, которая обрабатывает бизнес-логику, как показано ниже:
const fs = require. («фс»); fs.readFile('example.js', (данные) => { // Бизнес-логика процесса});
Базовый асинхронный механизм реализации Nodejs различен на разных платформах: в Windows IOCP в основном используется для отправки вызовов ввода-вывода в ядро системы и получения завершенных операций ввода-вывода из ядра. с циклом событий для завершения процесса асинхронного ввода-вывода этот процесс реализуется через epoll в Linux, через kqueue в FreeBSD и через порты событий в Solaris; Пул потоков напрямую предоставляется ядром (IOCP) под Windows, а серия *nix
реализуется самой libuv.
Из-за разницы между платформой Windows и платформой *nix
, Node предоставляет libuv в качестве абстрактного уровня инкапсуляции, так что все оценки совместимости платформы выполняются на этом уровне, гарантируя, что Node верхнего уровня, собственный пул потоков нижнего уровня и IOCP независимы друг от друга. Node определит условия платформы во время компиляции и выборочно скомпилирует исходные файлы в каталоге unix или каталоге win в целевую программу:
Вышеуказанное является реализацией асинхронного режима Node.
(Размер пула потоков можно установить с помощью переменной среды UV_THREADPOOL_SIZE
. Значение по умолчанию — 4. Пользователь может настроить размер этого значения в зависимости от реальной ситуации.)
Тогда возникает вопрос: после получения данных, переданных пул потоков, как работает основной поток? Когда вызывается функция обратного вызова? Ответ — цикл событий.
Посколькуиспользует функции обратного вызова для обработки данных ввода-вывода, она неизбежно включает в себя вопрос о том, когда и как вызывать функцию обратного вызова. В реальной разработке часто задействованы сценарии асинхронных вызовов ввода-вывода с несколькими типами. Как разумно организовать вызовы этих обратных вызовов асинхронного ввода-вывода и обеспечить упорядоченное выполнение асинхронных обратных вызовов, является сложной проблемой. асинхронный ввод-вывод Помимо /O, существуют также асинхронные вызовы, не связанные с вводом-выводом, такие как таймеры. Такие API работают в реальном времени и имеют, соответственно, более высокие приоритеты. Как запланировать обратные вызовы с разными приоритетами?
Следовательно, должен существовать механизм планирования для координации асинхронных задач различных приоритетов и типов, чтобы гарантировать упорядоченное выполнение этих задач в основном потоке. Как и браузеры, Node выбрал цикл событий для выполнения этой тяжелой работы.
Node делит задачи на семь категорий в зависимости от их типа и приоритета: Таймеры, Ожидание, Ожидание, Подготовка, Опрос, Проверка и Закрытие. Для каждого типа задач существует очередь задач в порядке очереди для хранения задач и их обратных вызовов (таймеры хранятся в небольшой верхней куче). На основании этих семи типов Node делит выполнение цикла событий на следующие семь этапов:
Приоритет выполнения этого этапа
На этом этапе цикл событий проверит структуру данных (минимальная куча), в которой хранится таймер, пройдётся по таймерам в ней, сравнит текущее время и время истечения один за другим и определит, истек ли срок действия таймера. , таймер будет Функция обратного вызова вынесена и выполнена.
фазе будут выполняться обратные вызовы при возникновении сетевых, IO- и других исключений. На этом этапе будут обработаны некоторые ошибки, о которых сообщает *nix
. Кроме того, некоторые обратные вызовы ввода-вывода, которые должны быть выполнены на этапе опроса предыдущего цикла, будут перенесены на этот этап.
используются только внутри цикла событий.
извлекает новые события ввода-вывода; выполняет обратные вызовы, связанные с вводом-выводом (почти все обратные вызовы, кроме обратных вызовов завершения работы, обратных вызовов, запланированных по таймеру, и setImmediate()
), узел будет блокироваться здесь в подходящее время.
Опрос, то есть этап опроса, является наиболее важным этапом цикла событий. На этом этапе в основном обрабатываются обратные вызовы для сетевого ввода-вывода и файлового ввода-вывода. Этот этап имеет две основные функции:
расчет продолжительности блокировки и опрос ввода-вывода.
Обработка обратных вызовов в очереди ввода-вывода.
Когда цикл событий переходит в фазу опроса и таймер не установлен:
если очередь опроса не пуста, цикл событий будет проходить по очереди, выполняя их синхронно, пока очередь не станет пустой или не будет достигнуто максимальное число, которое может быть выполнено.
Если очередь опроса пуста, произойдет одно из двух:
если есть обратный вызов setImmediate()
, который необходимо выполнить, фаза опроса немедленно заканчивается и начинается фаза проверки для выполнения обратного вызова.
Если нет обратных вызовов setImmediate()
для выполнения, цикл событий останется на этой фазе, ожидая добавления обратных вызовов в очередь, а затем немедленно выполнит их. Цикл событий будет ждать, пока истечет время ожидания. Причина, по которой я решил остановиться на этом, заключается в том, что Node в основном обрабатывает ввод-вывод, поэтому он может реагировать на ввод-вывод более своевременно.
Когда очередь опроса пуста, цикл событий проверяет наличие таймеров, достигших своего порога времени. Если один или несколько таймеров достигнут порогового значения времени, цикл событий вернется к фазе таймеров, чтобы выполнить обратные вызовы для этих таймеров.
этапе последовательно выполняются обратные вызовы setImmediate()
.
На этом этапе будут выполняться некоторые обратные вызовы для закрытия ресурсов, например, socket.on('close', ...)
. Отложенное выполнение этого этапа окажет незначительное влияние и будет иметь самый низкий приоритет.
Когда процесс Node запускается, он инициализирует цикл событий, выполняет входной код пользователя, выполняет соответствующие асинхронные вызовы API, планирование таймера и т. д., а затем начинает входить в цикл событий:
┌───────── ── ─────────────────┐ ┌─>│ таймеры │ + + │ │ ожидающие обратные вызовы │ + + │ │ простаивать, готовиться │ │ └──────────────┬───────────┘ ┌───────────────┐ │ ┌─────────────┴────────────┐ │ входящие: │ │ │ опрос │<─────┤ связи, │ │ └─────────────┬─────────────┘ │ данные и т. д. │ + │ │ проверить │ + + └──┤ закрытие обратных вызовов │ └─────────────────────────────┘Каждая
итерация цикла событий (часто называемая галочкой) будет такой, как указано выше. Приоритет заказ входит в семь этапов выполнения. На каждом этапе выполняется определенное количество обратных вызовов в очереди. Причина, по которой выполняется только определенное количество, но не все, заключается в том, чтобы время выполнения текущего этапа не было слишком длинным и избежать провала следующего этапа. Не выполняется.
Хорошо, выше приведен основной поток выполнения цикла событий. Теперь давайте рассмотрим другой вопрос.
Для следующего сценария:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
Когда служба успешно привязана к порту 8000, то есть когда listen()
успешно вызывается, обратный вызов события listening
еще не привязан, поэтому после того, как порт будет успешно привязан, обратный вызов события listening
, которое мы передали, не будет выполнен.
Думая о другом вопросе, у нас могут возникнуть некоторые потребности во время разработки, такие как обработка ошибок, очистка ненужных ресурсов и другие задачи с низким приоритетом. Если эта логика выполняется синхронно, это повлияет на эффективность выполнения; если setImmediate()
передается асинхронно, например, в форме обратных вызовов, время их выполнения не может быть гарантировано, а производительность в реальном времени не высока. И как бороться с этой логикой?
Основываясь на этих проблемах, Node взял ссылку на браузер и реализовал набор механизмов микрозадач. В Node, помимо вызова new Promise().then()
переданная функция обратного вызова будет инкапсулирована в микрозадачу. Обратный вызов process.nextTick()
также будет инкапсулирован в микрозадачу, а приоритет выполнения будет инкапсулирован в микрозадачу. последнее будет выше первого.
Каков процесс выполнения цикла событий в случае микрозадач? Другими словами, когда выполняются микрозадачи?
В узле 11 и более поздних версиях после выполнения задачи на этапе очередь микрозадач немедленно выполняется, и очередь очищается.
Выполнение микрозадачи начинается после того, как этап был выполнен до узла 11.
Таким образом, в случае с микрозадачами каждый цикл цикла событий сначала выполняет задачу на этапе таймеров, а затем очищает очереди микрозадач process.nextTick()
и new Promise().then()
по порядку, а затем продолжает выполнение. следующая задача на этапе таймеров или следующий этап, то есть задача на этапе ожидания, и так далее в указанном порядке.
Используя process.nextTick()
, Node может решить описанную выше проблему привязки порта: внутри метода listen()
выдача события listening
будет инкапсулирована в обратный вызов и передана в process.nextTick()
, как показано в следующем псевдо-коде. код:
функция прослушивания() { // Выполняем операции с портом прослушивания... // Инкапсулируем выдачу события `listening` в обратный вызов и передаем его в `process.nextTick()` вprocess.nextTick(() => { излучать('слушаю'); }); };
После выполнения текущего кода микрозадача начнет выполняться, тем самым выдавая событие listening
и запуская вызов обратного вызова события.
Из-за непредсказуемости и сложности самой асинхронности, в процессе использования асинхронного API, предоставляемого Node, хотя мы и освоили принцип выполнения цикла событий, все же могут возникнуть некоторые явления, которые не являются интуитивными или ожидаемыми. .
Например, порядок выполнения таймеров ( setTimeout
, setImmediate
) будет отличаться в зависимости от контекста, в котором они вызываются. Если оба вызываются из контекста верхнего уровня, время их выполнения зависит от производительности процесса или машины.
Давайте рассмотрим следующий пример:
setTimeout(() => { console.log('тайм-аут'); }, 0); setImmediate(() => { console.log('немедленно'); });
Каков результат выполнения приведенного выше кода? Согласно нашему описанию цикла событий, вы можете получить такой ответ: поскольку фаза таймеров будет выполнена до фазы проверки, сначала будет выполнен обратный вызов setTimeout()
, а затем обратный вызов setImmediate()
. казнен.
Фактически, результат вывода этого кода неопределенен. Сначала может быть выведено значение Timeout или немедленное значение. Это связано с тем, что оба таймера вызываются в глобальном контексте. Когда цикл событий запускается и выполняется до стадии таймеров, текущее время может быть больше 1 мс или меньше 1 мс, в зависимости от производительности машины. , на самом деле неясно, будет setTimeout()
на первом этапе таймеров, поэтому появятся разные выходные результаты.
(Когда значение delay
(второй параметр setTimeout
) больше 2147483647
или меньше 1
, delay
будет присвоено значение 1
)
Давайте посмотрим на следующий код:
const fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => { console.log('тайм-аут'); }, 0); setImmediate(() => { console.log('немедленно'); }); });
Видно, что в этом коде оба таймера инкапсулированы в функции обратного вызова и передаются в readFile
. Очевидно, что при вызове обратного вызова текущее время должно быть больше 1 мс, поэтому обратный вызов setTimeout
будет выполнен. быть длиннее, чем обратный вызов setImmediate
Обратный вызов вызывается первым, поэтому напечатанный результат: timeout immediate
.
Вышеупомянутое относится к таймерам, на которые следует обратить внимание при использовании Node. Кроме того, вам также необходимо обратить внимание на порядок выполненияprocess.nextTick process.nextTick()
, new Promise().then()
и setImmediate()
. Поскольку эта часть относительно проста, она упоминалась ранее и не будет повторяться. .
: статья начинается с более подробного объяснения принципов реализации цикла событий Node с двух точек зрения: зачем нужна асинхронность и как ее реализовать, а также упоминаются некоторые связанные с этим вопросы, которые, я надеюсь, будут полезны. ты.