SharedArrayBuffer и историю с отключением во всех браузерах.SharedArrayBuffer — почти то же самое, что ArrayBuffer, но с одним принципиальным отличием: его можно передать в несколько Web Workers одновременно, и все они будут работать с одной и той же памятью.
const sab = new SharedArrayBuffer(256);
worker.postMessage(sab); // worker получает доступ к той же памяти
С обычным
ArrayBuffer при передаче через postMessage происходит transfer — оригинал становится недоступным, а получатель получает владение. Это сделано намеренно: два потока не должны одновременно писать в одну память без синхронизации. SharedArrayBuffer снимает это ограничение — но тогда синхронизацию нужно делать самостоятельно. Для этого существует объект Atomics. Он предоставляет атомарные операции — гарантированно неделимые чтение-запись:
const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);
// В воркере 1:
Atomics.add(view, 0, 1); // атомарный инкремент
// В воркере 2:
Atomics.load(view, 0); // атомарное чтение
Без
Atomics параллельная запись в SharedArrayBuffer приводит к data race — результат непредсказуем. Atomics.add, Atomics.load и другие методы гарантируют, что операция выполнится целиком, без прерывания другим потоком. Есть и механизм ожидания: Atomics.wait(view, index, expectedValue) блокирует поток, пока значение по индексу равно ожидаемому. Atomics.notify(view, index) будит ждущие потоки.У
SharedArrayBuffer непростая история. В начале 2018 года его отключили во всех браузерах — Chrome, Firefox, Safari, Edge — одновременно. Причина: атака Spectre. Чтобы понять, при чём тут SharedArrayBuffer, нужно разобрать саму атаку — она на самом деле довольно изящная.Современные процессоры не ждут, пока проверится условие
if — они предсказывают результат и выполняют код «наперёд». Если предсказание верное — результат уже готов. Если нет — процессор откатывает результат. Но есть нюанс: данные, которые попали в кеш процессора во время спекуляции, остаются там даже после отката.Допустим, в памяти процесса (рядом с нашим JS-кодом) лежит секретный байт — например, кусок данных от другой вкладки. Напрямую прочитать его нельзя, есть проверка границ. Но атакующий может обойти её через спекуляцию.
Шаг 1: атакующий много раз вызывает код с валидным индексом. Предсказатель ветвлений запоминает: «условие всегда true».
Шаг 2: атакующий подаёт индекс, который выходит за границу массива и указывает на секретный байт. Процессор по привычке предсказывает «true» и спекулятивно читает секрет — допустим, значение
42.Шаг 3: спекулятивный код использует прочитанное значение как индекс в другом массиве —
probeArray[42 * 256]. Эта ячейка попадает в кеш. Потом процессор понимает, что условие было false, и откатывает всё. Но кеш не откатывается.Шаг 4: атакующий перебирает все 256 возможных значений, обращаясь к
probeArray[0 * 256], probeArray[1 * 256], …, probeArray[255 * 256], и замеряет время каждого обращения. Одна ячейка отвечает за ~3 наносекунды (из кеша), все остальные — за ~100 наносекунд (из оперативной памяти). Быстрая ячейка выдаёт значение секрета: 42.Секрет утёк не напрямую, а через побочный канал: значение было использован как индекс в массиве и он оставил физический след в кеше.
Вся атака стоит на способности различить «3 наносекунды» и «100 наносекунд». Для этого нужен очень точный таймер.
performance.now() давал точность ~5 микросекунд — это всё ещё достаточно грубо. А SharedArrayBuffer позволяет собрать самодельный наносекундный таймер буквально в пять строк:
// Воркер-таймер:
const counter = new Uint32Array(sharedBuffer);
while (true) {
counter[0]++;
}
// Основной поток:
const start = counter[0];
// ... замеряем что-то ...
const elapsed = counter[0] - start;
Один воркер крутит счётчик в бесконечном цикле, другой поток читает его до и после интересующей операции. Разница — время выполнения с точностью вплоть до наносекунд. Этого достаточно, чтобы отличить попадание в кеш от промаха, а значит — достаточно для Spectre.