Web Workers в JavaScript: Параллельные вычисления и улучшение производительности

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

Есть случаи, когда эту проблему можно решить с помощью Web Workers, про них я и расскажу вам далее!

Что такое Web Workers?

Web Workers — предоставляют простое средство для запуска скриптов в фоновом потоке. Поток Worker’а может выполнять задачи без вмешательства в пользовательский интерфейс.

Имеет доступ к Navigator, XMLHttpRequest, Array,  Date,  Math, and String, setTimeout (), setInterval ().

Имеет следующие ограничения, отсутсвие доступа к DOM, вместо window — глобальный объект self, отсутствует доступ к cookies/localStorage/sessionStorage, также недоступны часть браузерных API, например доступ к камере/микрофону. Также у них есть ограничения по ресурсам от самого браузера.

Также Web Workers имеют свой собственный event loop, но он функционирует немного по-другому, в отличии от главного потока.

В Web Workers существует единственный поток выполнения, который используется для обработки всех задач, включая события, сообщения и выполнение кода — WorkerGlobalScope. Он работает в асинхронном режиме и выполняет код в ответ на сообщения и события.

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

Я расскажу про Worker двух типов:

Dedicated Worker — worker, который создает отдельный и изолированный контекст выполнения, работающий параллельно с основным потоком в приложения. В основном использует для выполнения вычислений, обработки данных и задач, которые требуют много времени, не блокирующих основной поток.

Shared Worker — worker, который создает общий контекст выполнения, доступный для нескольких окон, вкладок или фреймов приложения. В основном используется для выполнения кода в фоновом режиме и обеспечивает общий доступ к данным и состоянию между разными частями приложения, что делает его особенно полезным в сценариях, где несколько пользователей или компонентов должны совместно использовать данные и взаимодействовать друг с другом.

Как работать с Dedicated Workers?

Все взаимодействие происходит с помощью функции postMessage() и listener onmessage, далее мы подробно их рассмотрим. Общий workflow выглядит следующим образом:

  1. Инициализируем Dedicated Worker с помощью конструктора

  2. Делаем postMessage из Main Thread

  3. Срабатывает listener в Worker Thread

  4. Worker выполняет логику, которую вы написали

  5. Worker с помощью postMessage отправляет событие обратно в Main Thread

  6. Срабатываем listener в Main Thread

Схема работы Main Thread -> Worker» /></p>

<p>Схема работы Main Thread → Worker</p>

<p>Мы можем создавать сколько угодно потоков (при этом, каждый из них будет иметь разный контекст), главное чтобы хватило ресурсов ПК и мы не уперлись в ограничения браузера.</p>

<blockquote><p><strong>Инициализация Dedicated Worker</strong></p></blockquote>

<p>Для инициализации инстанса Worker’a, прокидываем в конструктор путь до файла нашего Worker файла</p>

<pre><code class=// new Worker('Путь до worker файла, относительно текущего файла') const worker = new Worker('worker.js');

Worker мы получили, далее рассмотрим основные функции для обмена данными между потоками

postMessage(message: any, transfer: Transferable[]): void — метод для отправки сообщения из одного поток в другой.

  • message — любое значение или объект, который может быть обработан алгоритмом структурного клонирования, если коротко, то этот алгоритм продвинутее чем JSON серилизатор, например он может клонировать — Blob, File, ImageData, Buffers, может восстанавливать циклические ссылки, но не умеет в клонирование свойств и прототипов и не работает с Error, Function, DOM Elements.

  • transfer — массив объектов (объекты могут быть только ArrayBuffer | MessagePort | ImageBitmap), которые перенесутся в контекст worker и больше не будут доступны в изначальном потоке, это может помочь при копировании большого объема данных, чтобы не потерять в производительности и памяти.

// Тут мы передаем buffer в контекст worker, в этом скрипте он больше не будет доступен 
const buffer = new ArrayBuffer(42);
const data = { text: 'Hello, World!', buffer };
worker.postMessage(data, [buffer]);

onmessage: ((this: Worker, event: MessageEvent) => any) | null — слушатель отправки message.

interface MessageEvent extends Event {
    // Переданные данные
    readonly data: T;
    // Последний идентификатор события (event ID) в случае событий, связанных с сервером
    readonly lastEventId: string;
    // Origin сообщения, используется, при работе с событиями связанными cross-document messaging, и позволяет определить источник отправителя сообщения.
    readonly origin: string;
    // Порты, по сути, открытые нами страницы, используются для обмена данными и сообщениями между веб-воркерами и основными потоками.
    readonly ports: ReadonlyArray;
    // Предоставляет информацию об отправителе сообщения, такую как, например, какое окно отправило событие
    readonly source: MessageEventSource | null;
}

Пример использования

Покажу пример использования Dedicated Worker, на примере работы с изображением (пример максимально абстрактный, без конкретных реализаций, но демонстрирует некоторые возможности).

Допустим вы пишите какой-то подобие google docs и хотите сжимать картинку, если она больше определенного размера и при этом не блокировать основной поток.

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

index.js

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

const imageSelect = document.getElementById('image-select');

imageSelect.addEventListener('change', function(event) {
  const selectedImage = event.target.files[0];
  imageProcessingWorker.postMessage(selectedImage);
});

imageProcessingWorker.onmessage = function(event) {
  const processedImage = event.data;
  const imageContainer = document.getElementById('image-container');
  imageContainer.appendChild(processedImage);
};

worker.js

self.onmessage = function(event) {
  const image = event.data;
  // Функция, которая производит какие-то преобразования с картинкой, например сжатие
  const processedImage = processImage(image)
  
  self.postMessage(processedImage);
};

Убийство Dedicated Worker

  1. Когда Dedicated Worker вам больше не нужен его можно убить с помощью worker.terminate().

  2. Dedicated Worker сам уничтожиться, когда вы закроете вкладку с ним.

Use Cases

  • Обработка видео/аудио/картинок — ресайз, наложение фильтров и кодирование/декодирование медиаданных и тд.

  • Загрузка, обработка и сохранение больших файлов.

  • 3D-графика и различные анимации анимация.

  • Маппинг больших данных, например списков/различные сортировки и тд.

Можно самому ради интереса поискать Workers на сайтах, например, с помощью devtools: sources → threads → smth with workers.js

Как работать с Shared Workers?

Shared Worker работает похожим образом с Dedicated Worker, однако тут все взаимодействие проходит через port: MessagePort, и соответсвенно из-за этого у нас появляется listener onconnect в файле Worker’a. Общий workflow выглядит следующим образом:

  1. Инициализируем Shared Worker с помощью конструктора в наших файлах (в этом примере их 2)

  2. Получаем port нашего Shared Worker’a

  3. Делаем port.postMessage() из Main Threads

  4. Устанавливаем connect с Main Threads из Worker Thread, с помощью onconnect

  5. Получаем port из event’a, который прилетел нам на подключении, я пока рассматриваю случай, когда у меня 1 порт — const port = event.ports[0];, если у вас будет больше выбирайте соответсвующий (порты создаются следующим образом — тык)

  6. Worker с помощью port.postMessage отправляет событие обратно в Main Thread’s всем портам на которых висит port.onmessage

  7. Срабатывает listeners в Main Thread’s

Схема работы Shared Worker с двумя страницами index1.html и index2.html

Схема работы Shared Worker с двумя страницами index1.html и index2.html

Мы можем создавать сколько угодно потоков (при этом если мы создаем их из одного файла Worker’a, они будут иметь одинаковый контекст), главное чтобы хватило ресурсов ПК и мы не уперлись в ограничения браузера.

Инициализация Shared Worker

// new Worker('Путь до worker файла, относительно текущего файла')
const worker = new SharedWorker('worker.js');
// тут у нас в worker есть объект port он используется для управления Shared Worker

SharedWorker имеет такие же сигнатуры функции для postMessage и listener onmessage, а также onconnect имеет такую же сигантуру как onmessage

postMessage(message: any, transfer: Transferable[]): void
onmessage: ((this: Worker, event: MessageEvent) => any) | null
onconnect: ((this: Worker, event: MessageEvent) => any) | null

Пример использования

Покажу пример использования Shared Worker, сделаем формочку где можно будет вбить сообщение и оно появится на странице и с помощью нашего Worker’a отобразим его сразу на двух страницах (index1.html, index2.html). Откройте обе странички, чтобы заценить.

Инициализируем наших Shared Worker’s в index1.html, index2.html, где

index1.html

  1. Инициализируем Shared Worker и берем его port

  2. При клике на кнопку Send отправляем в Shared Worker message, с помощью port.postMessage()

  3. Создаем handleronmessage, в нем добавляем новую строку в наш контейнер с сообщениями

index2.html

  1. Инициализируем Shared Worker берем его port

  2. Создаем handler onmessage, в нем добавляем новую строку в наш контейнер с сообщениями (тут не делаем нашей формочки, тут будет только список сообщений)

worker.js

  1. Создаем массив куда сложим все наши порты — ports(эти порты нам нужны, чтобы отправить сообщение сразу во все вкладки/iframe где используется наш Worker и отобразить там новое сообщение)

  2. Создаем handler onmessage, и отправляем сообщение на все наши ports

  3. Вуаля, получаем на обоих страницах одинаковые messages

index1.html



  
    Shared Worker 1
  
  
    

index2.html



  
    Shared Worker 2
  
  
    

worker.js

const ports = [];

self.onconnect = (event) => {
  // Достаем порт с которого подключились и сохраняем его, чтобы потом отправить ему сообщение
  const port = event.ports[0];
  ports.push(port);

  port.onmessage = (e) => {
    const message = e.data;
    for (const client of ports) {
      client.postMessage(`Message: ${message}`);
    }
  };
};

Убийство Shared Worker

  1. С помощью worker.close()

  2. Когда закрыли все вкладки на которых был использован этот Shared Worker

Use Cases

  • Все что связано с обменом данными между вкладками и окнами приложения.

  • Управление общими ресурсами.

  • Все тоже самое что и у Dedicated Workers

Итого

Конец очень близок

Конец очень близок

Shared Workers и Dedicated Workers — мощные инструменты для улучшения производительности приложений. Shared Workers подходят для обмена данными и управления общими ресурсами между разными частями приложения, в то время как Dedicated Workers помогает обеспечить изоляцию кода и ускоряют выполнение вычислений. Выбор между ними зависит от конкретных задач вашего проекта. Пробуйте!

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

© Habrahabr.ru