Web Workers в JavaScript: Параллельные вычисления и улучшение производительности
- понедельник, 16 октября 2023 г. в 00:00:39
В современном мире пользователи становятся все более требовательными к производительности веб‑сайтов и хороший пользовательский опыт выходит на первый план. Даже малейшее зависание или отсутствие плавности могут привести к потере пользователей.
Есть случаи, когда эту проблему можно решить с помощью 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, который создает общий контекст выполнения, доступный для нескольких окон, вкладок или фреймов приложения. В основном используется для выполнения кода в фоновом режиме и обеспечивает общий доступ к данным и состоянию между разными частями приложения, что делает его особенно полезным в сценариях, где несколько пользователей или компонентов должны совместно использовать данные и взаимодействовать друг с другом.
Все взаимодействие происходит с помощью функции postMessage()
и listener onmessage
, далее мы подробно их рассмотрим. Общий workflow выглядит следующим образом:
Инициализируем Dedicated Worker с помощью конструктора
Делаем postMessage
из Main Thread
Срабатывает listener в Worker Thread
Worker выполняет логику, которую вы написали
Worker с помощью postMessage
отправляет событие обратно в Main Thread
Срабатываем listener в Main Thread
Мы можем создавать сколько угодно потоков(при этом, каждый из них будет иметь разный контекст), главное чтобы хватило ресурсов ПК и мы не уперлись в ограничения браузера.
Инициализация Dedicated Worker
Для инициализации инстанса Worker'a, прокидываем в конструктор путь до файла нашего Worker файла
// 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.
event — объект события полученный от worker/main thread
interface MessageEvent<T = any> extends Event {
// Переданные данные
readonly data: T;
// Последний идентификатор события (event ID) в случае событий, связанных с сервером
readonly lastEventId: string;
// Origin сообщения, используется, при работе с событиями связанными cross-document messaging, и позволяет определить источник отправителя сообщения.
readonly origin: string;
// Порты, по сути, открытые нами страницы, используются для обмена данными и сообщениями между веб-воркерами и основными потоками.
readonly ports: ReadonlyArray<MessagePort>;
// Предоставляет информацию об отправителе сообщения, такую как, например, какое окно отправило событие
readonly source: MessageEventSource | null;
}
Пример использования
Покажу пример использования Dedicated Worker, на примере работы с изображением(пример максимально абстрактный, без конкретных реализаций, но демонстрирует некоторые возможности).
Допустим вы пишите какой‑то подобие google docs и хотите сжимать картинку, если она больше определенного размера и при этом не блокировать основной поток.
В основном потоке, хэндлим событие выбора юзером файла изображения, отправляем его в Worker и когда приходит обработанное изображение из Worker добавляем эту картинку на страницу.
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);
};
self.onmessage = function(event) {
const image = event.data;
// Функция, которая производит какие-то преобразования с картинкой, например сжатие
const processedImage = processImage(image)
self.postMessage(processedImage);
};
Убийство Dedicated Worker
Когда Dedicated Worker вам больше не нужен его можно убить с помощью worker.terminate()
.
Dedicated Worker сам уничтожиться, когда вы закроете вкладку с ним.
Use Cases
Обработка видео/аудио/картинок - ресайз, наложение фильтров и кодирование/декодирование медиаданных и тд.
Загрузка, обработка и сохранение больших файлов.
3D-графика и различные анимации анимация.
Маппинг больших данных, например списков/различные сортировки и тд.
Можно самому ради интереса поискать Workers на сайтах, например, с помощью devtools: sources → threads → smth with workers.js
Shared Worker работает похожим образом с Dedicated Worker, однако тут все взаимодействие проходит через port: MessagePort,
и соответсвенно из‑за этого у нас появляется listener onconnect
в файле Worker'a. Общий workflow выглядит следующим образом:
Инициализируем Shared Worker с помощью конструктора в наших файлах(в этом примере их 2)
Получаем port нашего Shared Worker'a
Делаем port.postMessage()
из Main Threads
Устанавливаем connect с Main Threads из Worker Thread, с помощью onconnect
Получаем port из event'a, который прилетел нам на подключении, я пока рассматриваю случай, когда у меня 1 порт — const port = event.ports[0];
, если у вас будет больше выбирайте соответсвующий(порты создаются следующим образом — тык)
Worker с помощью port.postMessage
отправляет событие обратно в Main Thread's всем портам на которых висит port.onmessage
Срабатывает listeners в Main Thread's
Мы можем создавать сколько угодно потоков(при этом если мы создаем их из одного файла 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
Инициализируем Shared Worker и берем его port
При клике на кнопку Send отправляем в Shared Worker message, с помощью port.postMessage()
Создаем handleronmessage
, в нем добавляем новую строку в наш контейнер с сообщениями
index2.html
Инициализируем Shared Worker берем его port
Создаем handler onmessage
, в нем добавляем новую строку в наш контейнер с сообщениями (тут не делаем нашей формочки, тут будет только список сообщений)
worker.js
Создаем массив куда сложим все наши порты — ports
(эти порты нам нужны, чтобы отправить сообщение сразу во все вкладки/iframe где используется наш Worker и отобразить там новое сообщение)
Создаем handler onmessage
, и отправляем сообщение на все наши ports
Вуаля, получаем на обоих страницах одинаковые messages
<!DOCTYPE html>
<html>
<head>
<title>Shared Worker 1</title>
</head>
<body>
<div class="message-container"></div>
<input type="text" class="message-input" />
<button class="send-message-button">Send</button>
<script>
const messageContainer = document.querySelector('.message-container');
const messageInput = document.querySelector('.message-input');
const sendMessageButton = document.querySelector('.send-message-button');
const worker = new SharedWorker('worker.js');
const port = worker.port;
sendMessageButton.addEventListener('click', () => {
const message = messageInput.value;
port.postMessage(message);
messageInput.value = '';
});
port.onmessage = (e) => {
messageContainer.innerHTML += e.data + '<br>';
};
</script>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<title>Shared Worker 2</title>
</head>
<body>
<div class="message-container"></div>
<script>
const messageContainer = document.querySelector('.message-container');
const worker = new SharedWorker('worker.js');
const port = worker.port;
port.onmessage = (e) => {
messageContainer.innerHTML += e.data + '<br>';
};
</script>
</body>
</html>
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
С помощью worker.close()
Когда закрыли все вкладки на которых был использован этот Shared Worker
Use Cases
Все что связано с обменом данными между вкладками и окнами приложения.
Управление общими ресурсами.
Все тоже самое что и у Dedicated Workers
Shared Workers и Dedicated Workers — мощные инструменты для улучшения производительности приложений. Shared Workers подходят для обмена данными и управления общими ресурсами между разными частями приложения, в то время как Dedicated Workers помогает обеспечить изоляцию кода и ускоряют выполнение вычислений. Выбор между ними зависит от конкретных задач вашего проекта. Пробуйте!
Если статья показалась вам интересной, то у меня есть Телеграм Канал, где я пишу про новые технологии во фронте, делюсь хорошими книжками и интересными статьями других авторов.