Обзор блокирующих и неблокирующих вызовов
В этом обзоре рассматривается разница между блокирующими и неблокирующими вызовами в Node.js. В обзоре будут упоминаться цикл событий и libuv, но предварительные знания по этим темам не требуются. Предполагается, что читатели имеют базовое понимание языка JavaScript и шаблона обратного вызова Node.js callback pattern.
INFO
"Ввод/вывод" (I/O) в первую очередь относится к взаимодействию с диском и сетью системы, поддерживаемому libuv.
Блокирующие вызовы
Блокирующий вызов — это ситуация, когда выполнение дополнительного кода JavaScript в процессе Node.js должно ждать завершения операции, не являющейся JavaScript. Это происходит потому, что цикл событий не может продолжать выполнение JavaScript, пока выполняется блокирующая операция.
В Node.js JavaScript, демонстрирующий низкую производительность из-за высокой загрузки ЦП, а не ожидания операции, не являющейся JavaScript, например, ввода/вывода, обычно не называется блокирующим. Синхронные методы в стандартной библиотеке Node.js, использующие libuv, являются наиболее часто используемыми блокирующими операциями. Собственные модули также могут иметь блокирующие методы.
Все методы ввода/вывода в стандартной библиотеке Node.js предоставляют асинхронные версии, которые являются неблокирующими и принимают функции обратного вызова. Некоторые методы также имеют блокирующие аналоги, имена которых заканчиваются на Sync
.
Сравнение кода
Блокирующие методы выполняются синхронно, а неблокирующие методы выполняются асинхронно.
Используя модуль файловой системы в качестве примера, это синхронное чтение файла:
const fs = require('node:fs')
const data = fs.readFileSync('/file.md') // блокируется здесь до завершения чтения файла
А вот эквивалентный асинхронный пример:
const fs = require('node:fs')
fs.readFile('/file.md', (err, data) => {
if (err) throw err
})
Первый пример кажется проще, чем второй, но имеет недостаток: вторая строка блокирует выполнение любого дополнительного JavaScript до тех пор, пока не будет прочитан весь файл. Обратите внимание, что в синхронной версии, если возникает ошибка, её необходимо перехватить, иначе процесс завершится аварийно. В асинхронной версии автор сам решает, следует ли обрабатывать ошибку как показано.
Давайте немного расширим наш пример:
const fs = require('node:fs')
const data = fs.readFileSync('/file.md') // блокируется здесь до завершения чтения файла
console.log(data)
moreWork() // будет выполнено после console.log
А вот похожий, но не эквивалентный асинхронный пример:
const fs = require('node:fs')
fs.readFile('/file.md', (err, data) => {
if (err) throw err
console.log(data)
})
moreWork() // будет выполнено до console.log
В первом примере выше console.log
будет вызван до moreWork()
. Во втором примере fs.readFile()
является неблокирующим, поэтому выполнение JavaScript может продолжаться, и moreWork()
будет вызван первым. Возможность запуска moreWork()
без ожидания завершения чтения файла — это ключевое проектное решение, которое позволяет добиться более высокой производительности.
Конкурентность и производительность
Выполнение JavaScript в Node.js однопоточно, поэтому конкурентность относится к способности цикла событий выполнять функции обратного вызова JavaScript после завершения другой работы. Любой код, который предполагается запускать конкурентно, должен позволять циклу событий продолжать работу, пока происходят не-JavaScript операции, такие как ввод-вывод.
Например, рассмотрим случай, когда каждый запрос к веб-серверу занимает 50 мс для завершения, и 45 мс из этих 50 мс — это ввод-вывод базы данных, который может выполняться асинхронно. Выбор неблокирующих асинхронных операций освобождает эти 45 мс на запрос для обработки других запросов. Это существенная разница в производительности просто за счет выбора неблокирующих методов вместо блокирующих.
Цикл событий отличается от моделей во многих других языках, где для обработки конкурентной работы могут создаваться дополнительные потоки.
Опасности смешивания блокирующего и неблокирующего кода
Существуют некоторые шаблоны, которых следует избегать при работе с вводом-выводом. Рассмотрим пример:
const fs = require('node:fs')
fs.readFile('/file.md', (err, data) => {
if (err) throw err
console.log(data)
})
fs.unlinkSync('/file.md')
В приведенном выше примере fs.unlinkSync()
скорее всего будет выполнен до fs.readFile()
, что приведет к удалению file.md
до того, как он будет фактически прочитан. Лучший способ написать это, который является полностью неблокирующим и гарантированно выполняется в правильном порядке:
const fs = require('node:fs')
fs.readFile('/file.md', (readFileErr, data) => {
if (readFileErr) throw readFileErr
console.log(data)
fs.unlink('/file.md', unlinkErr => {
if (unlinkErr) throw unlinkErr
})
})
Приведенный выше код размещает неблокирующий вызов fs.unlink()
внутри обратного вызова fs.readFile()
, что гарантирует правильный порядок операций.