Перевод: сумасшедший технический ботаник
оригинал:blog.log Rocket.com/ah-complete-…
Многие люди хотят знать об однопоточныхNode.jsКак он может конкурировать с многопоточным бэкендом. Учитывая его предположительно однопоточный характер, многим крупным компаниям может показаться нелогичным выбирать Node в качестве серверной части. Чтобы понять почему, нужно понять, что на самом деле означает быть однопоточным.
JavaScript предназначен для выполнения относительно простых задач в Интернете, таких как проверка форм или создание разноцветных следов мыши.В 2009 году Райан Даль, основатель Node.jsПозволяет разработчикам писать внутренний код на языке.
Бэкэнд-языки, обычно поддерживающие многопоточность, имеют различные механизмы синхронизации данных между потоками и другие функции, ориентированные на потоки. Добавление поддержки такой функции в JavaScript потребует изменения всего языка, что не является целью Даля. Чтобы чистый JavaScript поддерживал многопоточность, ему пришлось придумать обходной путь. Далее давайте исследовать тайны...
Как работает Node.js
Node.js использует два типа потоков:event loopосновной поток обработки иworker poolНесколько вспомогательных потоков в .
Цикл событий — это механизм, который принимает обратные вызовы (функции) и регистрирует их, готовые к выполнению в какой-то момент в будущем. Он выполняется в том же потоке, что и связанный с ним код JavaScript. Когда операция JavaScript блокирует поток, цикл событий также блокируется.
Рабочий пул — это модель выполнения, которая порождает и обрабатывает отдельные потоки, затем синхронно выполняет задачи и возвращает результаты в цикл обработки событий. Цикл событий выполняет предоставленный обратный вызов с возвращаемым результатом.
Короче говоря, он отвечает за асинхронные операции ввода-вывода — прежде всего за взаимодействие с системным диском и сетью. В основном состоит изfs
(интенсивный ввод-вывод) илиcrypto
(интенсивная нагрузка на ЦП) и т. д. Для рабочего бассейнаlibuvРеализация, когда Node необходимо внутренне взаимодействовать между JavaScript и C++, вызывает небольшую задержку, но едва заметную.
На основе этих двух механизмов мы можем написать следующий код:
fs.readFile(path.join(__dirname, './package.json'), (err, content) => {
if (err) {
return null;
}
console.log(content.toString());
});
упоминалось ранееfs
Модуль указывает рабочему пулу использовать один из потоков для чтения содержимого файла и уведомления цикла обработки событий, когда это будет сделано. Затем цикл событий принимает предоставленную функцию обратного вызова и выполняет ее с содержимым файла.
Выше приведен пример неблокирующего кода, в котором нам не нужно ждать, пока что-то произойдет синхронно. Просто скажите рабочему пулу прочитать файл и использовать результат для вызова предоставленной функции. Поскольку рабочий пул имеет собственный поток, цикл событий может продолжать выполняться в обычном режиме, пока файл читается.
Все это прекрасно работает, когда вам не нужно синхронно выполнять какую-то сложную операцию: любая функция, которая выполняется слишком долго, блокирует поток. Если у вас в приложении много подобных функций, вы можете значительно снизить пропускную способность сервера, а то и вовсе заморозить его. В этом случае работа не может быть делегирована рабочему пулу.
Node.js нельзя эффективно использовать, когда требуются сложные вычисления с данными (например, искусственный интеллект, машинное обучение или большие данные), потому что операции блокируют основной (и единственный) поток, делая сервер невосприимчивым. Так было до выпуска Node.js v10.5.0, в котором была добавлена поддержка многопоточности.
Введение:worker_threads
worker_threads
Модули позволяют нам создавать полнофункциональные многопоточные программы Node.js.
Рабочий поток — это фрагмент кода (обычно берется из файла), который порождается в отдельном потоке.
Обратите внимание, что терминthread worker,workerа такжеthreadЧасто используемые взаимозаменяемо, они оба относятся к одному и тому же.
Чтобы использовать рабочие потоки, вы должны импортироватьworker_threads
модуль. Давайте сначала напишем функцию, которая поможет нам создавать эти рабочие потоки, а затем обсудим их свойства.
type WorkerCallback = (err: any, result?: any) => any;
export function runWorker(path: string, cb: WorkerCallback, workerData: object | null = null) {
const worker = new Worker(path, { workerData });
worker.on('message', cb.bind(null, null));
worker.on('error', cb);
worker.on('exit', (exitCode) => {
if (exitCode === 0) {
return null;
}
return cb(new Error(`Worker has stopped with code ${exitCode}`));
});
return worker;
}
Чтобы создать работника, вы должны сначала создатьWorker
экземпляр класса. Его первый параметр указывает путь к файлу, содержащему рабочий код, второй параметр указывает файл с именемworkerData
Объект, содержащий одно свойство. Это данные, к которым мы хотим, чтобы поток имел доступ при запуске.
Обратите внимание: независимо от того, используете ли вы JavaScript или язык, который вы в конечном итоге конвертируете в JavaScript (например, TypeScript), путь всегда должен указывать на.js
или.mjs
файл расширения.
Я также хотел бы указать, почему метод обратного вызова используется вместо возврата при срабатывании.message
Обещание, которое будет разрешено, когда произойдет событие. Это потому, что работник может отправить многоmessage
событие, а не одно.
Как вы можете видеть в приведенном выше примере, связь между потоками основана на событиях, что означает, что мы настраиваем прослушиватели, которые вызывает рабочий процесс после отправки данного события.
Наиболее распространены следующие события:
worker.on('error', (error) => {});
Генерируется всякий раз, когда в рабочем потоке возникает неперехваченное исключение.error
мероприятие. Затем рабочий процесс завершается с ошибкой, доступной в качестве первого аргумента в предоставленном обратном вызове.
worker.on('exit', (exitCode) => {});
Испускается, когда рабочий выходитexit
мероприятие. Если призвали в рабочийprocess.exit()
,ТакexitCode
будет предоставлен обратный вызов. Если рабочий начинает сworker.terminate()
прекращено, код 1.
worker.on('online', () => {});
Генерируется всякий раз, когда рабочий процесс прекращает синтаксический анализ кода JavaScript и начинает выполнениеonline
мероприятие. Обычно он не используется, но может быть информативным в определенных ситуациях.
worker.on('message', (data) => {});
Генерируется всякий раз, когда рабочий отправляет данные в родительский потокmessage
мероприятие.
Теперь давайте посмотрим, как данные распределяются между потоками.
обмен данными между потоками
Чтобы отправить данные в другой поток, вы можете использоватьport.postMessage()
метод. Его прототип выглядит следующим образом:
port.postMessage(data[, transferList])
Объект порта может бытьparentPort
,так же может бытьMessagePort
пример — об этом позже.
параметр данных
Первый параметр - вызывается здесьdata
- это объект, который копируется в другой поток. Это может быть что угодно, поддерживаемое алгоритмом репликации.
данные поАлгоритм структурированного клонированиясделать копию. Цитата из Мозиллы:
Он рекурсивно клонирует входной объект, сохраняя карту ранее посещенных ссылок, чтобы избежать бесконечных циклов обхода.
Алгоритм не дублирует функции, ошибки, дескрипторы свойств или цепочки прототипов. Также важно отметить, что такое копирование объекта отличается от использования JSON, поскольку оно может содержать циклические ссылки и типизированные массивы, чего не может JSON.
Благодаря возможности копировать типизированные массивы алгоритм может разделять память между потоками.
Делить память между потоками
люди могут сказать что-то вродеcluster
илиchild_process
Такие модули давно начали использовать потоки. Это правильно и неправильно.
cluster
Модули могут создавать несколько экземпляров узла, при этом запросы маршрутизации основного процесса между ними. Кластеризация может эффективно увеличить пропускную способность сервера, но мы не можем использоватьcluster
Модуль порождает отдельный поток.
Люди склонны использовать такие инструменты, как PM2, для централизованного управления своими программами вместо того, чтобы делать это вручную в своем собственном коде. Если вам интересно, посмотрите, как использоватьcluster
модуль.
child_process
Модуль может генерировать любой исполняемый файл, независимо от того, написан он на JavaScript или нет. это иworker_threads
Очень похоже, но лишено нескольких важных особенностей последнего.
В частности, рабочие потоки более легкие и используют тот же идентификатор процесса, что и их родительский поток. Они также могут совместно использовать память с родительским потоком, что позволяет избежать сериализации больших загрузок данных, позволяя более эффективно передавать данные туда и обратно.
Теперь давайте посмотрим, как распределяется память между потоками. Чтобы поделиться памятью,ArrayBuffer
илиSharedArrayBuffer
Экземпляр передается в другой поток в качестве параметра данных.
Вот рабочий процесс, который разделяет память со своим родительским потоком:
import { parentPort } from 'worker_threads';
parentPort.on('message', () => {
const numberOfElements = 100;
const sharedBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * numberOfElements);
const arr = new Int32Array(sharedBuffer);
for (let i = 0; i < numberOfElements; i += 1) {
arr[i] = Math.round(Math.random() * 30);
}
parentPort.postMessage({ arr });
});
Во-первых, мы создаемSharedArrayBuffer
, его память должна содержать 100 32-битных целых чисел. Затем создайтеInt32Array
Например, он будет использовать буфер для хранения своей структуры, затем заполнит массив случайными числами и отправит его родительскому потоку.
В родительском потоке:
import path from 'path';
import { runWorker } from '../run-worker';
const worker = runWorker(path.join(__dirname, 'worker.js'), (err, { arr }) => {
if (err) {
return null;
}
arr[0] = 5;
});
worker.postMessage({});
Пучокarr [0]
Значение изменяется на5
, фактически изменит его в два потока.
Конечно, разделяя память, мы рискуем изменить значение в одном потоке, а также изменить его в другом потоке. Но мы также получаем преимущество в этом процессе: значение можно использовать в другом потоке без сериализации, что значительно повышает эффективность. Просто не забудьте управлять правильными ссылками на данные, чтобы они могли быть удалены сборщиком мусора, когда вы закончите их обработку.
Совместное использование массива целых чисел — это хорошо, но что нас действительно интересует, так это общий объект — способ хранения информации по умолчанию. К сожалению нетSharedObjectBuffer
или что-то в этом роде, но мы можемСоздайте аналогичную структуру самостоятельно.
параметр списка передачи
transferList
может содержать толькоArrayBuffer
а такжеMessagePort
. Как только они доставлены в другой поток, они не могут быть доставлены снова, потому что содержимое памяти уже перемещено в другой поток.
В настоящее время невозможно пройтиtransferList
(можно использоватьchild_process
module) для транспортировки сетевых сокетов.
Создайте каналы связи
Связь между потоками осуществляется через порт, которыйMessagePort
Экземпляры класса и обеспечивают связь на основе событий.
Существует два способа связи между потоками с использованием портов. Первое — это значение по умолчанию, которое является более простым методом. В рабочем коде мы начинаем сworker_threads
Модуль импортирует файл с именемparentPort
объект и использовать объект.postMessage()
метод для отправки сообщения в родительский поток.
Вот пример:
import { parentPort } from 'worker_threads';
const data = {
// ...
};
parentPort.postMessage(data);
parentPort
создается Node.js за кулисамиMessagePort
Экземпляр, используемый для связи с родительским потоком. так что вы можете использоватьparentPort
а такжеworker
Объекты взаимодействуют между потоками.
Второй способ взаимодействия между потоками — созданиеMessageChannel
и отправить работнику. Следующий код показывает, как создать новыйMessagePort
и поделитесь им с нашим работником:
import path from 'path';
import { Worker, MessageChannel } from 'worker_threads';
const worker = new Worker(path.join(__dirname, 'worker.js'));
const { port1, port2 } = new MessageChannel();
port1.on('message', (message) => {
console.log('message from worker:', message);
});
worker.postMessage({ port: port2 }, [port2]);
при созданииport1
а такжеport2
После этого мыport1
установить прослушиватель событий наport2
Отправлено рабочему. мы должны включить его вtransferList
Для того, чтобы передавать его рабочему.
Внутри рабочего:
import { parentPort, MessagePort } from 'worker_threads';
parentPort.on('message', (data) => {
const { port }: { port: MessagePort } = data;
port.postMessage('heres your message!');
});
Таким образом, мы можем использовать порт, отправленный родительским потоком.
использоватьparentPort
Не обязательно неправильный путь, но лучше использоватьMessageChannel
экземпляр создает новыйMessagePort
А потом поделитесь этим с порожденным работником.
Обратите внимание, что в следующих примерах для краткости я использовалparentPort
.
Два способа использования воркеров
Рабочих можно использовать двумя способами. Первый заключается в порождении рабочего процесса, выполнении его кода и отправке результата в родительский поток. При таком подходе работник должен создаваться заново каждый раз, когда появляется новая задача.
Второй способ — создать рабочего и создатьmessage
Слушатель настройки событий. каждый триггерmessage
, он и завершает работу, и отправляет результат обратно в родительский поток, который сохраняет рабочий поток для последующего использования.
В документации Node.js рекомендуется второй подход, поскольку для создания рабочего потока требуется создание виртуальной машины, а также синтаксический анализ и выполнение кода, что влечет за собой большие накладные расходы. Таким образом, этот метод более эффективен, чем постоянное создание новых рабочих.
Этот подход называется пулом рабочих, потому что мы создаем пул рабочих и заставляем их ждать, отправляя их по мере необходимости.message
события, необходимые для выполнения работы.
Вот пример порождения, выполнения и закрытия воркеров:
import { parentPort } from 'worker_threads';
const collection = [];
for (let i = 0; i < 10; i += 1) {
collection[i] = i;
}
parentPort.postMessage(collection);
Будуcollection
После отправки в родительский поток он завершается.
Вот пример работника, который может долго ждать, прежде чем ему дадут задание:
import { parentPort } from 'worker_threads';
parentPort.on('message', (data: any) => {
const result = doSomething(data);
parentPort.postMessage(result);
});
Важные свойства, доступные в модуле worker_threads
worker_threads
В модулях доступны некоторые свойства:
isMainThread
Когда не работает в рабочем потоке, свойствоtrue
. Если вы считаете это необходимым, включите простой в начало рабочего файлаif
оператор, чтобы убедиться, что он работает только как рабочий.
import { isMainThread } from 'worker_threads';
if (isMainThread) {
throw new Error('Its not a worker');
}
workerData
Данные для включения в конструктор рабочего процесса при порождении потока.
const worker = new Worker(path, { workerData });
В рабочем потоке:
import { workerData } from 'worker_threads';
console.log(workerData.property);
parentPort
упоминалось ранееMessagePort
Экземпляр, используемый для связи с родительским потоком.
threadId
Уникальный идентификатор, присвоенный работнику.
Теперь, когда мы знаем технические детали, давайте кое-что реализуем и проверим то, что узнали на практике.
выполнитьsetTimeout
setTimeout
Является ли бесконечный цикл, как следует из названия, используется для определения того, не истек ли тайм-аут программы. Он проверяет время начала заданного количества миллисекунд и меньше, чем фактическая дата в цикле.
import { parentPort, workerData } from 'worker_threads';
const time = Date.now();
while (true) {
if (time + workerData.time <= Date.now()) {
parentPort.postMessage({});
break;
}
}
Эта конкретная реализация порождает поток и выполняет его код и, наконец, завершает работу после завершения.
Затем реализуйте код, который использует этот рабочий процесс. Сначала создайте состояние, которое будет использоваться для отслеживания порожденных воркеров:
const timeoutState: { [key: string]: Worker } = {};
Затем функция, отвечающая за создание воркера и сохранение его в состояние:
export function setTimeout(callback: (err: any) => any, time: number) {
const id = uuidv4();
const worker = runWorker(
path.join(__dirname, './timeout-worker.js'),
(err) => {
if (!timeoutState[id]) {
return null;
}
timeoutState[id] = null;
if (err) {
return callback(err);
}
callback(null);
},
{
time,
},
);
timeoutState[id] = worker;
return id;
}
Сначала мы используем пакет UUID для создания уникального идентификатора для воркера, затем используем ранее определенную функциюrunWorker
чтобы получить рабочих. Мы также передаем функцию обратного вызова воркеру, которая будет запущена, как только воркер отправит данные. Наконец, сохраните работника в состоянии и вернитеid
.
В функции обратного вызова мы должны проверить, существует ли рабочий процесс в состоянии, потому что может бытьcancelTimeout()
, который удалит его. Если он существует, удалите его из состояния и вызовитеsetTimeout
функциональныйcallback
.
cancelTimeout
использование функции.terminate()
метод заставляет работника выйти и удаляет работника из состояния:
export function cancelTimeout(id: string) {
if (timeoutState[id]) {
timeoutState[id].terminate();
timeoutState[id] = undefined;
return true;
}
return false;
}
Если вам интересно, я также реализовалsetInterval
, код вздесь, но так как он ничего не делает с потоком (мы повторно используемsetTimeout
код), поэтому я решил не объяснять это здесь.
Я создал короткий тестовый код, чтобы проверить, чем этот подход отличается от нативного. Ты сможешьнайти код здесь. Вот результаты:
native setTimeout { ms: 7004, averageCPUCost: 0.1416 }
worker setTimeout { ms: 7046, averageCPUCost: 0.308 }
Мы видим, чтоsetTimeout
Есть небольшая задержка - примерно 40мс - создается потребление Worker. Средняя стоимость ЦП также немного высока, но не является невыносимой (стоимость ЦП — это среднее значение использования ЦП в течение всего процесса).
Если мы сможем повторно использовать воркеры, мы сможем уменьшить задержку и использование ЦП, поэтому мы реализуем пулы воркеров.
Внедрить рабочий пул
Как упоминалось выше, пул воркеров — это заданное количество предварительно созданных воркеров, которые остаются бездействующими и прослушиваютmessage
мероприятие. однаждыmessage
События запускаются, они начинают работать и возвращают результаты.
Чтобы лучше описать, что мы собираемся делать, давайте создадим пул из восьми рабочих потоков:
const pool = new WorkerPool(path.join(__dirname, './test-worker.js'), 8);
если вы знакомы сОграничение одновременных операций, то логика, которую вы видите здесь, почти такая же, просто другой вариант использования.
Как показано во фрагменте кода выше, мы передаем путь к воркеру и количество воркеров для создания.WorkerPool
конструктор.
export class WorkerPool<T, N> {
private queue: QueueItem<T, N>[] = [];
private workersById: { [key: number]: Worker } = {};
private activeWorkersById: { [key: number]: boolean } = {};
public constructor(public workerPath: string, public numberOfThreads: number) {
this.init();
}
}
Вот некоторые другие свойства, такие какworkersById
а такжеactiveWorkersById
, мы можем сохранить ID существующего воркера и текущего воркера отдельно. а такжеqueue
, мы можем использовать следующую структуру для сохранения объекта:
type QueueCallback<N> = (err: any, result?: N) => void;
interface QueueItem<T, N> {
callback: QueueCallback<N>;
getData: () => T;
}
callback
Просто обратный вызов узла по умолчанию, первый параметр — это ошибка, а второй параметр — возможные результаты.getData
передается в рабочий пул.run()
Функция метода (описанного ниже), которая будет вызываться после начала обработки элемента.getData
Данные, возвращаемые функцией, будут переданы рабочему потоку.
существует.init()
мы создаем воркеры и сохраняем их в следующем состоянии:
private init() {
if (this.numberOfThreads < 1) {
return null;
}
for (let i = 0; i < this.numberOfThreads; i += 1) {
const worker = new Worker(this.workerPath);
this.workersById[i] = worker;
this.activeWorkersById[i] = false;
}
}
Чтобы избежать бесконечных циклов, мы сначала убеждаемся, что количество потоков > 1. Затем создайте допустимое количество рабочих и сохраните их индекс вworkersById
условие. мы вactiveWorkersById
Состояние содержит информацию о том, запущены ли они в данный момент или нет, что по умолчанию всегда ложно.
Теперь мы должны реализовать ранее упомянутое.run()
метод для установки задач, доступных для работника.
public run(getData: () => T) {
return new Promise<N>((resolve, reject) => {
const availableWorkerId = this.getInactiveWorkerId();
const queueItem: QueueItem<T, N> = {
getData,
callback: (error, result) => {
if (error) {
return reject(error);
}
return resolve(result);
},
};
if (availableWorkerId === -1) {
this.queue.push(queueItem);
return null;
}
this.runWorker(availableWorkerId, queueItem);
});
}
В функции обещания мы сначала вызываем.getInactiveWorkerId()
Чтобы проверить, есть ли незанятые рабочие процессы, доступные для обработки данных:
private getInactiveWorkerId(): number {
for (let i = 0; i < this.numberOfThreads; i += 1) {
if (!this.activeWorkersById[i]) {
return i;
}
}
return -1;
}
Далее мы создаемqueueItem
, в котором сохранить переданный.run()
методgetData
функции и обратные вызовы. В обратном вызове мы либоresolve
илиreject
обещание, это зависит от того, передал ли работник ошибку обратному вызову или нет.
еслиavailableWorkerId
Значение равно -1, что означает, что в настоящее время нет доступных рабочих, мы будемqueueItem
добавить вqueue
. Если есть свободные рабочие, звоните.runWorker()
метод выполнения работника.
существует.runWorker()
метод, мы должны поставить текущий рабочийactiveWorkersById
установить для использования; дляmessage
а такжеerror
Событие устанавливает прослушиватели событий (и очищает их после); наконец, отправляет данные рабочему процессу.
private async runWorker(workerId: number, queueItem: QueueItem<T, N>) {
const worker = this.workersById[workerId];
this.activeWorkersById[workerId] = true;
const messageCallback = (result: N) => {
queueItem.callback(null, result);
cleanUp();
};
const errorCallback = (error: any) => {
queueItem.callback(error);
cleanUp();
};
const cleanUp = () => {
worker.removeAllListeners('message');
worker.removeAllListeners('error');
this.activeWorkersById[workerId] = false;
if (!this.queue.length) {
return null;
}
this.runWorker(workerId, this.queue.shift());
};
worker.once('message', messageCallback);
worker.once('error', errorCallback);
worker.postMessage(await queueItem.getData());
}
Во-первых, с помощью переданногоworkerId
, мы начинаем сworkersById
Получите ссылку на работника в формате . Затем вactiveWorkersById
в, будет[workerId]
Свойство имеет значение true, чтобы мы знали, что, пока рабочий процесс занят, другие задачи не выполняются.
Далее создайтеmessageCallback
а такжеerrorCallback
Используется для вызова сообщений и событий ошибок, затем регистрирует функцию для прослушивания событий и отправки данных рабочим процессам.
В обратном вызове мы вызываемqueueItem
обратный звонок, затем звонокcleanUp
функция. существуетcleanUp
функцию, чтобы удалить прослушиватель событий, потому что мы будем повторно использовать один и тот же рабочий процесс несколько раз. Если прослушиватель не удалить, произойдет утечка памяти, и память будет медленно исчерпана.
существуетactiveWorkersById
Государство, мы будем[workerId]
свойство установлено наfalse
и проверьте, не пуста ли очередь. Если нет, то изqueue
удалить первый элемент и заменить его другимqueueItem
Позовите работника еще раз.
Затем создайтеmessage
Рабочий, который выполняет некоторые вычисления после данных в событии:
import { isMainThread, parentPort } from 'worker_threads';
if (isMainThread) {
throw new Error('Its not a worker');
}
const doCalcs = (data: any) => {
const collection = [];
for (let i = 0; i < 1000000; i += 1) {
collection[i] = Math.round(Math.random() * 100000);
}
return collection.sort((a, b) => {
if (a > b) {
return 1;
}
return -1;
});
};
parentPort.on('message', (data: any) => {
const result = doCalcs(data);
parentPort.postMessage(result);
});
Рабочий создает массив из 1 миллиона случайных чисел, а затем сортирует их. Неважно, что вы делаете, главное, чтобы это заняло немного больше времени.
Ниже приведен пример простого использования рабочего пула:
const pool = new WorkerPool<{ i: number }, number>(path.join(__dirname, './test-worker.js'), 8);
const items = [...new Array(100)].fill(null);
Promise.all(
items.map(async (_, i) => {
await pool.run(() => ({ i }));
console.log('finished', i);
}),
).then(() => {
console.log('finished all');
});
Сначала создайте рабочий пул, состоящий из восьми рабочих. Затем создаем массив из 100 элементов, для каждого элемента запускаем задачу в worker pool. Восемь задач выполняются сразу после запуска, а остальные задачи ставятся в очередь и выполняются одна за другой. Используя пул рабочих процессов, нам не нужно каждый раз создавать одного рабочего, что значительно повышает эффективность.
В заключение
worker_threads
Предоставляет простой способ добавить в программу поддержку многопоточности. Делегируя тяжелые вычисления ЦП другим потокам, можно значительно повысить пропускную способность сервера. Благодаря официальной поддержке потоков мы можем ожидать, что больше разработчиков и инженеров из таких областей, как ИИ, машинное обучение и большие данные, будут использовать Node.js.