Обзор блокирующих и неблокирующих операций
В этом обзоре рассматривается разница между блокирующими и неблокирующими вызовами в Node.js. В этом обзоре будут упоминаться event loop и libuv, но предварительные знания этих тем не требуются. Предполагается, что читатели имеют базовое представление о языке JavaScript и паттерне callback в Node.js.
INFO
"I/O" (ввод/вывод) относится, прежде всего, к взаимодействию с диском и сетью системы, поддерживаемому libuv.
Блокирующие операции
Блокирующая операция - это когда выполнение дополнительного JavaScript в процессе Node.js должно ждать завершения операции, не относящейся к JavaScript. Это происходит потому, что event loop не может продолжать выполнение JavaScript во время выполнения блокирующей операции.
В Node.js JavaScript, который демонстрирует низкую производительность из-за интенсивного использования ЦП, а не из-за ожидания операции, не относящейся к JavaScript, такой как I/O, обычно не называется блокирующим. Синхронные методы в стандартной библиотеке Node.js, использующие libuv, являются наиболее часто используемыми блокирующими операциями. Нативные модули также могут иметь блокирующие методы.
Все методы I/O в стандартной библиотеке Node.js предоставляют асинхронные версии, которые являются неблокирующими и принимают функции обратного вызова. Некоторые методы также имеют блокирующие аналоги, имена которых заканчиваются на Sync
.
Сравнение кода
Блокирующие методы выполняются синхронно, а неблокирующие методы выполняются асинхронно.
Используя модуль File System в качестве примера, вот синхронное чтение файла:
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()
, что гарантирует правильный порядок операций.