8 минут чтения

Приложения JavaScript выполняются в одном потоке обработки: программа может выполнять одну операцию за раз. Проще говоря, сервер с 16-ядерным процессором выполняет код на одном ядре, пока 15 простаивают.

Отдельные потоки позволяют избежать сложных ситуаций параллелизма. Что произойдет, если:

  • один поток браузера добавил содержимое в узел DOM, а другой удалил этот элемент?
  • два или более потока браузера пытались перенаправить на разные URL-адреса?
  • два или более потока сервера пытались одновременно обновить одну и ту же глобальную переменную?

Такие языки, как PHP и Python, также используют один поток. Многопоточный веб-сервер запускает новый экземпляр интерпретатора для каждого запроса, поэтому ответы возвращаются параллельно.

Запуск потоков требует больших ресурсов, поэтому приложения Node.js предоставляют собственный веб-сервер. По умолчанию это выполняется в одном потоке, но не обязательно вызывает проблемы с производительностью, поскольку JavaScript является асинхронным:

  • Браузерному JavaScript не нужно ждать, пока пользователь нажмет кнопку — браузер вызывает событие, которое вызывает функцию, когда происходит щелчок.
  • Браузерному JavaScript не нужно ждать ответа на Fetch запрос — браузер вызывает событие, которое вызывает функцию, когда сервер возвращает данные.
  • Node.js JavaScript не нужно ждать результата запроса к базе данных — среда выполнения вызывает событие, которое вызывает функцию, когда данные возвращаются.

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

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

Долгоиграющий JavaScript

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

  • В браузере пользователь не сможет взаимодействовать со страницей. Они не могут нажимать, прокручивать или печатать и могут столкнуться с ошибкой "не отвечающий скрипт".
  • Серверное приложение Node.js, Deno или Bun не может отвечать на другие запросы во время выполнения функции. Один пользователь, запускающий 10-секундный расчет, подвергается 10-секундному ожиданию для всех остальных пользователей, независимо от их запроса.

До 2012 года разработчики JavaScript решали эту проблему, разбивая вычисления на более мелкие подзадачи, разделенные небольшой задержкой setTimeout. Это разблокировало цикл событий, но не всегда было практично, и один поток по-прежнему использовался, даже когда ЦП мог делать больше. Современный JavaScript предлагает многопоточность через Web Workers.

Веб-воркеры

Все браузеры, Node.js, Deno и Bun поддерживают Web Workers. Они используют аналогичный синтаксис, хотя среда выполнения сервера может предоставлять дополнительные параметры.

Web Worker — это скрипт, который работает как фоновый поток с собственным экземпляром механизма и циклом обработки событий. Он выполняется параллельно основному потоку выполнения и не блокирует цикл обработки событий.

Основной поток — или сам воркер — может запускать любое количество воркеров. Чтобы создать рабочий скрипт:

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

Рабочие лучше всего использовать для задач, интенсивно использующих ЦП на основе JavaScript. Они не приносят пользы от интенсивной работы ввода-вывода, поскольку она перекладывается на ОС и выполняется асинхронно.

Ограничения веб-воркеров на стороне клиента

Рабочий процесс не может получить доступ к данным в других потоках, если они не переданы ему явно. Копия данных передается с использованием алгоритма структурированного клонирования JavaScript, поэтому она может включать нативные типы, такие как строки, числа, логические значения, массивы и объекты, но НЕ функции или узлы DOM.

Обработчики браузера могут использовать такие API, как console, Fetch, XMLHttpRequest, WebSocket и IndexDB. Они не могут получить доступ к объекту document, узлам DOM, localStorage и некоторым частям объекта window, так как это может привести к проблемам параллелизма, как описано выше.

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

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

Ограничения веб-воркеров на стороне сервера

Работники сервера также работают изолированно и получают копии данных так же, как и в браузере. Рабочие потоки в Node.js, Deno и Bun имеют меньше ограничений API, чем рабочие потоки браузера, потому что в них нет DOM. Вы можете столкнуться с проблемами, если два или более исполнителей одновременно записывают данные в один и тот же файл, но это маловероятно.

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

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

Использование клиентских веб-воркеров

Ваш основной скрипт должен определить Объект Worker с именем файла рабочего скрипта (относительно HTML-файла):

// main.js: run on worker thread
const worker = new Worker('./worker.js');

Запустите работника, используя его метод postMessage() для отправки данных, таких как объект:

// main.js: post data to worker
worker.postMessage({ a: 1, b: 2, c: 3 });

Это запускает функцию обработчика событий onmessage в рабочем скрипте. Он получает входящие данные ( e.data ), обрабатывает их и запускает метод postMessage() для возврата данных обратно в основной скрипт перед завершением:

// worker.js
onmessage = function(e) {

  console.log(e.data); // { a: 1, b: 2, c: 3 }

  const result = runSomeProcess(e.data);

  // return to main thread
  postMessage(result);

};

Это запускает функцию обработчика событий onmessage в основном скрипте. Он получает входящие данные ( e.data) и может обрабатывать или выводить их по мере необходимости.

// main.js: receive data from worker
worker.onmessage = function(e) {
  console.log(e.data); // result
};

Основной скрипт может в любой момент остановить воркер, вызвав его метод .terminate(). Он также может объявлять следующие обработчики событий:

  • onmessageerror — срабатывает, когда рабочий получает данные, которые он не может десериализовать, и
  • onerror — срабатывает, когда в рабочем скрипте возникает ошибка JavaScript. Возвращенный объект события предоставляет сведения об ошибке в свойствах .filename, .lineno и .message.

Демонстрация воркера на стороне клиента

Посмотреть демонстрацию веб-воркеров на стороне клиента

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

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

Включение других скриптов в клиентский worker

Веб-воркеры браузера не могут быть модулями ES (использующими синтаксис export и import). Вы должны использовать метод importScripts() для синхронного импорта других скриптов в область действия воркера:

// worker.js
importScripts('./library.js');

Браузеры на основе Chrome поддерживают рабочие модули ES, если в конструкторе используется второй аргумент { type: "module" }:

const worker = new Worker('./src/worker.js', { type: 'module' });ja

Затем вы можете import библиотеки в воркере:

// worker.js
import { functionX } from './library.js';

Эта функция пока недоступна в Firefox или Safari. До тех пор, пока не будет доступна более широкая поддержка, вы можете использовать сборщик JavaScript, такой как esbuild или Rollup.js, для разрешения импорта во время сборки и упаковки всего рабочего кода в один файл.

Повтор сеанса для разработчиков

Раскройте разочарования, выявите ошибки и устраните замедления работы, как никогда раньше, с помощью OpenReplay — набора для воспроизведения сеансов с открытым исходным кодом для разработчиков. Его можно разместить самостоятельно за несколько минут, что дает вам полный контроль над данными клиентов.

Удачной отладки! Попробуйте использовать OpenReplay сегодня.

Использование серверных веб-воркеров

Среды выполнения JavaScript на стороне сервера также поддерживают рабочие процессы:

  • Node.js реализовал рабочие API в версии 10.
  • Deno копирует Web Worker API, поэтому синтаксис идентичен коду браузера. Он также предлагает режим совместимости, который заполняет API-интерфейсы Node.js, если вы хотите использовать синтаксис рабочего потока этой среды выполнения.
  • Bun находится в стадии бета-тестирования, хотя он будет поддерживать рабочие API браузера и Node.js.
  • Бессерверные функции, такие как AWS Lambda, функции Azure, функции Google Cloud, рабочие процессы Cloudflare и пограничные функции Netlify, могут предоставлять API-интерфейсы, подобные веб-работникам. Преимуществ может быть меньше, потому что запрос пользователя часто запускает отдельный изолированный экземпляр.

Чтобы использовать воркеры в Node.js, основной скрипт должен определить Объект воркера с именем воркера относительно корня проекта. Второй параметр конструктора определяет объект со свойством workerData, содержащим данные, которые вы хотите отправить:

const worker = new Worker('./worker.js', {
  workerData: { a: 1, b: 2, c: 3 }
});

В отличие от воркеров браузера, воркер запускается, и нет необходимости запускать worker.postMessage(). Вы можете вызвать этот метод, если это необходимо, и отправить дополнительные данные позже — он запускает обработчик события parentPort.on('message') воркера:

// worker message handler
parentPort.on('message', e => {
  console.log(e);
});

Как только рабочий процесс завершает обработку, он отправляет полученные данные обратно в основной поток, используя следующее:

// worker.js: return result to main
parentPort.postMessage( result );

Это вызывает событие message в основном скрипте, который получает результат:

worker.on('exit', code => {
  //... clean up
});

Рабочий процесс завершается после отправки сообщения. Это также вызывает событие exit, если вы хотите запустить очистку или другие функции:

Также поддерживаются другие обработчики событий:

  • messageerror — срабатывает, когда рабочий процесс получает данные, которые он не может десериализовать.
  • online — срабатывает, когда рабочий поток начинает выполняться, и
  • error — срабатывает, когда в рабочем скрипте возникает ошибка JavaScript.

Встроенные рабочие скрипты на стороне сервера

Один файл сценария Node.js может содержать как основной, так и рабочий код. Сценарий должен проверить, работает ли он в основном потоке, используя isMainThread, а затем вызвать себя как рабочий процесс (возможно, используя import.meta.url в качестве ссылки на файл в модуле ES или __filename в CommonJS):

import { Worker, isMainThread, workerData, parentPort } from "node:worker_threads";

if (isMainThread) {

  // main thread
  // create a worker from this script
  const worker = new Worker(import.meta.url, {
    workerData: { a: 1, b: 2, c: 3 }
  });

  worker.on('message', msg => {});
  worker.on('exit', code => {});

}
else {

  // worker thread
  const result = runSomeProcess( workerData );
  parentPort.postMessage(result);

}

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

Обмен данными между потоками

Связь между основным и рабочим потоками влечет за собой сериализацию данных. Можно обмениваться данными между потоками, используя объект SharedArrayBuffer, представляющий необработанные двоичные данные фиксированной длины. Следующий основной поток определяет 100 числовых элементов от 0 до 99, которые он отправляет рабочему процессу:

// main.js
import { Worker } from "node:worker_threads";

const
  buffer = new SharedArrayBuffer(100 * Int32Array.BYTES_PER_ELEMENT),
  value = new Int32Array(buffer);

value.forEach((v,i) => value[i] = i);

const worker = new Worker('./worker.js');

worker.postMessage({ value });

Рабочий может получить объект value:

// worker.js
import { parentPort } from 'node:worker_threads';

parentPort.on('message', value => {
  value[0] = 100;
});

Либо основной, либо рабочий потоки могут изменять элементы в массиве value, и они будут обновляться с обеих сторон. Это может улучшить производительность, но есть и недостатки:

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

дочерние процессы Node.js

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

Рабочих лучше всего использовать при выполнении сложных функций JavaScript — возможно, в рамках одного проекта. Дочерние процессы становятся необходимыми при запуске другого приложения, такого как команда Linux или Python.

Кластеризация Node.js

Кластеры Node.js позволяют разветвлять одинаковые процессы для более эффективной обработки нагрузок. Первоначальный первичный процесс может разветвляться — возможно, по одному разу для каждого ЦП, возвращаемого os.cpus(). Он также может обрабатывать перезапуски в случае сбоя экземпляра и посредничать сообщениям связи между разветвленными процессами.

Стандартная библиотека cluster предлагает свойства и методы, в том числе:

  • .isPrimary: возвращает true для основного первичного процесса.
  • .fork(): порождает дочерний рабочий процесс
  • .isWorker: возвращает true для рабочих процессов.

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

// app.js
import cluster from 'node:cluster';
import process from 'node:process';
import { cpus } from 'node:os';
import http from 'node:http';

const cpus = cpus().length;

if (cluster.isPrimary) {

  console.log(`Started primary process: ${ process.pid }`);

  // fork workers
  for (let i = 0; i < cpus; i++) {
    cluster.fork();
  }

  // worker failure event
  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${ worker.process.pid } failed`);
    cluster.fork();
  });

}
else {

  // start HTTP server on worker
  http.createServer((req, res) => {

    res.writeHead(200);
    res.end('Hello!');

  }).listen(8080);

  console.log(`Started worker process:  ${ process.pid }`);

}

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

$ node app.js
Started primary process: 1000
Started worker process:  2000
Started worker process:  3000
Started worker process:  4000
Started worker process:  5000

worker 3000 failed
Started worker process:  6000

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

Менеджеры процессов

Диспетчер процессов может запускать несколько экземпляров одного приложения Node.js без необходимости написания кода кластера. Самый популярный вариант Node.js — PM2, который может запускать экземпляр вашего приложения на каждом процессоре/ядре и перезапускать любой из них в случае сбоя:

pm2 start ./app.js -i max

Экземпляры приложений запускаются в фоновом режиме, поэтому он идеально подходит для развернутых серверов. Вы можете проверить, какие процессы запущены, введя pm2 status :

$ pm2 status

┌────┬──────┬───────────┬─────────┬─────────┬──────┬────────┐
│ id │ name │ namespace │ version │ mode    │ pid  │ uptime │
├────┼──────┼───────────┼─────────┼─────────┼──────┼────────┤
│ 1  │ app  │ default   │ 1.0.0   │ cluster │ 1000 │ 4D     │
│ 2  │ app  │ default   │ 1.0.0   │ cluster │ 2000 │ 4D     │
└────┴──────┴───────────┴─────────┴─────────┴──────┴────────┘

PM2 также может запускать приложения, отличные от Node.js, написанные на других языках.

Системы управления контейнерами

Кластеры и диспетчеры процессов запускают приложение на определенном устройстве. Если ваш сервер или ОС выйдет из строя, ваше приложение выйдет из строя независимо от количества экземпляров.

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

Хорошо известные контейнерные решения включают Docker и Kubernetes, которые могут запускать и отслеживать любое количество контейнеров на любом количестве устройств, даже в разных местах.

Заключение

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

Использовать работников просто, но помните:

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

Больше информации:

Первоначально опубликовано на https://blog.openreplay.com.