На этой странице

Этот обзор рассматривает разницу между блокирующими и неблокирующими вызовами в Node.js. Здесь будут упомянуты event loop и libuv, но предварительное знание этих тем не требуется. Предполагается, что читатель обладает базовым пониманием языка JavaScript и паттерна колбэков в Node.js.

Под «I/O» здесь в первую очередь понимается взаимодействие с диском и сетью системы, обеспечиваемое libuv.

Блокировка — это когда выполнение дополнительного JavaScript в процессе Node.js вынуждено ждать завершения не-JavaScript-операции. Так происходит, потому что event loop не способен продолжать выполнять JavaScript, пока идёт блокирующая операция.

В Node.js JavaScript, демонстрирующий низкую производительность из-за интенсивной нагрузки на CPU, а не из-за ожидания не-JavaScript-операции, такой как I/O, обычно не называют блокирующим. Синхронные методы стандартной библиотеки Node.js, использующие libuv, — самые часто применяемые блокирующие операции. У нативных модулей тоже могут быть блокирующие методы.

Все методы I/O в стандартной библиотеке 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 однопоточно, поэтому конкурентность здесь означает способность event loop выполнять функции-колбэки JavaScript после завершения другой работы. Любой код, который должен выполняться конкурентно, должен позволять event loop продолжать работать, пока идут не-JavaScript-операции, такие как I/O.

В качестве примера рассмотрим случай, когда каждый запрос к веб-серверу выполняется 50 мс, и 45 мс из этих 50 мс — это I/O к базе данных, которое можно выполнить асинхронно. Выбор неблокирующих асинхронных операций высвобождает эти 45 мс на запрос для обработки других запросов. Это существенная разница в пропускной способности — всего лишь благодаря выбору неблокирующих методов вместо блокирующих.

Event loop отличается от моделей многих других языков, где для обработки конкурентной работы могут создаваться дополнительные потоки.

Есть паттерны, которых при работе с I/O следует избегать. Рассмотрим пример:

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(), что гарантирует правильный порядок операций.