Web Worker и актуальный результат вычисления
Анализатор уже загружает Wasm и возвращает компактную статистику. Однако синхронное вычисление в основном потоке может мешать странице отвечать на действия. await перед вызовом не переносит работу автоматически. Теперь создадим обычный Web Worker, у которого собственный контекст выполнения и свой экземпляр модуля.
Вместе с отдельным потоком появится протокол сообщений. Пользователь может выбрать другой файл прежде, чем закончится предыдущая операция. Нам нужно понимать, к какому запросу относится результат, и не показывать старые числа под новым названием. Это задача достоверности интерфейса, а не только производительности.
Worker не имеет доступа к DOM
Вычислительный поток получает текст и возвращает результат. Он не находит кнопку через document.querySelector и не обновляет страницу. Такой порядок сохраняет уже выбранную границу: интерфейс находится в основном потоке, чистая операция — рядом с модулем.
Полный начальный web/worker.mjs использует загрузчик из предыдущего урока:
import { getEngine } from "./engine.mjs";
self.addEventListener("message", async event => {
const { id, text } = event.data;
if (!Number.isSafeInteger(id) || typeof text !== "string") return;
try {
const engine = await getEngine();
const result = await engine.analyze(text);
self.postMessage({ id, ok: true, result, engine: engine.name });
} catch (error) {
self.postMessage({
id, ok: false,
error: error instanceof Error ? error.message : String(error)
});
}
});
Параметр id обозначает операцию. Он не является ID статьи или именем файла и не участвует в вычислении. Согласованный ответ повторяет его независимо от успеха. Ошибка передаётся в собственном простом формате, поэтому мы не рассчитываем на перенос сложного экземпляра Error через все границы.
Загрузчик вызывается внутри действия. Мы не создаём заранее отвергаемое обещание, которое ещё никто не обрабатывает. Все запросы используют общий экземпляр движка в этом Worker; главный поток не обязан инициализировать второй Wasm-модуль рядом с ним.
Запуск модульного потока
В web/page.mjs создадим Worker с type: "module". URL строится относительно текущего JavaScript-файла, поэтому не зависит от того, с какого адреса страницы подключили модуль:
let worker = new Worker(new URL("./worker.mjs", import.meta.url), {
type: "module"
});
let latestRequest = 0;
function requestAnalysis(text) {
const id = ++latestRequest;
worker.postMessage({ id, text });
return id;
}
Если перед отправкой читается файл асинхронно, номер операции лучше резервировать при начале пользовательского действия. Иначе старое чтение, закончившееся позже нового, может получить самый новый номер и показаться актуальным. Идентификатор описывает намерение пользователя, а не порядок завершения arrayBuffer.
Это легко увидеть на сценарии: сначала выбран большой файл A, затем маленький B. Чтение B может закончиться раньше. Если номер присваивается только после чтения, поздний результат A ошибочно окажется последним. В итоговом интерфейсе серии номер увеличивается до первого await, а после каждого ожидания проверяется актуальность.
Принимаем только актуальный ответ
Обработчик результата сверяет id с текущим намерением. Функции showResult и элементы статуса уже существуют в странице:
worker.addEventListener("message", event => {
const response = event.data;
if (response.id !== latestRequest) return;
if (!response.ok) {
status.textContent = response.error;
return;
}
showResult(response.result);
status.textContent = `Готово; движок ${response.engine}`;
});
Отбрасывание ответа не отменяет вычисление, которое уже произошло. Оно лишь защищает отображение. Если работа очень дорогая и действительно нужно остановить её, потребуется другой механизм. Самый простой для нашего проекта — завершить Worker и создать новый для следующего действия.
Счётчик не нужно сбрасывать при каждой смене файла: одинаковые номера разных операций затруднили бы различение ответа. В маленькой странице безопасного целого диапазона хватает с большим запасом. Для долгоживущих систем с несколькими источниками запросов можно использовать строковый ID, но это другое требование.
Отмена и освобождение потока
Кнопка отмены завершает Worker. Из-за синхронного Wasm-вызова сообщение cancel не сможет быть обработано этим же потоком, пока вычисление занимает его. Поэтому обещать немедленную отмену через обычный дополнительный postMessage неправильно.
function cancelAnalysis() {
latestRequest += 1;
worker.terminate();
worker = new Worker(new URL("./worker.mjs", import.meta.url), {
type: "module"
});
attachWorkerHandlers(worker);
status.textContent = "Операция отменена";
}
attachWorkerHandlers в полном исходнике назначает обработчики сообщения и ошибки новому экземпляру. Если оставить их только на старом Worker, следующий запрос будет отправлен, но интерфейс не узнает результат. Инициализация нового Wasm-экземпляра имеет стоимость; отмена не бесплатна.
В более сложном клиенте каждый запрос может иметь Promise в карте ожидающих операций. При завершении Worker такие обещания нужно отвергнуть и удалить из карты. Иначе вызывающий код навсегда останется в состоянии ожидания. Итоговый пример использует именно этот договор для аккуратного освобождения ресурсов.
Передача текста тоже стоит времени
Строка проходит границу сообщения Worker, а затем границу Wasm. Не следует называть такой путь «без копирования» без анализа конкретных представлений. Для бинарного ArrayBuffer можно использовать список transferable и передать владение, но после передачи исходный буфер перестанет быть доступен прежней стороне.
Мы пока передаём строку, чтобы не смешивать изменение потоковой архитектуры с новым двоичным протоколом. Для лимита восемь MiB такая модель понятна и пригодна для последующей оценки. Если данных станет больше, рассмотрим чтение и декодирование непосредственно в Worker или обработку порциями.
Правила модулей, сообщений и transferable описаны в руководстве Web Workers. Проверять нужно всю операцию, включая создание потока и путь ресурсов, а не только чистую Rust-функцию. Наши исходники пока не выполнялись.
Ошибка потока отличается от ответа с ошибкой
Ответ {ok: false} означает, что обработчик получил запрос и смог описать отказ. Событие error у Worker может означать, что сам поток не загрузился или завершился до отправки ответа. Если клиент ждёт только message, такой запрос останется без завершения. Поэтому итоговая обвязка обрабатывает обе ситуации и очищает список ожидающих операций.
При восстановлении создаётся новый экземпляр и новый жизненный цикл. Старые Promise отвергаются с понятной причиной, а новые запросы не используют прежние обработчики. Нельзя просто заменить переменную worker, оставив карту ожидания заполненной и ожидая, что следующий поток продолжит старое состояние.
Для текущего интерфейса достаточно одного потока и одного актуального результата. Пул Worker имеет смысл при независимых дорогих задачах, но добавит очередь, ограничение параллелизма и общий бюджет памяти. Создавать новый поток на каждый клик без завершения старого значит копить ресурсы, а не автоматически ускорять вычисление.
Теперь вычисление отделено от страницы, а ответ связан с конкретным намерением пользователя. Следующий урок добавит JavaScript-движок на случай недоступности Wasm. Тот же протокол сообщений позволит менять реализацию без переписывания формы и вывода показателей.