Розблокуйте багатопотоковість у JavaScript. Цей посібник про SharedArrayBuffer, Atomics, Web Workers та безпеку для високопродуктивних вебзастосунків.
JavaScript SharedArrayBuffer: Глибоке занурення у паралельне програмування у вебі
Десятиліттями однопотокова природа JavaScript була водночас джерелом його простоти та значним вузьким місцем у продуктивності. Модель циклу подій чудово працює для більшості завдань, керованих інтерфейсом користувача, але вона стикається з труднощами при виконанні обчислювально інтенсивних операцій. Тривалі обчислення можуть заморозити браузер, створюючи неприємний досвід для користувача. Хоча Web Workers запропонували часткове вирішення, дозволяючи виконувати скрипти у фоновому режимі, вони мали власне суттєве обмеження: неефективну передачу даних.
Зустрічайте SharedArrayBuffer
(SAB) — потужну функцію, яка кардинально змінює правила гри, запроваджуючи справжній, низькорівневий спільний доступ до пам'яті між потоками у вебі. У парі з об'єктом Atomics
, SAB відкриває нову еру високопродуктивних, паралельних застосунків безпосередньо в браузері. Однак з великою силою приходить велика відповідальність — і складність.
Цей посібник проведе вас у глибоке занурення у світ паралельного програмування в JavaScript. Ми розглянемо, чому воно нам потрібне, як працюють SharedArrayBuffer
та Atomics
, критичні аспекти безпеки, які ви повинні врахувати, та практичні приклади для початку роботи.
Старий світ: Однопотокова модель JavaScript та її обмеження
Перш ніж ми зможемо оцінити рішення, ми повинні повністю зрозуміти проблему. Виконання JavaScript у браузері традиційно відбувається в одному потоці, який часто називають "основним потоком" або "потоком інтерфейсу".
Цикл подій
Основний потік відповідає за все: виконання вашого коду JavaScript, рендеринг сторінки, реагування на взаємодії користувача (наприклад, кліки та прокручування) та запуск анімацій CSS. Він керує цими завданнями за допомогою циклу подій, який безперервно обробляє чергу повідомлень (завдань). Якщо завдання виконується довго, воно блокує всю чергу. Нічого іншого не може відбуватися — інтерфейс зависає, анімації затинаються, а сторінка стає невідгукливою.
Web Workers: Крок у правильному напрямку
Web Workers були запроваджені для пом'якшення цієї проблеми. Web Worker — це, по суті, скрипт, що виконується в окремому фоновому потоці. Ви можете перекласти важкі обчислення на воркер, звільнивши основний потік для обробки інтерфейсу користувача.
Комунікація між основним потоком і воркером відбувається через API postMessage()
. Коли ви надсилаєте дані, вони обробляються за алгоритмом структурованого клонування. Це означає, що дані серіалізуються, копіюються, а потім десеріалізуються в контексті воркера. Хоча цей процес ефективний, він має значні недоліки для великих наборів даних:
- Надлишкові витрати на продуктивність: Копіювання мегабайтів або навіть гігабайтів даних між потоками є повільним та інтенсивним для процесора.
- Споживання пам'яті: Створюється дублікат даних у пам'яті, що може бути серйозною проблемою для пристроїв з обмеженим об'ємом пам'яті.
Уявіть собі відеоредактор у браузері. Надсилання цілого відеокадру (який може мати розмір у кілька мегабайтів) туди й назад до воркера для обробки 60 разів на секунду було б надзвичайно дорогим. Саме цю проблему і було створено вирішувати за допомогою SharedArrayBuffer
.
Зміна правил гри: Представляємо SharedArrayBuffer
SharedArrayBuffer
— це буфер необроблених бінарних даних фіксованої довжини, схожий на ArrayBuffer
. Критична відмінність полягає в тому, що SharedArrayBuffer
може бути спільним для кількох потоків (наприклад, основного потоку та одного або кількох Web Workers). Коли ви "надсилаєте" SharedArrayBuffer
за допомогою postMessage()
, ви надсилаєте не копію, а посилання на той самий блок пам'яті.
Це означає, що будь-які зміни, зроблені в даних буфера одним потоком, миттєво стають видимими для всіх інших потоків, які мають на нього посилання. Це усуває дорогий крок копіювання та серіалізації, забезпечуючи майже миттєвий обмін даними.
Уявіть це так:
- Web Workers з
postMessage()
: Це як два колеги, що працюють над документом, пересилаючи копії електронною поштою. Кожна зміна вимагає надсилання цілої нової копії. - Web Workers з
SharedArrayBuffer
: Це як два колеги, що працюють над одним документом у спільному онлайн-редакторі (наприклад, Google Docs). Зміни видно обом у реальному часі.
Небезпека спільної пам'яті: Стани гонитви
Миттєвий обмін пам'яттю є потужним, але він також створює класичну проблему зі світу паралельного програмування: стани гонитви.
Стан гонитви виникає, коли кілька потоків намагаються одночасно отримати доступ і змінити ті самі спільні дані, і кінцевий результат залежить від непередбачуваного порядку їх виконання. Розглянемо простий лічильник, що зберігається в SharedArrayBuffer
. І основний потік, і воркер хочуть його інкрементувати.
- Потік А зчитує поточне значення, яке дорівнює 5.
- Перш ніж Потік А зможе записати нове значення, операційна система призупиняє його і переключається на Потік Б.
- Потік Б зчитує поточне значення, яке все ще дорівнює 5.
- Потік Б обчислює нове значення (6) і записує його назад у пам'ять.
- Система переключається назад на Потік А. Він не знає, що Потік Б щось зробив. Він продовжує з того місця, де зупинився, обчислюючи своє нове значення (5 + 1 = 6) і записуючи 6 назад у пам'ять.
Хоча лічильник було інкрементовано двічі, кінцеве значення — 6, а не 7. Операції не були атомарними — їх можна було перервати, що призвело до втрати даних. Саме тому ви не можете використовувати SharedArrayBuffer
без його ключового партнера: об'єкта Atomics
.
Охоронець спільної пам'яті: Об'єкт Atomics
Об'єкт Atomics
надає набір статичних методів для виконання атомарних операцій над об'єктами SharedArrayBuffer
. Атомарна операція гарантовано виконується повністю, без переривання будь-якою іншою операцією. Вона або відбувається повністю, або не відбувається зовсім.
Використання Atomics
запобігає станам гонитви, забезпечуючи безпечне виконання операцій читання-модифікації-запису у спільній пам'яті.
Ключові методи Atomics
Розглянемо деякі з найважливіших методів, що надаються Atomics
.
Atomics.load(typedArray, index)
: Атомарно зчитує значення за заданим індексом і повертає його. Це гарантує, що ви читаєте повне, не пошкоджене значення.Atomics.store(typedArray, index, value)
: Атомарно зберігає значення за заданим індексом і повертає це значення. Це гарантує, що операція запису не буде перервана.Atomics.add(typedArray, index, value)
: Атомарно додає значення до значення за заданим індексом. Повертає початкове значення на цій позиції. Це атомарний еквівалентx += value
.Atomics.sub(typedArray, index, value)
: Атомарно віднімає значення від значення за заданим індексом.Atomics.compareExchange(typedArray, index, expectedValue, replacementValue)
: Це потужний умовний запис. Він перевіряє, чи дорівнює значення заindex
значеннюexpectedValue
. Якщо так, він замінює його наreplacementValue
і повертає початковеexpectedValue
. Якщо ні, він нічого не робить і повертає поточне значення. Це фундаментальний будівельний блок для реалізації складніших примітивів синхронізації, таких як блокування.
Синхронізація: Більше, ніж прості операції
Іноді вам потрібно більше, ніж просто безпечне читання та запис. Вам потрібно, щоб потоки координували свої дії та чекали один на одного. Поширеним антипатерном є "активне очікування", коли потік перебуває у щільному циклі, постійно перевіряючи комірку пам'яті на наявність змін. Це марнує цикли процесора та розряджає батарею.
Atomics
надає набагато ефективніше рішення за допомогою wait()
та notify()
.
Atomics.wait(typedArray, index, value, timeout)
: Це наказує потоку перейти в режим сну. Він перевіряє, чи значення заindex
все ще дорівнюєvalue
. Якщо так, потік спить, доки його не розбудитьAtomics.notify()
або доки не мине необов'язковийtimeout
(у мілісекундах). Якщо значення заindex
вже змінилося, він негайно повертається. Це неймовірно ефективно, оскільки сплячий потік майже не споживає ресурсів процесора.Atomics.notify(typedArray, index, count)
: Використовується для пробудження потоків, які сплять на певній комірці пам'яті черезAtomics.wait()
. Він розбудить щонайбільшеcount
очікуючих потоків (або всі, якщоcount
не вказано або дорівнюєInfinity
).
Збираємо все докупи: Практичний посібник
Тепер, коли ми розуміємо теорію, давайте пройдемося кроками реалізації рішення з використанням SharedArrayBuffer
.
Крок 1: Вимоги безпеки — міждоменна ізоляція
Це найпоширеніша перешкода для розробників. З міркувань безпеки SharedArrayBuffer
доступний лише на сторінках, що перебувають у стані міждоменної ізоляції. Це захід безпеки для пом'якшення вразливостей спекулятивного виконання, таких як Spectre, які потенційно можуть використовувати таймери високої роздільної здатності (що стало можливим завдяки спільній пам'яті) для витоку даних між доменами.
Щоб увімкнути міждоменну ізоляцію, ви повинні налаштувати свій вебсервер на надсилання двох спеціальних HTTP-заголовків для вашого основного документа:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
(COOP): Ізолює контекст перегляду вашого документа від інших документів, не дозволяючи їм безпосередньо взаємодіяти з вашим об'єктом window.Cross-Origin-Embedder-Policy: require-corp
(COEP): Вимагає, щоб усі підресурси (такі як зображення, скрипти та iframe), завантажені вашою сторінкою, були або з того ж домену, або явно позначені як такі, що завантажуються з інших доменів, за допомогою заголовкаCross-Origin-Resource-Policy
або CORS.
Це може бути складно налаштувати, особливо якщо ви покладаєтеся на сторонні скрипти або ресурси, які не надають необхідних заголовків. Після налаштування сервера ви можете перевірити, чи ізольована ваша сторінка, перевіривши властивість self.crossOriginIsolated
у консолі браузера. Вона повинна мати значення true
.
Крок 2: Створення та спільне використання буфера
У вашому основному скрипті ви створюєте SharedArrayBuffer
та "вигляд" на нього за допомогою TypedArray
, наприклад Int32Array
.
main.js:
// Спочатку перевірте міждоменну ізоляцію!
if (!self.crossOriginIsolated) {
console.error("Ця сторінка не є міждоменно ізольованою. SharedArrayBuffer буде недоступний.");
} else {
// Створюємо спільний буфер для одного 32-бітного цілого числа.
const buffer = new SharedArrayBuffer(4);
// Створюємо вигляд на буфер. Усі атомарні операції відбуваються на вигляді.
const int32Array = new Int32Array(buffer);
// Ініціалізуємо значення за індексом 0.
int32Array[0] = 0;
// Створюємо нового воркера.
const worker = new Worker('worker.js');
// Надсилаємо СПІЛЬНИЙ буфер воркеру. Це передача посилання, а не копіювання.
worker.postMessage({ buffer });
// Слухаємо повідомлення від воркера.
worker.onmessage = (event) => {
console.log(`Воркер повідомив про завершення. Кінцеве значення: ${Atomics.load(int32Array, 0)}`);
};
}
Крок 3: Виконання атомарних операцій у воркері
Воркер отримує буфер і тепер може виконувати над ним атомарні операції.
worker.js:
self.onmessage = (event) => {
const { buffer } = event.data;
const int32Array = new Int32Array(buffer);
console.log("Воркер отримав спільний буфер.");
// Виконаємо кілька атомарних операцій.
for (let i = 0; i < 1000000; i++) {
// Безпечно інкрементуємо спільне значення.
Atomics.add(int32Array, 0, 1);
}
console.log("Воркер завершив інкрементування.");
// Повідомляємо основний потік, що ми закінчили.
self.postMessage({ done: true });
};
Крок 4: Складніший приклад — паралельне сумування з синхронізацією
Розглянемо більш реалістичну проблему: сумування дуже великого масиву чисел за допомогою кількох воркерів. Ми будемо використовувати Atomics.wait()
та Atomics.notify()
для ефективної синхронізації.
Наш спільний буфер матиме три частини:
- Індекс 0: Прапорець стану (0 = обробка, 1 = завершено).
- Індекс 1: Лічильник завершених воркерів.
- Індекс 2: Кінцева сума.
main.js:
if (self.crossOriginIsolated) {
const NUM_WORKERS = 4;
const DATA_SIZE = 10_000_000;
// [статус, воркери_завершили, результат_низький, результат_високий]
// Ми використовуємо два 32-бітних цілих числа для результату, щоб уникнути переповнення для великих сум.
const sharedBuffer = new SharedArrayBuffer(4 * 4); // 4 цілих числа
const sharedArray = new Int32Array(sharedBuffer);
// Генеруємо випадкові дані для обробки
const data = new Uint8Array(DATA_SIZE);
for (let i = 0; i < DATA_SIZE; i++) {
data[i] = Math.floor(Math.random() * 10);
}
const chunkSize = Math.ceil(DATA_SIZE / NUM_WORKERS);
for (let i = 0; i < NUM_WORKERS; i++) {
const worker = new Worker('sum_worker.js');
const start = i * chunkSize;
const end = Math.min(start + chunkSize, DATA_SIZE);
// Створюємо неспільний вигляд для частини даних воркера
const dataChunk = data.subarray(start, end);
worker.postMessage({
sharedBuffer,
dataChunk // Це копіюється
});
}
console.log('Основний потік тепер чекає на завершення роботи воркерів...');
// Чекаємо, поки прапорець стану за індексом 0 стане 1
// Це набагато краще, ніж цикл while!
Atomics.wait(sharedArray, 0, 0); // Чекати, якщо sharedArray[0] дорівнює 0
console.log('Основний потік розбуджено!');
const finalSum = Atomics.load(sharedArray, 2);
console.log(`Кінцева паралельна сума: ${finalSum}`);
} else {
console.error('Сторінка не є міждоменно ізольованою.');
}
sum_worker.js:
self.onmessage = ({ data }) => {
const { sharedBuffer, dataChunk } = data;
const sharedArray = new Int32Array(sharedBuffer);
// Обчислюємо суму для частини цього воркера
let localSum = 0;
for (let i = 0; i < dataChunk.length; i++) {
localSum += dataChunk[i];
}
// Атомарно додаємо локальну суму до спільної загальної суми
Atomics.add(sharedArray, 2, localSum);
// Атомарно інкрементуємо лічильник 'воркери завершили'
const finishedCount = Atomics.add(sharedArray, 1, 1) + 1;
// Якщо це останній воркер, що завершив роботу...
const NUM_WORKERS = 4; // У реальному застосунку це значення слід передавати
if (finishedCount === NUM_WORKERS) {
console.log('Останній воркер завершив роботу. Повідомляємо основний потік.');
// 1. Встановлюємо прапорець стану в 1 (завершено)
Atomics.store(sharedArray, 0, 1);
// 2. Повідомляємо основний потік, який очікує на індексі 0
Atomics.notify(sharedArray, 0, 1);
}
};
Реальні сценарії використання та застосування
Де ця потужна, але складна технологія насправді має значення? Вона відмінно проявляє себе в застосунках, що вимагають важких, паралельних обчислень над великими наборами даних.
- WebAssembly (Wasm): Це головний сценарій використання. Мови, такі як C++, Rust та Go, мають зрілу підтримку багатопотоковості. Wasm дозволяє розробникам компілювати існуючі високопродуктивні, багатопотокові застосунки (такі як ігрові рушії, програми САПР та наукові моделі) для запуску в браузері, використовуючи
SharedArrayBuffer
як основний механізм для комунікації між потоками. - Обробка даних у браузері: Великомасштабна візуалізація даних, виведення моделей машинного навчання на стороні клієнта та наукові симуляції, що обробляють величезні обсяги даних, можуть бути значно прискорені.
- Редагування медіа: Застосування фільтрів до зображень високої роздільної здатності або виконання аудіообробки звукового файлу можна розбити на частини та обробляти паралельно кількома воркерами, забезпечуючи зворотний зв'язок користувачеві в реальному часі.
- Високопродуктивні ігри: Сучасні ігрові рушії значною мірою покладаються на багатопотоковість для фізики, ШІ та завантаження ресурсів.
SharedArrayBuffer
дає змогу створювати ігри консольної якості, що повністю працюють у браузері.
Виклики та заключні міркування
Хоча SharedArrayBuffer
є трансформаційною технологією, це не панацея. Це низькорівневий інструмент, що вимагає обережного поводження.
- Складність: Паралельне програмування є відомо складним. Налагодження станів гонитви та взаємних блокувань може бути неймовірно важким. Ви повинні мислити інакше про те, як керувати станом вашого застосунку.
- Взаємні блокування: Взаємне блокування (deadlock) виникає, коли два або більше потоків блокуються назавжди, кожен чекаючи, поки інший звільнить ресурс. Це може статися, якщо ви неправильно реалізуєте складні механізми блокування.
- Накладні витрати на безпеку: Вимога міждоменної ізоляції є значною перешкодою. Вона може порушити інтеграцію зі сторонніми сервісами, рекламою та платіжними шлюзами, якщо вони не підтримують необхідні заголовки CORS/CORP.
- Не для кожної проблеми: Для простих фонових завдань або операцій вводу-виводу традиційна модель Web Worker з
postMessage()
часто є простішою та достатньою. ВикористовуйтеSharedArrayBuffer
лише тоді, коли у вас є чітке, пов'язане з процесором вузьке місце, що стосується великих обсягів даних.
Висновок
SharedArrayBuffer
, у поєднанні з Atomics
та Web Workers, являє собою зміну парадигми для веброзробки. Він руйнує межі однопотокової моделі, запрошуючи новий клас потужних, продуктивних та складних застосунків у браузер. Він ставить вебплатформу на більш рівні умови з розробкою нативних застосунків для обчислювально інтенсивних завдань.
Подорож у паралельний JavaScript є складною і вимагає суворого підходу до керування станом, синхронізації та безпеки. Але для розробників, які прагнуть розширити межі можливого в Інтернеті — від синтезу звуку в реальному часі до складного 3D-рендерингу та наукових обчислень — володіння SharedArrayBuffer
більше не є просто опцією; це необхідна навичка для створення вебзастосунків наступного покоління.