Параллелизм с NodeJS на практике

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

Зачем нужен параллелизм

Есть два типа задач, для выполнения которых современное оборудование требует определенных усилий и времени: интенсивные вычисления и чтение / запись на носитель, который не является памятью. Вычисления связаны с процессором, а операции чтения / записи связаны с вводом-выводом. Если у нас есть только одно ядро ​​ЦП, в данный момент может выполняться только одно вычисление. Точно так же, если имеется только один дисковод для гибких дисков, мы можем выполнять только одну операцию ввода-вывода за раз.

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

Неблокирующий ввод / вывод

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

Если ваш код продолжает работать, кто заботится об операциях ввода-вывода? Просто, другая ветка. Но NodeJS считается однопоточным! Да и нет. NodeJS является однопоточным для ВАШЕГО кода (включая все эти обратные вызовы). Каждый раз, когда вы запрашиваете операцию ввода-вывода, NodeJS порождает для нее новый поток и передает результаты вашему потоку через цикл обработки событий.

Вот что означает неблокирующий ввод-вывод: он выполняет операции ввода-вывода параллельно с вашим кодом. Что произойдет, если ваш код инициализировал операцию ввода-вывода, а затем начал выполнять некоторые интенсивные вычисления, на выполнение которых требуется время? Ну, код, предназначенный для обработки результатов ввода-вывода, должен ждать после завершения вычислений.

Соображения относительно вычислительных операций

Почему нужно относиться к этим двум типам задач по-разному? Почему бы не сделать выполнение задач, связанных с процессором, таким же образом, как операции ввода-вывода? Потому что характеристики параллельных операций ввода-вывода и параллельных вычислений сильно различаются. Операции ввода-вывода легко распараллеливаются. Веб-сервер может легко обрабатывать тысячи сетевых подключений в данный момент. С другой стороны, вычислительные задачи с привязкой к ЦП могут выполняться параллельно только по количеству ядер ЦП. Нет особой выгоды в том, чтобы настроить неблокирующие вычисления только для того, чтобы в конце они выполнялись в последовательном порядке. Кроме того, если ваш основной код не сразу полагается на результат вычислений, возможно, вам стоит рассмотреть возможность запуска вычислений в отдельном процессе или даже в отдельном микросервисе!

И это различие заключается в том, что некоторые традиционные языки и фреймворки не смогли донести до своих пользователей. Например, некоторые фреймворки веб-серверов запускают новый поток для каждого HTTP-запроса. Это нормально, когда большинство запросов связаны с задачами ввода-вывода. Если они привязаны к ЦП, наличие такого большого количества потоков не приведет к ускорению ответов, а все накладные расходы на потоки могут фактически замедлить работу всего сервера.

NodeJS как веб-сервер - пример

У нас есть микросервис, который принимает HTTP-запросы от игроков, стоящих в очереди вверх по лестнице. Если в очереди уже есть игроки, служба пытается сопоставить двух игроков в игре. Алгоритм сопоставления принимает в качестве входных данных уровни навыков игроков, их истории игр и время ожидания в очереди. Если игроки слишком долго ждали в очереди, мы постараемся сопоставить их с большим пробелом в навыках. На первый взгляд, мы могли отбросить несколько setInterval и вызвать алгоритм подбора игроков в заданные промежутки времени на веб-сервере NodeJS.

Обратной стороной этой реализации является то, что после запуска алгоритма подбора игроков веб-сервер практически не может отвечать ни на что другое. Вместо того, чтобы пытаться выжать из этой архитектуры больше производительности, мы спросили себя, должно ли вычисление сопоставления быть частью цикла HTTP-запроса / ответа или нет. Когда нам стало ясно, что ответ - НЕТ, мы переместили код подбора игроков в отдельное приложение.

Это еще не все. Мы разработали это приложение таким образом, чтобы мы могли запускать несколько экземпляров параллельно, и процессы не должны были знать друг о друге. Приложение без гражданства!

Заключение

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