Ресурсоемкий Node.js: часть 1

Мы изучаем проблемы и решения, связанные с запуском ресурсоемкого кода на Node.js в плане ЦПУ, в частности на веб-сервере.

Однопоточный подход Node.js к запуску кода (кода, который вы пишете) может представлять проблему, если код тратит много ресурсов процессора.

Проблема

Сначала мы продемонстрируем проблему путем запуска ресурсоемкого кода типичного веб-приложения Node.js. В данном случае мы модифицируем пример hello world Node.js с использованием пакета sleep для имитации веб-сервера, где каждый запрос занимает 5 секунд, запуская ресурсоемкий код (доступен полный пример).

index.js
/* eslint-disable no-console */
const http = require('http');
const { sleep } = require('sleep');
const hostname = '127.0.0.1';
const port = 3000;
const server = http.createServer((req, res) => {
  sleep(5); // ARTIFICIAL CPU INTENSIVE
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello World\n');
});
server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

Причина, по которой данный код представляет проблему, заключается в том, что каждый запрос блокирует цикл событий JavaScript в течение пяти секунд. Например, скажем, два запроса поступают примерно в одно и то же время. Первый запрос немедленно обрабатывается и завершается в ожидаемые пять секунд. Однако, второй запрос застревает в очереди событий в течение пяти секунд, а затем завершается через пять секунд (занимает десять секунд).
В этом примере представлены проблемы с производительностью для случая, когда приходит более одного запроса за пятисекундный период. Однако, этот пример искусственный и довольно ограниченный.

Усугубляем

Для дальнейшей демонстрации проблемы блокировки цикла событий JavaScript мы можем создать второй пример веб-приложения, основанный на примере Express hello world. В этом случае у нас есть два эндпоинта, один из которых не является русерсоемким, а другой является (доступен полный пример).

index.js
/* eslint-disable no-console */
const express = require('express');
const { sleep } = require('sleep');
const app = express();
app.get('/', (req, res) => res.send('Hello World!'));
app.get('/intense', (req, res) => {
  sleep(5); // ARTIFICIAL CPU INTENSIVE
  res.send('Hello Intense!');
});
app.listen(3000, () => console.log('Example app listening on port 3000!’));

Как и в предыдущем примере, в этом тоже возникают проблемы с производительностью (в течение пяти секунд), когда мы принимаем запрос к ресурсоемкому эндпоинту. В этом случае все запросы ко всем эндпоинтам задерживаются до завершения выполнения ресурсоемкого кода. Фак!

Fork

Подобно другим языкам программирования, Node.js может создавать новые дочерние процессы для обработки интенсивных задач без блокировки цикла событий родительского процесса. Возможно, мы сможем использовать этот подход при рефакторинге нашего веб-приложения (доступен полный пример).

index.js

/* eslint-disable no-console */
const express = require('express');
const { fork } = require('child_process');
const app = express();
app.get('/', (req, res) => res.send('Hello World!'));
app.get('/intense', (req, res) => {
  const worker = fork('./worker');
  worker.on('message', ({ fruit }) => {
    res.send(`Hello Intense ${fruit}!`);
    worker.kill();
  });
  worker.send({ letter: 'a' });
});
app.listen(3000, () => console.log('Example app listening on port 3000!'));

worker.js

const { sleep } = require('sleep');
process.on('message', ({ letter }) => {
  sleep(5); // ARTIFICIAL CPU INTENSIVE
  let fruit = null;
  switch (letter) {
    case 'a':
      fruit = 'apple';
      break;
    default:
      fruit = 'unknown';
  }
  process.send({ fruit });
});

Все это выглядит хорошо, пока вы не копнете немного глубже.
Важно иметь в виду, что порожденные дочерние процессы Node.js не зависят от родителя, за исключением канала связи IPC, который устанавливается между ними. Каждый процесс имеет свою собственную память с собственными экземплярами V8. Из-за необходимости выделения дополнительных ресурсов, не рекомендуется генерировать большое количество дочерних процессов Node.js.
— Команда Node.js

Глядя на монитор процессов моей ОС, я заметил, что родительский процесс потребляет около 14 Мбайт памяти, а каждый дочерний процесс (во время работы) потребляет около 7 Мбайт памяти. С моей машиной (1740 мегабайт свободной памяти) около 250 одновременных вызовов на эндпоинт с высокой загрузкой могут привести к крешу.
Примечание. Для сравнения, язык Go с goroutines будет использовать только несколько KiB на запрос (таким образом, он может обрабатывать 250 000 одновременных запросов).

Это решение плохо масштабируется для веб-сервера на Node.js. Фак.

Следующие шаги

Существует обобщенное решение этой проблемы Node.js (веб-сервер с высоконагруженными эндпоинтами): настройка очереди и пула рабочих процессов. В следующей статье Ресурсоемкий Node.js: Часть 2 мы исследуем это решение.

Если вам интересна веб-разработка, присоединяйтесь к нашему каналу в Telegram!

Оригинал статьи - CPU Intensive Node.js: Part 1