Многоядерные процессоры давно стали стандартом, но большинство веб-приложений до сих пор бессистемно используют лишь одно ядро. Результат — блокировка основного потока при сложных вычислениях, замирание интерфейса и разочарованные пользователи. Заметили рывки анимации при обработке данных или падение FPS при сортировке крупных массивов? Это критически влияет на пользовательский опыт.
Где тонко, там рвётся: проблема долгих задач
Основной поток браузера — единовластный диктатор: рендеринг, парсинг CSS, исполнение JavaScript, обработка событий — всё происходит здесь. Заблокируйте его вычислениями — и интерфейс перестаёт откликаться.
Простой пример (совсем не гипотетический):
// Прямо в обработчике клика:
function processImageData() {
const pixels = new Uint8ClampedArray(12_000_000); // 12M пикселей
// Тяжёлая операция (например, применение фильтра)
for (let i = 0; i < pixels.length; i += 4) {
const avg = (pixels[i] + pixels[i+1] + pixels[i+2]) / 3;
pixels[i] = pixels[i+1] = pixels[i+2] = avg; // Делаем ч/б
}
updateCanvas(pixels); // Обновляем UI
}
Выполнение этого кода забьёт поток на 300-800 мс (зависит от устройства). Пользователь не сможет скроллить страницу, нажимать кнопки — интерфейс «зависнет».
Web Workers: параллелизм без боли
Web Workers позволяют запускать JavaScript в фоновых потоках. Они не имеют доступа к DOM (это защищает от конкуренции за ресурсы), но идеальны для:
- Вычислительно сложных задач
- Предварительной обработки данных
- Длительных математических операций
- Парсинга крупных файлов
Архитектурно это выглядит так:
[Main Thread] ↔︎ [Web Worker 1]
↔︎ [Web Worker 2]
↔︎ […] (доступно до 255 воркеров)
Пишем реальный пример: фильтрация изображения
Шаг 1: Создаём воркер
image-processor.worker.js
:
self.addEventListener('message', ({ data }) => {
const { pixels } = data;
// Делаем пиксели чёрно-белыми
for (let i = 0; i < pixels.length; i += 4) {
const r = pixels[i], g = pixels[i+1], b = pixels[i+2];
// Более точная формуля: luminosity method
const gray = 0.21*r + 0.72*g + 0.07*b;
pixels[i] = pixels[i+1] = pixels[i+2] = gray;
}
self.postMessage({ processedPixels: pixels });
}, false);
Шаг 2: Подключаем воркер из основного кода
const imageProcessor = new Worker('image-processor.worker.js');
async function applyFilter(imageData) {
return new Promise((resolve) => {
imageProcessor.postMessage({ pixels: imageData.data });
imageProcessor.onmessage = ({ data }) => {
resolve(new ImageData(data.processedPixels, imageData.width, imageData.height));
};
});
}
// Использование в интерфейсе:
canvas.addEventListener('click', async () => {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const processed = await applyFilter(imageData); // Не блокирует поток!
ctx.putImageData(processed, 0, 0);
});
Преимущества:
- Основной поток свободен для анимации и UI
- Вычисления распределяются по ядрам процессора
- FPS остаётся стабильным
Грабли и подводные камни
Передача данных
При обмене данными между потоками срабатывает структурное клонирование. Для огромных массивов это дорогая операция. Решение — Transferable Objects
:
const pixels = new Uint8ClampedArray(10_000_000); // 10 МБ
worker.postMessage({ pixels }, [pixels.buffer]);
// Теперь буфер принадлежит воркеру, в основном потоке `pixels` обнуляется
Это мгновенная передача владения без копирования, но после отправки данные в основном потоке становятся недоступны.
Ошибки в воркерах
Обработка ошибок требует дополнительного механизма:
// В воркере:
try {
riskyOperation();
} catch (err) {
self.postMessage({ error: err.message });
}
// В основном потоке:
worker.onmessage = ({ data }) => {
if (data.error) {
showToast(`Ошибка обработки: ${data.error}`);
return;
}
// ... обработка данных ...
};
Ограничения среды
Web Workers лишены:
window
,document
, родительских DOM-элементов- Доступа к LocalStorage, IndexedDB (только через асинхронные API типа
WorkerGlobalScope.indexedDB
) - Части Web API (
getBoundingClientRect()
и т.д.)
Для работы с вычислениями — достаточно, но знать про ограничения критически важно.
Wrapper’ы для сложных сценариев
Для управления пулом воркеров используйте библиотечные решения:
- Comlink от Google: абстрагирует RPC вызовы
- Workerpool: динамическое распределение задач
- Threads.js: модели акторов с TypeScript
Пример с Comlink:
// worker.js
importScripts("https://unpkg.com/comlink/dist/umd/comlink.js");
const worker = {
calculatePrime(n) {
// ... тяжёлые вычисления ...
}
};
Comlink.expose(worker);
// main.js
const worker = Comlink.wrap(new Worker('worker.js'));
const result = await worker.calculatePrime(100000); // Выглядит как обычная функция!
Когда это реально нужно?
- Обработка медиа: видео/аудио-фильтры
- Интерактивная визуализация: алгоритмы графов, физические симуляции
- Игры: игровая логика, pathfinding
- Анализ данных: сортировки, агрегация, ML-модели в TensorFlow.js
Выводы: разумная параллелизация
Web Workers — не серебряная пуля, а острый скальпель:
- Измеряйте перед оптимизацией: если операция занимает <30 мс, воркер избыточен
- Минимизируйте передачу данных: используйте Transferable Objects
- Дробите задачи: вместо одного тяжёлого воркера — пул для микрозадач
- Деградируйте изящно: если воркеры не поддерживаются (IE), обеспечьте fallback
Для проверки эффективности замеряйте Total Blocking Time в Lighthouse. Прирост производительности 30-70% для вычислительных задач — не фантастика, а практический результат.
Отвязать интерфейс от compute-bound задач — значит превратить «зависание» в «обрабатывается». Именно этого ожидают пользователи: всегда отзывчивого UI, независимо от сложности бэкграунда.
gantt
title Сравнение выполнения задачи (800мс)
dateFormat ss.SSS
section Основной поток
Обработка изображения : active, t1, 00:0.000, 00:0.800
Анимация : t2, after t1, 00:0.800
section Worker
Обработка изображения : a1, 00:0.000, 00:0.800
Анимация : main, 00:0.000, 00:0.800
На диаграмме видно: основной поток свободен для анимаций, пока воркер трудится. Производительность — это не только быстрый код, но и правильная архитектура его исполнения.