Что делать, если вы не можете асинхронизировать/дождаться решения проблемы

Эта статья во многом основана на видео Хусейна Насера на аналогичную тему. Проверьте это, если вы этого не сделали.

Предпосылки:

Мне не нравится помещать в текст большие блоки «предварительных условий» — я считаю, что иногда они могут принести больше вреда, чем пользы — но я не буду отрицать, что знание этих блоков облегчит жизнь всем нам:
— Знакомство с экосистемой NodeJS и Express — вы создали сервер Express, каким бы простым он ни был.
— Базовые знания Promises (в частности, async/await и .then())
— A базовые знания асинхронного JavaScript. Вы должны уметь писать код, использующий обратные вызовы, обещания, async/await.

вступление

Представьте, что вы создаете инновационное приложение для прогнозирования погоды, которое опирается на сложное метеорологическое моделирование для получения точных прогнозов. Однако эти вычисления создают проблему: они нагружают процессор и потенциально могут помешать вашему приложению отвечать и обрабатывать запросы; Node.js превосходно справляется с задачами, связанными с вводом-выводом, благодаря своей логике async/await, но операции с интенсивным использованием ЦП требуют другого подхода. В этой статье мы отправимся в путешествие по многопроцессорной обработке, чтобы изящно решать задачи, связанные с процессором, что позволит нам создавать высокопроизводительные приложения.

Вот простой пример операции блокировки ЦП, позволяющей сделать это понятным:

const express = require("express");
const app = express();
app.use(express.json());

function fibonacci(number) {
    if (number < 0) {
        throw new Error("Non-negatives only, please");
    }
    if (number < 2) {
        return number;
    }
    return fibonacci(number - 1) + fibonacci(number - 2);
}

app.get("/blocking", async (req, res) => {
    number = parseInt(req.query.number);
    const start = Date.now();
    const result = fibonacci(number);
    const end = Date.now();
    res
        .status(200)
        .json({ number: number, result: result, time: end - start + "ms" });
});
app.get("/non-blocking", async (req, res) => {
    res.status(200).json("Hello");
});
app.listen(3000, () => {
    console.log(`Server running`);
});

Этот код создает простой веб-сервер с платформой Express в NodeJS; маршрут «блокировка» получает число, отправленное в качестве параметра запроса, вычисляет Фибоначчи для этого числа, отмечает время, необходимое для расчета Фибоначчи, и возвращает эту информацию в виде ответа JSON. У нас также есть «неблокирующий»маршрут, который возвращает строку при каждом посещении.

Небольшое примечание. Это неэффективный способ вычисления числовой последовательности Фибоначчи. Существуют более эффективные способы реализации этой функции (поищите мемоизацию). Я намеренно сделал эту функцию неэффективной, чтобы продемонстрировать задачу, связанную с процессором.

Создайте новый файл с именем index.js в VScode (или в предпочитаемом вами редакторе) и скопируйте код во вновь созданный файл. Откройте окно терминала в том же каталоге и выполните файл с помощью Node(node index.js), посетите localhost:3000/blocking?number=10 (это отправит запрос на блокирующий маршрут с параметром запроса 10) и вы должны получить ответ, подобный приведенному ниже:

Судя по результатам, код был выполнен менее чем за миллисекунду. Все бы ничего, но как насчет ситуации с более значительным числом?

Кажется, эта операция занимает некоторое время, давайте проверим наш «неблокирующий маршрут»:

Почему это происходит?

Среда выполнения NodeJS использует однопоточную модель выполнения; Механизм V8 работает с использованием стека вызовов и кучи — стек вызовов содержит выполняющуюся в данный момент функцию, а куча содержит динамические данные, такие как объекты. Во время работы нашей программы каждая функция помещается в стек и выполняется до тех пор, пока не вернется. Когда наша функция Фибоначчи начинает вычислять Фибоначчи для заданных входных данных, остальная часть программы останавливается в ожидании ее завершения — это включает в себя части нашего приложения по прослушиванию и обработке запросов.

Одним из решений может быть использование обещаний — в конце концов, разработчикам JS обычно рекомендуется использовать обещания для блокировкиопераций.

Возможное решение 1. Использование обещаний

Давайте попробуем обернуть всю операцию в обработчик обещаний. Остальная часть кода останется прежней.

app.get("/blocking", async (req, res) => {
    number = parseInt(req.query.number);
    Promise.resolve().then(() => {
        const start = Date.now();
        const result = fibonacci(number);
        const end = Date.now();
        res
            .status(200)
            .json({ number: number, result: result, time: end - start + "ms" });
    })
});

Примечание. Основная часть промиса выполняется синхронно. Обработчик промиса(.then/.catch) — это блок кода, который выполняется асинхронно. Вот почему мы создаем решенное обещание вместо того, чтобы запускать этот код в теле обещания. Подробнее об этом читайте в этой замечательной статье.

Давайте проверим наше модифицированное приложение:

Под капотом: обещания и очередь обещаний
В конечном итоге мы получаем такое же поведение; это происходит потому, что промис только замедляет выполнение нашего кода — основная часть промиса выполняется синхронно, а обработчик промиса ставится в очередь для запуска после выполнения всех синхронных строк кода в нашей программе — наша функция, интенсивно использующая процессор, будет блокировать генерацию ответа до тех пор, пока он не завершится. Цикл событий также не сможет обрабатывать другие запросы во время обработки нашей операции, поставленной в очередь в очереди обещаний.

Как же тогда работает асинхронный ввод-вывод?
Как возможно, что await asynchronousIOFunction — где «asynchronousIOFunction» соответствует любой асинхронной функции ввода-вывода на основе обещаний — позволяет нашему коду быть не- блокировать, если обещание только замедляет выполнение нашего кода?

Асинхронный ввод-вывод возможен, поскольку Javascript может делегировать задачи ввода-вывода (чтение с диска, чтение по сети и т. д.) Libuvбиблиотеке C для обработки асинхронных операций ввода-вывода. Эти задачи будут выполняться независимо от Javascript и возвращать данные для обратных вызовов и микрозадач. Эти обратные вызовы и микрозадачи (кстати, сюда входят и обработчики обещаний) будут выполняться после завершения всего синхронного кода. (Подробное и наглядное руководство по этому процессу см. https://www.builder.io/blog/visual -guide-to-nodejs-event-loop#libuv)

Исправление: многопоточность и многопроцессорность

Это два способа достижения одной и той же концепции: разделение программы на подзадачи и делегирование этих задач операционной системе. Многопроцессорность и многопоточность обеспечивают одновременную работу нескольких сегментов нашей программы.
Поток — это единица выполнения, представляющая собой последовательность инструкций. В контексте операционных систем поток относится к последовательности инструкций, выполнение которых можно запланировать на ядре ЦП. Это наименьшая единица выполнения ЦП, и в одном процессе может существовать несколько потоков. Каждый поток внутри процесса может независимо выполнять свой собственный набор инструкций и запланирован для выполнения на «ядрах» ЦП.
В процессоре аппаратные компоненты, отвечающие за выполнение инструкций, называются «ядрами». Ядро представляет собой вычислительную единицу, предназначенную для выполнения инструкций. В одноядерном процессоре в любой момент времени выполняется только один набор инструкций. Чтобы обеспечить одновременное выполнение, для одновременной обработки нескольких программ используются такие методы, как переключение контекста.

Многопоточность в разработке программного обеспечения обычно означает разделение нашей программы на разные потоки одного и того же процесса, что позволяет им выполняться псевдонезависимо. Microsoft Word, например, использует многопоточность, имея выделенный поток для сбора данных, введенных пользователем с помощью нажатий клавиш, другой поток для обновления документа, над которым ведется работа, и т. д.

Многопоточность дает несколько преимуществ:

Легче, чем процессы. Создание нового потока обходится относительно недорого по сравнению с созданием целого процесса в системе.

Более быстрое переключение контекста. Поскольку все потоки являются частью одного и того же процесса, между ними легче переключаться во время их выполнения.

Упрощенное взаимодействие и общая память. Потоки в процессе используют одно и то же пространство памяти; сюда входят память, переменные и другие данные программы. Кроме того, связь между потоками проще, чем между двумя отдельными процессами.

Преимущества потоков также создают ряд уникальных проблем:

Высокий риск повреждения памяти. Поскольку потоки используют одно и то же пространство памяти, любое повреждение потока может остановить работу всей программы. Необработанное исключение в одном потоке, скорее всего, приведет к сбою программы.

Проблемы масштабирования. Потоки также сложнее масштабировать на нескольких машинах из-за интенсивной связи между ними.

Состояния гонки и проблемы сложности. Многопоточный код значительно сложнее тестировать, отлаживать и разрабатывать, чем простой однопоточный код; это связано с необходимостью иметь дело с проблемами синхронизации, тестирования и другими проблемами, возникающими в результате этого подхода, главными из которых являются проблемы состояний гонки и взаимоблокировок.

При многопроцессорном подходе мы снова разделяем нашу программу на разные блоки, но на этот раз мы изолируем их как разные процессы на компьютере. Это решает некоторые из наших основных проблем с потоками:

Повреждения памяти. Процессы представляют собой изолированные программы на компьютере: сбой в одном процессе практически не влияет на другие процессы.

Простота масштабирования. Благодаря своей высокой независимости процессы вынуждают программиста разрабатывать эффективные каналы межпроцессного взаимодействия (IPC). Эти каналы обычно легко масштабируются на несколько машин.

К сожалению, использование нескольких процессов приводит к потере многих ранее обсуждавшихся преимуществ потоков; Компьютерные процессы требуют больших затрат на создание и взаимодействие из-за их высокой независимости, и, как упоминалось ранее, процессы требуют более структурированных каналов связи, чем потоки. Как и многие вещи в разработке программного обеспечения, каждый выбор несет в себе ряд компромиссов и преимуществ.

Из-за ранее упомянутых проблем с потоками в оставшейся части этого руководства мы будем использовать процессы; Хотя это в некоторой степени личный выбор — я думаю, что компьютерные потоки подойдут для этой демонстрации, но я бы предпочел избежать проблем с ними. Однако в реальной ситуации ваш выбор будет определяться характером проблемы и другими факторами. Как инженеры-программисты, мы должны уметь оценивать различные варианты и выбирать подходящее решение проблемы.

Дочерние процессы в NodeJS

Модуль Child_Process в NodeJS позволяет нам создавать дополнительные процессы, известные как дочерние процессы, и управлять ими из основного приложения Node.js. Этот модуль удобен при одновременном выполнении задач или взаимодействии с внешними исполняемыми файлами или скриптами. Дочерние процессы позволяют приложению эффективно использовать многоядерные системы, повышать производительность и выполнять задачи, которые в противном случае могли бы заблокировать основной цикл событий.

Разгрузка наших задач дочерним процессам
У нас будет основной процесс для получения и отправки запросов, а также мы создадим подпроцесс для обработки операций каждого запроса. Таким образом, основной процесс освобождается для продолжения приема и распределения запросов от клиентов.

Создайте новый файл с именем «child.js» и скопируйте в него функцию Фибоначчи. Возвращаясь к нашему «index.js», нам нужно внести это изменение в верхнюю часть файла:

const express = require('express');
  const { fork } = require('child_process');

Мы импортируем функцию Fork из модуля Child_process. Fork позволяет нам создать новый экземпляр NodeJS из заданного файла JavaScript с собственным движком V8, циклом событий и всем остальным. который поставляется с экземпляром NodeJS. В дополнение к этому, у нового дочернего процесса будет канал IPC (помните этот термин?) между родительским (наш основной файл — index.js) и вновь сформированным дочерним процессом. Это позволяет легко общаться между родительским процессом и дочерним процессом.

Вот остальная часть кода в нашем основном файле (index.js):

const app = express();
const port = 3000;

app.get('/blocking', (req, res) => {
    const number = parseInt(req.query.number);
    const child = fork('./child.js');
    child.send(number);
    child.on('message', (
        data) => { res.status(200).json(data); });
})

app.get('/non-blocking', (req, res) => {
    res.status(200).send("Hello!");
})

app.listen(port, 'localhost', () => { console.log('Now listening on port', port) });

Здесь есть что распаковать, так что начнем:

— Мы по-прежнему получаем целое число, введенное пользователем, из параметров запроса.

const child = fork(‘./child.js’)создать новый дочерний процесс из дочернего файла. Строка fork возвращает объект ‹ChildProcess><.

— Мы отправляем введенное пользователем число дочернему процессу с помощью функции ‹ChildProcess›.send().

— Мы прикрепляем прослушиватель событий к дочернему объекту процесса для события «сообщение». Это событие генерируется всякий раз, когда дочерний элемент отправляет сообщение родителю. В теле прослушивателя событий мы генерируем ответ на основе вычисленного значения Фибоначчи.

Код файла child.js:

const fibonacci = (n) => {
    if (n == 0) {
        return 0;
    }
    if (n == 1) {
        return 1;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
}

process.on('message', (data) => { 
    const initialTime = Date.now();
    const fibonacciValue = fibonacci(data);
    const endTime = Date.now() - initialTime;
    process.send({
        number: data,
        result: fibonacciValue,
        time: endTime + 'ms'
    });
    process.exit();
 })

— Мы переместили все блоки временных меток и вычислений Фибоначчи в «дочерний» файл.

— Прикрепляем прослушиватель событий к объекту «процесс» дочернего процесса; это соответствует реальному дочернему процессу, и мы слушаем событие «сообщение», которое генерируется всякий раз, когда родительский процесс отправляет сообщение.

— Вычисление Фибоначчи происходит в этом процессе, а не в родительском процессе. Это означает, что родительский процесс никогда не будет заблокирован.

— После расчета мы отправляем значение и время, затраченное на расчет, обратно в родительский процесс. Напомним, что это создаст событие «сообщение», которое будет получено родителем.

— В конце всего выходим из дочернего процесса с помощью функции «process.exit()». Это не позволяет нам иметь чрезмерное количество процессов, засоряющих память и ничего не делающих.

Вот и все! Остаётся только протестировать новую версию кода.

Последние мысли

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