1.SharedArrayBuffer 和跨域隔离策略
JavaScript 引擎自行访问和管理内存,并为编写和执行的每个程序或代码块分配内存,同时对内存中不存在的数据执行垃圾回收。
尽管 JavaScript 是一种内存管理语言,但也能管理数据,只是有缺陷。例如,JavaScript 可以为特定程序或变量分配超过内存所需的可用空间,当然这可能拖慢 JavaScript 的垃圾收集器。
为了让开发人员能够在多个线程之间分配和共享数据,引擎引入了 ArrayBuffer 和 SharedArrayBuffer。但是,Chrome 92 后需要站点支持跨域隔离 (cross-origin isolated),开发者可以在顶级文档上发送两个 HTTP 标头,从而允许访问 SharedArrayBuffer 等 Web API 并防止外部攻击(Spectre 攻击、跨源攻击等)。
Cross-Origin-Opener-Policy: same-origin
// 隔离当前页面与浏览器中的任何跨源弹出窗口
Cross-Origin-Embedder-Policy: require-corp
// 确保从当前网站加载的所有资源都已使用 CORP 加载
值得注意的是,COEP(
Cross-Origin-Embedder-Policy) 标头会破坏每个需要与浏览器中的跨源窗口通信的集成,例如: 来自第三方服务器的身份验证和付款等。通过在文档的顶层设置标头,则站点会处于安全的环境中,并提供对 Web API 的访问。
2. 什么是 SharedArrayBuffer
2.1 ArrayBuffer 代表字节数组
在讨论 SharedArrayBuffer 时很容易关注到 Shared、Array 和 Buffer 等字样。
Array 用于存储由不同数据类型(字符串、布尔值、数字和对象)组成的数据元素的数据结构,Buffer 是内存存储的一部分,用于在发送或接收数据之前临时存储数据。
而 ArrayBuffer 是一种与其他数组不同的数组,即 字节数组,其只接受字节,例如下面的示例:
const buffer = new ArrayBuffer(8);
// 创建一个 Int32Array 视图,指向同一个 ArrayBuffer
const int32View = new Int32Array(buffer);
// 设置值
int32View[0] = 42;
int32View[1] = 17;
console.log(int32View[0]);
// 输出: 42
console.log(int32View[1]);
// 输出: 17
2.2 SharedArrayBuffer 结构化克隆与跨线程共享
要在 JavaScript 中使用共享内存,开发者可以创建 SharedArrayBuffer,该对象创建一个新的对象构造函数,用于在多个线程之间写入和共享数据。
SharedArrayBuffer 表示通用的原始二进制数据缓冲区,类似于 ArrayBuffer ,但可用于在共享内存上创建视图。同时 SharedArrayBuffer 是不可传输的对象,这点与 ArrayBuffer 不同 。
为了使用 SharedArrayBuffer 对象从集群 (Cluster) 中的一个代理与另一个代理共享内存(代理可以是主程序也可以是 Worker),可以使用 postMessage 和 结构化克隆。
const sab = new SharedArrayBuffer(1024);
worker.postMessage(sab);
结构化克隆用于复制复杂的 JavaScript 对象,在调用 structuredClone() 时内部使用,用于通过 postMessage() 在 Workers 之间传输数据、使用 IndexedDB 存储对象等场景。
结构化克隆算法接受 SharedArrayBuffer 对象和映射到 SharedArrayBuffer 对象的类型化数组。在这两种情况下,SharedArrayBuffer 对象都会传输到接收方,从而在接收代理中产生一个 新的私有 SharedArrayBuffer 对象(就像 ArrayBuffer 一样)。但是,两个 SharedArrayBuffer 对象引用的共享数据块是同一个,并且一个代理中块的副作用最终会在另一个代理中显现出来。
let {SharedArrayBuffer=sayTheLineBart() } = globalThis;
console.assert(crossOriginIsolated === false, "The case of interest is specific to non-isolated");
try {
structuredClone(new SharedArrayBuffer(1));
console.assert(false, "Shouldn’t clone SAB at all without cross-origin isolation.");
} catch (err) {
console.assert(err.code === DOMException.DATA_CLONE_ERR, "Should throw DataCloneError");
}
function sayTheLineBart() {
return new WebAssembly.Memory({
initial: 0,
maximum: 0,
shared: 1
}).buffer.constructor;
}
共享内存可以在 Worker 线程或主线程中同时创建和更新。根据系统 CPU、操作系统、浏览器等的不同,更改可能需要一段时间才能传播到所有上下文。如果需要同步,则可以利用原子操作。
2018 年 1 月 5 日,由于在现代 CPU 架构中发现漏洞攻击,SharedArrayBuffer 在所有主流浏览器中被禁用。
后来 SharedArrayBuffer 在 Google Chrome v67 中重新启用,目前可以在启用了 ` 站点隔离功能 ` 的平台上使用,可防止 Spectre 漏洞攻击并使网站更安全。
3. 如何使用 SharedArrayBuffer
前面讲过,使用 SharedArrayBuffer 的一个好处是能够在 JavaScript 中共享内存。在 JavaScript 中,Web Worker 是创建 JS 线程的一种方式。
Web Worker 可以与 SharedArrayBuffer 一起使用,其通过直接指向每个数据存储或之前访问过的内存来实现 Web Worker 之间原始二进制数据的共享。
<script type="text/JavaScript" src="script.js"></script>
<script type="text/JavaScript" src="worker.js"></script>
下面是 worker.js 的核心代码:
const newWorker = new Worker('worker.js');
const buffMemLength = new SharedArrayBuffer(1024);
// 1024 字节长度
let typedArr = new Int16Array(buffMemLength);
// 初始化原始数据
typedArr[0] = 20;
// 将数据传递给 worker
newWorker.postMessage(buffMemLength);
为了与工作线程共享主线程的数据,工作线程设置了一个 message 事件监听器,在接收到 data 时运行并修改数据。
let BYTE_PER_LENTH = 5;
addEventListener('message', ({ data}) => {
var arr = new Int16Array(data);
console.group('[worker thread]');
console.log('Data received from main thread: %i', arr[0]);
console.groupEnd();
// 从 worker 线程更新数据
let dataChanged = 5 * BYTE_PER_LENTH;
arr[0] = dataChanged;
// 通知主线程
postMessage('Updated');
})
同时,开发者可以在主线程添加一个 onmessage 事件接受 Worker 线程的数据更新事件:
const newWorker = new Worker('worker.js');
const buffMemLength = new SharedArrayBuffer(1024);
var typedArr = new Int16Array(buffMemLength);
typedArr[0] = 20;
newWorker.postMessage(buffMemLength);
// 添加 onmessage
newWorker.onmessage = (e) => {
console.group('[the main thread]');
console.log('Data updated from the worker thread: %i', typedArr[0]);
console.groupEnd();
}
同步共享内存非常重要,其可以在多线程同时运行时不会发生意外更改,例如:数据不一致等。为了在共享内存中加入同步,开发人员需要使用原子操作 (Atomics)。
原子操作确保每个进程在下一个进程之前连续执行,并且所有从内存读取或写入特定内存的数据都在 wait() 和 notify() 方法的辅助下一个接一个地执行。下面的示例表示写入线程存储新值并在写入完成后通知等待线程:
const sab = new SharedArrayBuffer(1024);
const int32 = new Int32Array(sab);
console.log(int32[0]); // 0;
Atomics.store(int32, 0, 123);
Atomics.notify(int32, 0, 1);
参考资料
https://blog.logrocket.com/understanding-sharedarraybuffer-and-cross-origin-isolation/
https://zhuanlan.zhihu.com/p/11809045697
https://webreflection.medium.com/about-sharedarraybuffer-atomics-87f97ddfc098
https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics
https://www.hongkiat.com/blog/shared-memory-in-javascript/
https://blog.persistent.info/2021/08/worker-loop.html