Обещание звучит завлекательно: вынеси код в воркер, собери Rust в WASM и страница полетит. На практике львиная доля тормозов фронтенда живёт не в арифметике, а в перерисовке DOM, сетевых задержках и архитектурной каше, и там не помогает ни отдельный поток, ни байткод. Настоящие сценарии, где тяжёлые вычисления обязаны переехать из главного потока, пересчитываются по пальцам, и почти все связаны с пикселями, криптографией, парсингом больших объёмов и прогоном моделей. Разберём бюджет кадра, настоящую цену передачи данных в воркер, условия при которых байткод честно обгоняет JIT и порядок действий, уберегающий от переписывания в WASM всего подряд.
Порог шестнадцати миллисекунд и честный аудит главного потока
Частота 60 кадров в секунду даёт браузеру 16.7 миллисекунды на кадр, экраны на 120 герц сжимают бюджет до 8.3. В эти рамки обязаны уместиться пересчёт стилей, раскладка, растеризация, обработка событий и весь JavaScript приложения. Задача длиннее 50 миллисекунд попадает в разряд долгих и роняет отклик: при скролле это выглядит как залипание, при наборе текста как отставание букв.
Инструмент для замера встроен в сам браузер:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log("долгая задача:", Math.round(entry.duration), "мс");
}
});
observer.observe({ entryTypes: ["longtask"] });
Список виновников редко совпадает с ожиданиями. Обычно там оказывается сортировка пяти тысяч строк таблицы, синхронный разбор ответа интерфейса на десять мегабайт и обработчики, навешанные на каждое событие прокрутки. Погоня за WASM без такого замера это стрельба по площадям: сначала аудит, потом лечение.
Характерный расклад после недели аудита выглядит так: большинство долгих задач это перерисовка от лишних обращений к DOM, заметная часть это тяжёлые данные, и лишь остаток заслуживает отдельного потока. Аудит отвечает на главный вопрос: нагрузка действительно на процессор или на интерфейс? К вычислениям на процессоре относятся пиксельные фильтры, шифры, обработка сигналов, физика и матричная математика. Всё остальное чаще упирается в перерисовку, и воркер с байткодом лишь усложнят код.
Web Workers и настоящая цена передачи данных между потоками
Воркер живёт в отдельном окружении со своим событийным циклом и не видит DOM. Метод postMessage сериализует сообщение структурным клонированием: объект копируется целиком, и цена линейна объёму. Кадр 4K в формате RGBA это 3840 2160 4 байта, около 33 мегабайт, и клонирование такого объёма съедает десятки миллисекунд и удваивает память. Спасение в переносе: второй аргумент postMessage принимает список переносимых буферов, и владение ArrayBuffer переходит воркеру почти мгновенно, без копирования.
const worker = pool[task % pool.length];
worker.postMessage({ frame: buf }, [buf]);
У переноса есть обратная сторона: отправленный буфер нельзя больше ни прочитать, ни переслать дважды, он принадлежит воркеру. Протокол обмена напоминает передачу эстафетной палочки, и интерфейс фронтенда придётся проектировать под это правило, а не прикручивать после. Переносимы и другие сущности: MessagePort, ImageBitmap и OffscreenCanvas уезжают в воркер тем же способом.
Когда у воркера появляется десяток методов, возникает соблазн собрать свой протокол удалённых вызовов. Библиотека Comlink закрывает эту нишу: обращение к функции воркера выглядит как вызов метода обычного объекта, обёртки генерируются сами, и команде не приходится поддерживать рукописный протокол. Современные движки разрешают порождать воркеры и внутри воркеров, так что тяжёлый конвейер собирается в несколько этажей: основной поток раздаёт задачи, воркеры первого уровня режут кадр на полосы, второй этаж крутит свёртку. Отдельная дисциплина это ошибки: исключение внутри воркера не долетит до основного потока само, его пересылают через onerror и onmessage.
Пул воркеров создают по числу логических ядер минус одно, чтобы оставить ресурс системе и главному потоку:
const size = Math.max(2, navigator.hardwareConcurrency - 1);
const pool = Array.from({ length: size }, () =>
new Worker(new URL("./image-worker.js", import.meta.url), { type: "module" })
);
Модульные воркеры с типом module работают в Chrome начиная с 80 версии и поддерживают привычные import внутри воркера, остальные движки подтянулись позже. Очередь задач на такой пул насыщает все ядра и держит главный поток свободным для отрисовки. Для холстов есть OffscreenCanvas: двумерный контекст переносится в воркер, и рисование вместе с фильтрами уходит из интерфейсного потока целиком.
WebAssembly без магии и условия при которых байткод обгоняет JIT
У WASM нет доступа к DOM: модуль живёт в линейной памяти и общается с внешним миром через импортированные функции, и каждая граница стоит дороже обычного вызова. Победы над JavaScript приходят там, где работа сводится к плотным циклам над компактно уложенными данными: пиксельные фильтры, матрицы, кодеки, криптографические примитивы. Компилятору не нужно угадывать типы, нагрузка не мусорит память, а SIMD расширение обрабатывает по 128 бит за такт и доступно во всех основных браузерах с 91 версии Chrome и 89 версии Firefox весной 2021 года.
Типовой запуск модуля выглядит так:
const { instance } = await WebAssembly.instantiateStreaming(
await fetch("/wasm/sobel.wasm"),
{ env: { memory: new WebAssembly.Memory({ initial: 8 }) } }
);
const t0 = performance.now();
instance.exports.sobel(3840, 2160);
console.log("время фильтра, мс:", performance.now() - t0);
Компиляция идёт потоково по мере загрузки, байты модуля кладут в кеш браузера, и повторный визит не платит за сеть. Прирост на горячем цикле обычно от двух до пяти раз против хорошо оптимизированного JavaScript, на SIMD задачах заметно больше. Расплата это протокол обмена: данные придётся либо копировать в линейную память, либо выделять её снаружи и работать внутри напрямую.
Для больших объёмов предусмотрена память, растущая на лету: WebAssembly.Memory наращивается страницами по 64 килобайта методом grow, если модуль собран с разрешением роста. Это дешевле, чем пересоздавать буферы при каждом расширении, и оберегает от жёсткого потолка wasm32. Отдельное предостережение: WASM не чинит алгоритм. Квадратичная сложность остаётся квадратичной, и переписывание медленной сортировки в байткод ускоряет её лишь в константу раз, тогда как смена алгоритма даёт выигрыш на порядки. Сначала математика, потом низкоуровневая механика.
Для языков со сборщиком мусора придуман формат WasmGC: структуры с указателями убирает сборщик хоста, и Java, Kotlin и Dart компилируются без собственного рантайма памяти. Поддержка включена в Chrome 119 и Firefox 120 осенью 2023 года, Safari догнал их позже.
Реальные задачи с полным правом на вычисления в воркере
Список проверенных сценариев стабилен годами. Редактор Figma рисует сцены движком на C++, собранным в WASM, и миллионы векторов обрабатываются вне интерфейсного потока. Веб версия Photoshop построена на Emscripten и выполняет операции над изображениями прямо в браузере. Приложение Squoosh сжимает картинки кодеками MozJPEG и OxiPNG без сервера, ffmpeg.wasm конвертирует видеофайлы на клиенте, веб клиенты менеджеров паролей вычисляют Argon2 для растяжения ключей, а ONNX Runtime Web и WASM бэкенд TensorFlow.js прогоняют нейросети локально.
Ещё два стойких жильца этой ниши. Первый: терминалы мониторинга с потоками телеметрии, где в секунду прилетают десятки тысяч точек и их нужно агрегировать и сглаживать до отрисовки. Второй: табличные процессоры в браузере, проглатывающие CSV на сотни мегабайт; парсер на WASM раскладывает файл в типизированные колонки заметно быстрее JavaScript, а воркер прячет эту работу от интерфейса. Общий признак у всех историй один: миллионы однотипных операций над плотными данными. Фильтр Собеля по кадру 4K трогает 8.3 миллиона пикселей, разбор файла логов на 500 мегабайт это десятки миллионов токенов, Argon2 намеренно прожорлив по замыслу. Если задача не про плотный цикл, выигрыш растворится в накладных расходах.
Показательный антипример из жизни: валидация формы из двадцати полей, которую однажды вынесли в WASM модуль. Модуль весил 300 килобайт, грузился дольше самой валидации и проигрывал нативному JavaScript по всем замерам. Граница применимости проходит не по вкусу, а по числу операций.
Инструменты Emscripten и wasm-bindgen и цена каждого килобайта байткода
Emscripten компилирует C и C++, wasm-bindgen связывает Rust с JavaScript, AssemblyScript даёт типизированное подмножество TypeScript. Цепочка почти всегда заканчивается wasm-opt из Binaryen: оптимизатор вычищает мёртвый код и сворачивает инструкции. Сборка типового модуля на Rust выглядит так:
cargo build --release --target wasm32-unknown-unknown
wasm-bindgen target/wasm32-unknown-unknown/release/processor.wasm --out-dir pkg --target web
wasm-opt -O3 pkg/processor_bg.wasm -o pkg/processor.opt.wasm
Со стороны C++ цепочка похожая и короче:
emcc sobel.cpp -O3 -o sobel.js -s EXPORTED_FUNCTIONS=_sobel -s ALLOW_MEMORY_GROWTH=1
Размер модуля не мелочь. Минимальный модуль на Rust и C умещается в единицы и десятки килобайт, обвязка wasm-bindgen добавляет свой вес, а Go со встроенным сборщиком мусора легко переваливает за мегабайт. Пользователь с мобильным интернетом заметит разницу при первом визите, поэтому модуль грузят лениво, ровно к моменту, когда он понадобится, а не на старте страницы. Состав байткода разбирают отчётом twiggy: инструмент показывает вклад каждой функции в итоговый файл.
Гигиена размеров приносит дивиденды: релизная сборка с оптимизацией размера, обрезка неиспользуемых функций, сжатие на сервере. Отдельная строка бюджета это память: жадный аллокатор внутри модуля легко занимает сотни мегабайт, и на слабых устройствах бюджет памяти жёстче, чем бюджет процессора.
Границы применимости где воркеры и WASM не ускоряют проект
Первая граница это интерфейс: воркер не трогает DOM, WASM тем более, и управлять разметкой из отдельного потока не выйдет. Вторая это обмен: если на каждую порцию вычислений нужен конверт данных туда и обратно, экономия растворяется на клонировании. Третья это память: копия кадра в линейной памяти плюс копия в воркере плюс исходник в JavaScript умножают потребление втрое на слабых устройствах. Четвёртая это масштаб: расчёт над сотней элементов быстрее остаётся в JavaScript, накладные расходы на модуль не окупятся никогда. Пятая граница это разделяемая память. SharedArrayBuffer с Atomics разрешает общие данные между воркерами, но защита от атак по времени требует перекрёстных заголовков изоляции источника:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Эти заголовки ломают сторонние виджеты и вставки на странице, и решение включать их стоит дорого. Чаще дешевле оставить каждому воркеру собственную копию данных и мириться с лишними мегабайтами.
Наконец, поддержка. Две цепочки сборки, два языка, межъязыковая граница на каждом код ревью. Если в команде восемь фронтендеров и ни одного системного разработчика, WASM модуль станет чужим: его будут обходить стороной, а обновления откладывать. Честный вопрос на старте звучит так: кто будет содержать этот код через год.
Порядок выбора решения от замера до выноса кода в воркер
Последовательность, проверенная в реальных проектах, укладывается в четыре шага:
1. замерить долгие задачи и убедиться, что узел именно в вычислениях, а не в перерисовке;
2. вынести чистую функцию в воркер и передавать буферы переносом, без копирования;
3. пересчитать бюджет кадра и оставить только то, что дало измеримый выигрыш;
4. при оставшемся дефиците переписать горячий участок в WASM и подключить SIMD обработку.
Четвёртый шаг оправдан, когда счёт идёт на миллионы элементов, а вычисления описываются плотным циклом. В остальных случаях достаточно первых двух, и это отличная новость: воркеры дешевле в поддержке, пишутся на том же JavaScript и не требуют новой цепочки сборки.
Тяжёлые вычисления в браузере решаемы, но у решения узкая специализация. Воркеры возвращают интерфейсу отзывчивость, WASM добавляет кратный прирост на плотных циклах, а всё между ними не лечится ни тем ни другим. Команда, которая начинает с замера, платит за ускорение только там, где оно покупается честно, и не тратит квартал на переписывание в байткод того, что никогда не было медленным.