Почему, несмотря на свой однопоточный дизайн, Node.js по-прежнему способен обрабатывать параллелизм и несколько операций ввода-вывода одновременно? Ну, это все благодаря его асинхронной природе. Я мог бы закончить это прямо здесь и назвать это днем. Или я мог бы полностью объяснить эту асинхронную природу с практическими примерами, связанными понятиями и многими другими вещами. И это именно то, что я собираюсь сделать. Добро пожаловать в мое глубокое погружение в параллелизм в Node.js!

Держу пари, что вы знакомы с параллелизмом JavaScript в Node.js. Кроме того, скорее всего, вы уже слышали, что Node превосходно справляется с несколькими асинхронными операциями ввода-вывода.

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

Что вы узнаете

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

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

Звучит отлично? Давай сделаем это.

Начнем с параллелизма в теории Node.js.

Прежде чем я начну говорить о Node.js, давайте быстро выделим два термина, которые могут немного сбивать с толку. Это параллельный и параллельный параллелизм в Node.js. Давайте представим, что у вас есть две операции: A и B. Если бы вы вообще не имели дело с каким-либо параллелизмом, вы бы начали с операции A. После ее завершения вы бы начали операцию B, поэтому они просто будут работать последовательно. Такое исполнение будет выглядеть так, как показано на схеме ниже.

Операции выполняются последовательно

Как только операция A завершена, вы запускаете операцию B — они выполняются последовательно.

Если вы хотите обрабатывать эти операции одновременно, выполнение будет выглядеть следующим образом.

Операции выполняются одновременно

Альтернативное решение — выполнять операции одновременно.

Эти операции не выполняются последовательно, но и не выполняются одновременно. Вместо этого процесс «прыгает» или переключает контекст между ними.

Вы также можете выполнять эти операции параллельно. Затем они запускаются одновременно на двух отдельных процессорах. Теоретически их можно начинать и заканчивать одновременно.

Операции, выполняемые параллельно

Третий вариант — параллельное выполнение операций.

Одна нить, чтобы управлять ими всеми

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

Вот почему Node использует вместо этого один поток.

Такой подход имеет множество преимуществ. Никаких накладных расходов не возникает при создании новых потоков. Кроме того, в вашем коде гораздо проще рассуждать, так как вам не нужно беспокоиться о том, что произойдет, если два потока получат доступ к одной и той же переменной. Потому что этого просто не может быть. Есть и некоторые недостатки. Node — не лучший выбор для приложений, которые в основном связаны с интенсивными вычислениями ЦП. С другой стороны, он отлично справляется с обработкой нескольких запросов ввода-вывода. Итак, давайте немного сосредоточимся на этой части.

Операции ввода-вывода и узел

Во-первых, я должен ответить на вопрос: что вы подразумеваете под операциями ввода-вывода? Это операции, которые взаимодействуют с вещами извне вашего приложения. Это означает HTTP-запросы, чтение и запись на диск или операции с базой данных, и это лишь некоторые из них. Ввод-вывод в Node бывает двух «разновидностей»: блокирующий и неблокирующий. Очень важно различать эти два. «Блокировка» в «блокировке операций ввода-вывода» говорит сама за себя.

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

Давайте посмотрим на пример.

fs.readFileSync(filePath1)fs.readFileSync(filePath2)console.log('Журналирование после завершения обоих чтений')

view raw blocking-io.js размещено на ❤ на GitHub

В примере вы запускаете первое чтение, а затем, только после его завершения, запускаете второе. console.log появится после завершения обоих чтений.

Неблокирующая модель работает совсем по-другому. Давайте еще раз посмотрим на пример.

fs.readFile(filePath1, (err, content) =› { if (err) // обработка ошибки // иначе сделайте что-нибудь с содержимым}) fs.readFile(filePath2, (err, content) =› { /* та же история */ }) console.log('Произойдет до завершения чтения')

view rawnon-blocking-io.js, размещенный на ❤ на GitHub

Здесь запускается первая операция чтения, но код не останавливается. Вы также запускаете второе чтение и сразу после этого — console.log записывает в консоль. Как насчет результатов двух операций чтения? Ну, они будут обрабатываться асинхронно. Это означает, что вы получите их результаты в будущем, и у вас нет гарантии, какая операция завершится первой.

Какой из них вы должны предпочесть?

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

const express = require('express') const app = express()const port = 3055 app.get('/async', async (req, res) => { const user = await readUserAsync() res.send(user) }) app.get('/sync', (req, res) =› { const user = readUserSync() res.send(user)}) app.listen(port, () =› console.log(`Пример приложения прослушивание порта ${port}!`))

view rawexpress-endpoints.js, размещенный на ❤ на GitHub

Если вы хотите сравнить производительность этих двух конечных точек, вы можете использовать один из доступных инструментов бенчмаркинга (скажем, вас интересует, сколько запросов вы можете обработать в течение 5 секунд и до 10 запросов одновременно). Я выбрал Apache Benchmark. Он существует уже довольно давно, но он встроен в macOS. Он отлично справляется со своей задачей. Давайте запустим следующий тест, сначала для синхронной/блокирующей конечной точки.

ab -c 10 -t 5 "http://localhost:3055/sync

view rawbenchmark.sh размещено на ❤ на GitHub

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

Запросов в секунду

Как видите, вы можете обрабатывать около 12,1 запросов в секунду, что имеет смысл. Вы можете обрабатывать только один запрос за раз, это занимает около 80 мс (75 из вашего «дополнительного» времени чтения плюс несколько миллисекунд на чтение файла и обработку запроса). Таким образом, вы можете легко подсчитать, сколько запросов в секунду вы можете обработать. Это 1000 мс (1 секунда) / 80 мс = 12,5 запросов в секунду, так что вы довольно близки.

Теперь давайте взглянем на бенчмарк для асинхронной/неблокирующей конечной точки ниже.

Эталонный анализ асинхронных/неблокирующих конечных точек

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

Важно: всегда отдавайте предпочтение неблокирующему вводу-выводу, а не блокирующему вводу-выводу.

Но как?

Вы можете видеть, что Node работает достаточно хорошо, когда дело доходит до асинхронной обработки операций ввода-вывода. Это может быть немного удивительно, когда известно, что он работает только с одним потоком. Разве один поток не равен одной операции одновременно? Ну… да и нет.

Чтобы быть менее расплывчатым, вы действительно можете обрабатывать только одну операцию JavaScript за раз, однако вы можете воспользоваться тем фактом, что ядра современных операционных систем являются многопоточными. Таким образом, вы можете делегировать им операции ввода-вывода.

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

Что такое цикл событий JavaScript?

Давайте кратко объясним, что такое цикл событий и как он работает. Ранее я упоминал, что вам нужен какой-то «менеджер», чтобы иметь возможность обрабатывать асинхронное программирование или асинхронные операции. Это именно то, что делает Event Loop. Давайте рассмотрим пример асинхронной операции.

asyncOperation(параметр1, параметр2, функция(ошибка, результат) { /* … */ })

просмотреть сырой async-operation.js, размещенный на ❤ на GitHub

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

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

Это, конечно, большое упрощение того, как работает Event Loop. Если вы хотите узнать об этом больше — вы можете проверить этот замечательный раздел Петля событий из статьи Rising Stack — или посмотреть это вводное видео ниже.

Как насчет операций без ввода-вывода?

Итак, я поделился информацией об использовании блокирующего и неблокирующего ввода-вывода. Вы видели, что Node неплохо справляется с последним, и теперь знаете, как это сделать. Цикл событий для победы! Как насчет того, что не связано с вводом-выводом, но может выполнять для вас некоторые тяжелые вычисления? Например — сортировка огромного списка. К сожалению, именно здесь однопоточная среда Node не сияет так ярко. Вернемся к нашему примеру с Express API. Вы видели, что Node довольно хорошо справляется с обработкой запросов к вашей асинхронной конечной точке. Что произойдет, если тот же API предоставит новую конечную точку, которая выполняет тяжелые вычисления, занимающие несколько секунд?

app.get(‘/timeConsumingEndpoint’, (req, res) =› { doSomeHeavyComputing() res.sendStatus(200)})

view rawtime-current-endpoints.js, размещенный на ❤ на GitHub

Теперь давайте представим сценарий, в котором эта конечная точка используется некоторым пакетным заданием для обработки некоторых данных. В таком сценарии вы даже не выполняете никаких одновременных запросов. Вместо этого вы следите за тем, чтобы ваш сервер постоянно работал и выполнял какую-то работу за вас. Что произойдет, если вы запустите некоторые тесты на своей старой асинхронной конечной точке (назовем ее /async), в то время как ваша задача с интенсивным использованием ЦП постоянно выполняется? Давайте посмотрим.

API перестает отвечать

Да, вы правы. Ваш API в настоящее время практически не отвечает. Если вы не знаете об ограничениях однопоточной работы Node, то, к сожалению, вы можете довольно легко добиться такой невосприимчивости. Можно ли как-то исправить это дело? Одна идея, которая приходит на ум, — масштабировать ваше приложение. Но для этого вам не нужна помощь команды DevOps. К счастью, в Node есть несколько встроенных механизмов, которые позволяют вам этого добиться.

Кластерный режим

Официальная документация объясняет это довольно хорошо, поэтому я не буду изобретать велосипед и просто процитирую его здесь.

Один экземпляр Node.js выполняется в одном потоке. Чтобы воспользоваться преимуществами многоядерных систем, пользователю иногда требуется запустить кластер процессов Node.js для обработки нагрузки.

Итак, режим кластера дает вам очень простой балансировщик нагрузки. Он будет распределять нагрузку в циклическом подходе между узлами. Звучит довольно мило, не так ли? В настоящее время у нас есть модные процессоры с несколькими ядрами, поэтому имеет смысл воспользоваться ими. Особенно, если ваше приложение стало так безнадежно зависать.

Давайте посмотрим, как это может выглядеть.

if (cluster.isMaster) { console.log(`Master ${process.pid} запущен`) for (let i = 0; i ‹ numCPUs; i++) { cluster.fork() } cluster.on('exit' , (воркер, код, сигнал) => { console.log(`воркер ${worker.process.pid} умер`) })} else { const express = require('express') const app = express() // определить наши конечные точки здесь app.listen(port) console.log(`Процесс ${process.pid} запущен`)}

view rawapi-with-cluster.js, размещенный на ❤ на GitHub

Ничего слишком сложного, правда? Тот факт, что вам не нужно беспокоить команду DevOps, делает ее еще лучше! Если вы запустите свой API, вы увидите, что вы действительно породили некоторые процессы.

Новые процессы

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

Новый эталон

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

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

Значит, ты обречен? Ну, к счастью, это не так. Некоторое время назад один из релизов Node преподнес нам всем приятный сюрприз, а именно…

Рабочие темы!

Рабочие потоки все еще являются экспериментальной функцией веб-разработки на основе Node.js, а это означает, что их использование в производственной среде, вероятно, не лучшая идея. Но есть шанс, что это скоро изменится. Рабочие потоки позволяют выполнять код на JavaScript параллельно, упрощая работу с несколькими задачами или несколькими запросами. Это звучит точно так же, как то, что вы можете использовать в вышеупомянутом сценарии. Давайте кратко рассмотрим, как вы можете внедрить рабочие потоки в свой API. Первое, что вам нужно сделать, это создать новый файл. Я назвал его heavy-computing-with-threads.js.

const { Worker, isMainThread, parentPort } = require('worker_threads') if (isMainThread) { module.exports = асинхронная функция timeConsumingOperationOnThreads(raw) { return new Promise((resolve, reject) => { const worker = new Worker(__filename , { workerData: raw }) worker.on('сообщение', разрешение) worker.on('ошибка', отклонение) worker.on('выход', (код) => { if (код !== 0) { reject(new Error(`Worker остановлен с кодом выхода ${code}`)) } }) }) }} else { const result = doSomeHeavyComputing() parentPort.postMessage({ result})}

view rawheavy-computing-with-threads.js, размещенный на ❤ на GitHub

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

Теперь вам просто нужно изменить свой Express API, чтобы он использовал эту версию алгоритма с интенсивным использованием ЦП.

const timeConsumingOperationWithThreads = require('./тяжелые вычисления-с-потоками')/* … */ app.get('/timeConsumingEndpoint', async (req, res) =› { const result = await timeConsumingOperationWithThreads() res.send (результат)})

view rawapi-with-threads.js, размещенный на ❤ на GitHub

Если вы снова запустите тесты (даже с версией, которая отправляла параллельные запросы к долго работающей конечной точке), все должно быть в порядке. Другие ваши конечные точки снова реагируют!

Заинтересованы в разработке микросервисов? 🤔 Обязательно ознакомьтесь с нашим Отчетом о состоянии микросервисов в 2020 году, основанным на мнениях более 650 экспертов по микросервисам!

Еще несколько вещей, которые нужно помнить

Помимо того, что они полезны при использовании экспериментальных рабочих потоков, вы также должны помнить, что они не обязательно полезны для обработки операций ввода-вывода. То, что уже было встроено в Node до Worker Threads, будет работать намного лучше. И последнее — если вы посмотрите на свой API, который использует потоки, вы увидите, что вы запускаете новый поток для каждого запроса. Это не будет слишком эффективным в долгосрочной перспективе. Вместо этого вы должны иметь пул потоков и повторно использовать их. В противном случае затраты на создание новых потоков превзойдут их преимущества.

Только один поток — что касается параллелизма в Node.js…

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

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

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

В настоящее время я создаю рассылку новостей, чтобы делиться информативными статьями и новостями, посвященными искусственному интеллекту, стартапам 🚀, технологиям 📱 и программированию 💻! мой прогресс в становлении разработчиком программного обеспечения с небольшим опытом, и как вы можете учиться на моем опыте.

Пожалуйста, подпишитесь по этой ссылке.

Также опубликовано здесь.