Опубліковано
11 хв читання

Web Workers: як виконувати важкі обчислення й не блокувати інтерфейс

Автори
  • Learna
    Ім’я
    Learna

Поки сторінка виконує важке обчислення, поле введення може перестати реагувати на клавіатуру. Самого async тут замало: функція почне повертати проміс, але її обчислення все одно виконуватимуться в тому самому потоці JavaScript. Колбек Promise.then() також не створює окремого потоку. Докладніше про це — у розділі MDN про конкурентне виконання промісів.

Web Worker дає змогу винести обчислення в окремий контекст із власним циклом подій. Сторінка надсилає йому дані, воркер рахує й повертає результат, а сторінка його показує. Сам воркер не має прямого доступу до DOM. Це корисно для обробки зображень, розбору великих файлів, чисельних симуляцій та інших обчислень, через які інтерфейс підвисає. У MDN є огляд роботи воркерів та доступних їм API.

Водночас воркер не гарантує, що результат буде готовий швидше. Його запуск, обмін повідомленнями й копіювання даних теж потребують часу. До того ж воркер ділить ресурси комп’ютера з рештою програми. Інтерфейс може краще реагувати на дії користувача, навіть якщо саме обчислення триватиме довше. Спершу з’ясуйте причину затримок: воркер не прискорить повільний сервер і не замінить вдалого алгоритму.

Як запустити приклади

Нижче — два окремі браузерні демо. Створіть тимчасову папку, а в ній — папки primes/ і mix/. Кожен блок коду збережіть у файл із назвою, вказаною в першому рядку. Обчислення почнуться лише після того, як ви відкриєте локальну сторінку й натиснете кнопку.

Якщо Python 3 уже встановлений, запустіть сервер із папки, у якій лежать primes/ і mix/:

python3 -m http.server 8000 --bind 127.0.0.1

Відкрийте http://127.0.0.1:8000/primes/ або http://127.0.0.1:8000/mix/ у сучасному браузері. Якщо порт зайнятий, виберіть інший. Сервер зупиняється через Ctrl+C. Додаткові пакети, сторонні бінарні файли, фреймворк чи збирання проєкту не потрібні. Відкривайте сторінку через HTTP, а не подвійним клацанням по HTML-файлу: для модулів і воркерів діють обмеження завантаження та походження ресурсу (origin). new URL('./worker.js', import.meta.url) визначає адресу воркера відносно поточного модуля, а { type: 'module' } вмикає підтримку модулів. Це описано в стандарті HTML.

Приклад 1: рахуємо прості числа, поки користувач вводить текст

Для наочності перевірятимемо числа простим діленням. Це створює достатнє навантаження, щоб побачити різницю, а верхня межа не дає обчисленню рости безконтрольно. У реальній задачі підрахунку простих чисел часто краще підійде алгоритм решета. Рахуємо до заданої межі включно: до 10 є чотири прості числа, до 100 — двадцять п’ять.

<!-- primes/index.html -->
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Prime worker</title>
<label>Upper limit <input id="limit" type="number" min="2" max="2000000" value="500000"></label>
<button id="run">Count / replace job</button>
<button id="cancel">Cancel</button>
<p><label>Type while it works <input></label></p>
<p id="output" role="status">Ready</p>
<script type="module" src="./main.js"></script>
</html>
// primes/main.js
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
const output = document.querySelector('#output');
const run = document.querySelector('#run');
let latest = 0;

run.onclick = () => {
  const id = ++latest;
  output.textContent = 'Counting…';
  worker.postMessage({ type: 'count', id, limit: Number(document.querySelector('#limit').value) });
};
document.querySelector('#cancel').onclick = () => {
  worker.postMessage({ type: 'cancel', id: latest });
  latest++; // Also invalidate a result already queued for the page.
  output.textContent = 'Cancelled';
};
worker.onmessage = ({ data }) => {
  if (data.id !== latest) return;
  if (data.type === 'result') output.textContent = `${data.count} primes`;
  if (data.type === 'error') output.textContent = data.message;
};
worker.onerror = () => {
  latest++;
  output.textContent = 'Worker failed. Reload to retry.';
  run.disabled = true;
};
addEventListener('pagehide', () => worker.terminate());
addEventListener('pageshow', (event) => {
  if (event.persisted) location.reload(); // Recreate after a back/forward cache restore.
});
// primes/worker.js
let generation = 0;
let activeId = null;

self.onmessage = ({ data }) => {
  if (data.type === 'cancel') {
    if (data.id === activeId) {
      generation++;
      activeId = null;
    }
    return;
  }
  if (data.type !== 'count') return;
  const { id, limit } = data;
  if (!Number.isSafeInteger(id) || !Number.isInteger(limit) || limit < 2 || limit > 2_000_000) {
    postMessage({ type: 'error', id, message: 'Choose an integer from 2 to 2000000.' });
    return;
  }
  const token = ++generation;
  activeId = id;
  let candidate = 2;
  let count = 0;

  function chunk() {
    if (token !== generation) return;
    const end = Math.min(candidate + 2_000, limit + 1);
    for (; candidate < end; candidate++) {
      let prime = true;
      for (let divisor = 2; divisor * divisor <= candidate; divisor++) {
        if (candidate % divisor === 0) { prime = false; break; }
      }
      if (prime) count++;
    }
    if (candidate <= limit) setTimeout(chunk, 0);
    else {
      activeId = null;
      postMessage({ type: 'result', id, count });
    }
  }
  chunk();
};

Спочатку введіть 100, потім — 500000. Очікувані результати: 25 primes і 41538 primes. Поки триває довше обчислення, спробуйте набрати текст в іншому полі. Кнопка Count запускає нове завдання замість поточного, а Cancel скасовує його. Час виконання залежить від пристрою. Якщо все завершується надто швидко, увімкніть уповільнення процесора в інструментах розробника браузера.

На сторінці id визначає, результат якого запиту ще актуальний. У воркері лічильник generation визначає, яке завдання ще треба обчислювати. Ігнорувати застарілу відповідь недостатньо: без скасування воркер і далі витрачатиме час на непотрібний результат. Є і зворотна ситуація: відповідь уже надіслано, а користувач устиг натиснути Cancel. Її вже не відкличеш, тому сторінка теж перевіряє ID.

Воркер виконує одну порцію обчислень, а наступну планує через таймер. Між ними він може обробити нове повідомлення. Поки триває суцільний синхронний цикл, повідомлення про скасування чекатиме в черзі. Постійне await Promise.resolve() теж не допоможе: воно дає виконатися лише мікрозадачам, а повідомлення надходять окремими задачами. Розмір порції обмежує кількість роботи до наступної нагоди скасувати завдання, але не гарантує певної затримки в мілісекундах — на неї впливають таймери, планування задач і швидкість пристрою.

worker.terminate() одразу зупиняє воркер, не даючи йому завершити прибирання ресурсів. Так можна позбутися непотрібного воркера або зупинити обчислення, у якому не передбачено скасування. Але для наступного завдання доведеться створити новий воркер. У нашому прикладі Cancel працює інакше: воркер сам припиняє обчислення між порціями й залишається готовим до наступного запиту. Коли залишаємо сторінку, воркер завершується. Якщо браузер поверне її з кешу переходів (back/forward cache), обробник pageshow перезавантажить демо й створить воркер заново. Попередній стан демо при цьому втратиться. Про негайне завершення воркера — у документації terminate().

Повідомлення: скопіювати дані чи передати буфер

Звичайні повідомлення використовують структуроване клонування. Сторінка й воркер отримують окремі об’єкти, а не спільне посилання на один змінюваний об’єкт. Функції та DOM-вузли так передати не можна. Надсилайте прості об’єкти з потрібними полями, наприклад { type, id, limit }, замість сервісу застосунку чи екземпляра компонента. MDN перелічує типи, які підтримує structured clone.

Великий обсяг бінарних даних зручніше передати через ArrayBuffer без копіювання. Для цього буфер потрібно додати і в саме повідомлення, і в список передавання. Після надсилання він від’єднається у відправника: byteLength стане нульовим, а раніше створені масиви вже не матимуть доступу до його даних. Тепер буфер належить одержувачу; той за потреби може передати його назад. Типізований масив лише надає доступ до буфера, тому в список передавання додають саме його buffer. Пам’ять тут не стає спільною — змінюється її власник. Докладніше про передавання й від’єднання буферів — у MDN.

Приклад 2: однакові обчислення на JavaScript і Wasm

Worker дає змогу обрати, де виконувати обчислення. WebAssembly — у якому вигляді подати й виконувати обчислювальний код. Wasm у головному потоці теж може блокувати інтерфейс. Тут обидва варіанти працюють у воркері: отримують однакові дані й однаково обробляють переповнення беззнакових 32-бітних цілих чисел.

Для кожного початкового значення багато разів обчислюємо x = x * 1664525 + 1013904223 за модулем 2³². Це проста вправа з арифметики. Приклад не призначений для криптографії; використовувати його як генератор випадкових чисел ми також не радимо. За замовчуванням виконуємо 4096 × 8192 = 33 554 432 ітерації. Обчислювальне ядро навмисно маленьке, щоб можна було розібрати кожну інструкцію. Швидкодія реального застосунку залежатиме від його алгоритму й даних.

Нижче — повний текст модуля mix/kernel.wat. Браузер не виконує WAT безпосередньо, тому в kernel.js наведені еквівалентні 75 байтів готового модуля. Це дозволяє запустити демо без завантаження компілятора. Модуль нічого не імпортує, не оголошує лінійної пам’яті й не завантажує сторонніх бінарних файлів. У посібнику MDN пояснено запис WAT, а в специфікації Wasm — кодування інструкцій.

;; mix/kernel.wat (audit source; not loaded by the browser)
(module
  (func (export "mix") (param $seed i32) (param $rounds i32) (result i32)
    block $done
      loop $again
        local.get $rounds
        i32.eqz
        br_if $done
        local.get $seed
        i32.const 1664525
        i32.mul
        i32.const 1013904223
        i32.add
        local.set $seed
        local.get $rounds
        i32.const 1
        i32.sub
        local.set $rounds
        br $again
      end
    end
    local.get $seed))
// mix/kernel.js
export function mixJS(seed, rounds) {
  let value = seed >>> 0;
  for (let round = 0; round < rounds; round++) {
    value = (Math.imul(value, 1_664_525) + 1_013_904_223) >>> 0;
  }
  return value;
}

export const wasmBytes = new Uint8Array([
  0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x07, 0x01, 0x60,
  0x02, 0x7f, 0x7f, 0x01, 0x7f, 0x03, 0x02, 0x01, 0x00, 0x07, 0x07, 0x01,
  0x03, 0x6d, 0x69, 0x78, 0x00, 0x00, 0x0a, 0x2b, 0x01, 0x29, 0x00, 0x02,
  0x40, 0x03, 0x40, 0x20, 0x01, 0x45, 0x0d, 0x01, 0x20, 0x00, 0x41, 0x8d,
  0xcc, 0xe5, 0x00, 0x6c, 0x41, 0xdf, 0xe6, 0xbb, 0xe3, 0x03, 0x6a, 0x21,
  0x00, 0x20, 0x01, 0x41, 0x01, 0x6b, 0x21, 0x01, 0x0c, 0x00, 0x0b, 0x0b,
  0x20, 0x00, 0x0b,
]);
<!-- mix/index.html -->
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Worker kernel comparison</title>
<button id="js">Run JavaScript</button>
<button id="wasm">Run Wasm</button>
<p><label>Type while it works <input></label></p>
<p id="output" role="status">Ready</p>
<script type="module" src="./main.js"></script>
</html>
// mix/main.js
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
const output = document.querySelector('#output');
const js = document.querySelector('#js');
const wasm = document.querySelector('#wasm');
let latest = 0;
let started = 0;

function start(engine) {
  const id = ++latest;
  js.disabled = wasm.disabled = true; // One job at a time.
  started = performance.now();
  const values = Uint32Array.from({ length: 4_096 }, (_, index) => index);
  const buffer = values.buffer;
  worker.postMessage({ type: 'mix', id, engine, rounds: 8_192, buffer }, [buffer]);
  output.textContent = `Working; sender buffer now has ${buffer.byteLength} bytes`;
}
js.onclick = () => start('js');
wasm.onclick = () => start('wasm');
worker.onmessage = ({ data }) => {
  if (data.id !== latest) return;
  const elapsed = performance.now() - started;
  js.disabled = wasm.disabled = false;
  if (data.type === 'error') { output.textContent = data.message; return; }
  const values = new Uint32Array(data.buffer);
  output.textContent = `First: ${values[0]}; kernel: ${data.kernelMs.toFixed(1)} ms; elapsed: ${elapsed.toFixed(1)} ms`;
};
worker.onerror = () => {
  latest++;
  output.textContent = 'Worker failed. Reload to retry.';
  js.disabled = wasm.disabled = true;
};
addEventListener('pagehide', () => worker.terminate());
addEventListener('pageshow', (event) => {
  if (event.persisted) location.reload(); // Recreate after a back/forward cache restore.
});
// mix/worker.js
import { mixJS, wasmBytes } from './kernel.js';
let compiled;

self.onmessage = async ({ data }) => {
  const { id, engine, rounds, buffer } = data;
  try {
    if (data.type !== 'mix' || !['js', 'wasm'].includes(engine) ||
        !Number.isInteger(rounds) || rounds < 0 || rounds > 8_192 ||
        !(buffer instanceof ArrayBuffer) || buffer.byteLength % 4 !== 0 ||
        buffer.byteLength > 4_096 * 4) {
      throw new Error('Invalid or excessive work');
    }
    let kernel = mixJS;
    if (engine === 'wasm') {
      compiled ??= WebAssembly.instantiate(wasmBytes);
      kernel = (await compiled).instance.exports.mix;
    }
    const values = new Uint32Array(buffer);
    const started = performance.now();
    for (let index = 0; index < values.length; index++) {
      values[index] = kernel(values[index], rounds) >>> 0;
    }
    const kernelMs = performance.now() - started;
    postMessage({ type: 'result', id, buffer, kernelMs }, [buffer]);
  } catch {
    postMessage({ type: 'error', id, message: 'Kernel failed; reload or try another engine.' });
  }
};

Обидві кнопки мають показати First: 1695850496. Для перевірки простішого випадку: mixJS(0, 1) дає 1013904223, а нуль ітерацій залишає початкове значення незмінним. Wasm повертає i32; операція >>> 0 дає його беззнакове значення, як у типізованому масиві та JavaScript-версії. Math.imul повертає молодші 32 біти добутку. Так ми явно задаємо потрібне цілочисельне множення, не покладаючись на точність великих добутків звичайних чисел JavaScript.

Воркер створює екземпляр Wasm-модуля з байтів лише один раз — під час першого запиту до Wasm. Показник kernel не враховує цієї підготовки. Показник elapsed на сторінці охоплює створення вхідних даних, очікування в черзі, підготовку під час першого виклику й отримання відповіді. Але сам воркер починає запускатися ще до натискання кнопки, тож повного часу запуску ми тут не вимірюємо. Після кожного натискання створюються нові початкові дані, адже попередній буфер уже передано. Якщо після передавання сталася помилка, ці дані просто відкидаємо: наступне натискання створить їх заново.

У другому прикладі обсяг роботи обмежений, але всередині воркера вона виконується суцільним синхронним циклом. Кнопки Cancel тут немає. Якщо просто додати повідомлення про скасування без поділу роботи на порції, воркер прочитає його лише після завершення обчислень. Примусово зупинити воркер можна, але разом із ним втратимо й переданий буфер.

Вимірюйте і швидкодію, і реакцію інтерфейсу

Запишіть профіль продуктивності в браузері: подивіться на довгі задачі головного потоку й перевірте, як сторінка реагує на дії, поки воркер працює. Спершу переконайтеся, що обидва варіанти дають правильний результат. Потім вимірюйте час на типових даних і пристроях, які плануєте підтримувати. Вхідні дані, кількість ітерацій та обробка результату мають бути однаковими. Окремо вимірюйте перший запуск із компіляцією та наступні запуски вже підготовленого коду. Чергуйте порядок JavaScript і Wasm, збирайте результати кількох запусків і порівнюйте їхній розподіл, а не лише найкращий час. У нашому демо виклик із JavaScript у Wasm відбувається для кожного початкового значення — і це теж має свою ціну. За таким маленьким ядром не варто судити, чи прискорить Wasm ваш кодек зображень або парсер.

Автоматичні тести виконують JavaScript саме з блоків цієї статті, перевіряють Wasm-модуль і звіряють обидві реалізації з незалежним розрахунком на цілих числах. Окремий тестовий стенд відтворює обмін повідомленнями, скасування й відкидання застарілих відповідей. structuredClone у Node перевіряє від’єднання буфера. Проте ці тести не замінюють браузер: вони не перевіряють справжній обмін із Worker, порядок виконання браузерних задач, обмеження MIME-типів та origin або плавність відображення. Для цього запустіть локальне демо. Додаткове навантаження у вкладці, повільний процесор і перегрів можуть впливати і на реакцію інтерфейсу, і на час обчислень.

Воркер доречний, коли незалежні обчислення заважають користуватися сторінкою, а витрати на обмін даними прийнятні. Wasm варто обирати під конкретну реалізацію й підтверджувати вибір вимірюваннями. Ці інструменти можна поєднувати, але кожен вирішує свою задачу.