SharedArrayBuffer和Atomics是什么

快速的回答

SharedArrayBuffer是用来创建共享内存的,和谁共享呢?和多个线程之间共享。

Atomics对象提供了一组静态的方法对SharedArrayBuffer的对象进行原子操作。

什么是原子操作?原子操作就是该操作在进行时,其他对相同区域的操作不能并行,是独占式的,不可分割。

为什么需要SharedArrayBuffer

一直以来Web前端对于开发人员来说都是单线程的,JavaScript在运行时页面是不能响应用户的交互的,因此如果运行时间较长,用户就会感觉页面卡死了。这就是单线程的缺陷。(这里不要误会,浏览器的各种后台服务可都是多线程的,只是开发人员只能在UI线程中执行代码,所以对于开发人员来说是单线程的,但是浏览器本身肯定是多线程的)

若干年前,HTML5带来了一个新的特性就是多线程,其名字叫Web Worker,一个主现线程和其他Worker线程形成了HTML5的多线程。

这里要提一下的是,Web Worker是HTML5的新特性,其标准是由W3C制定的,而不是ECMA,因此它不是JavaScript语言的特性,它是一个浏览器特性,只是借助JavaScript表现出来罢了。

Web Worker给前端CPU密集型的应用带来了可能,例如Web版的导航、游戏,开发人员可以将成千上万次的循环放到Worker中去做,这样UI界面就不会因此而卡顿。

但是Web Worker在线程通信方面有一些问题。

Web Worker要么只能通过transfer将数据转给另一个线程,一旦转过去后,原线程就不能访问该数据了。

要么就是数据在线程间的传递速度很慢,每次都需要通过structuredClone()的形式进行深度拷贝数据。
举例如下:
HTML页面的<script>标签中:

// 创建一个Worker线程
const worker = new Worker('worker.js');
const o = {a: 1, b: 5};
const p = {o, x: 'a'};
// p通过structuredClone(),被深度拷贝到Worker线程
worker.postMessage(p);

worker.js文件:

self.addEventListener('message', m => {
  // 最少都需要几十毫秒后,才能收到数据
  // m.data是p的深度拷贝复制品
  console.log(m.data);
});

这虽然简化了线程间的通信,但是性能却大大降低了。
用笔者本地的Chrome测试,postMessage发出数据后,worker.js中最少都得要几十毫秒后才能收到数据,worker.js处理完数据后再将数据传回UI主线程又需要几十毫秒,很有可能光是传数据就消耗掉了一百多毫秒,这对于性能要求较高的应用来说是不可接受的。

因此其实一直以来Web Worker都是一个鸡肋,大家基本不用,如果有大量的计算,我们采用setTimeout来进行分片,也能解决问题。

还好,SharedArrayBuffer来了,Web Worker可以不再是鸡肋了。

SharedArrayBuffer可以在内存中开辟一块供UI线程和Web Worker共享的一片内存,有了这片共享内存,线程间就可以交互了,对于线程来说,这种通信成本相对于postMessage来说,成本几乎为零。

如果某线程想要和其他线程通信,只需要将数据放入SharedArrayBuffer指定的内存中就行,其他线程看到数据有变化,直接读取这片内存。

举例如下:

UI主线程:

// 创建Web Worker
const worker = new Worker('worker.js');

// 创建SharedArrayBuffer
const length = 10;
const sharedBuffer = new SharedArrayBuffer(length * Int32Array.BYTES_PER_ELEMENT);

// 用Int32Array初始化sharedBuffer
const sharedArray = new Int32Array(sharedBuffer); 
for (let i = 0; i < length; i++)  {
	sharedArray[i] = 0;
}

// 将sharedBuffer传给Web Worker,采用structuredClone()深度拷贝
worker.postMessage(sharedBuffer);


// 模拟从服务端获取数据
setTimeout(function() {
	console.log('开始传递数据处', Date.now());
	// 最关键的一步,将数据放入共享内存
    sharedArray[0] = 100;
}, 500);

worker.js文件:

self.addEventListener('message', (m) => {
    // m.data就是sharedBuffer的拷贝
	// 基于sharedBuffer创建Int32Array
    const sharedArray = new Int32Array(m.data)

	// 最关键的一步,只要sharedArray[0]是0就一直循环等待。注意最后的分号,它代表空语句
    while (sharedArray[0] === 0);
	console.log('收到新数据处', Date.now());
	// sharedArray[0]的值已经变化,可以进行处理了
    console.log('新值是' + sharedArray[0])
});

上面的例子分别列出了UI主线程和worker.js文件的代码。
我们看worker.js文件中, while (sharedArray[0] === 0)这段代码,对,它在死循环,在我们平时前端的编程中,这种代码是很恐怖的,它可以让整个页面永久性的卡死,用户将不得不关闭页面。
但是在Web Worker中不会,首先它不会影响主线程的执行,用户的交互可以继续;其次,由于sharedArray指向的是共享内存,UI主线程一旦更改了这片内存,sharedArray的值也就立即更改了,循环就会退出,后面的代码得以执行,并且拿到了主线程中获取到的新数据。

如果你像例子中一样在主线程sharedArray[0] = 100处和work.jswhile循环后面加上时间戳打印,你会发现它们的时间间隔已经在毫秒级以下。这才是我们需要的高性能。

看到这里相信大家已经大概知道SharedArrayBuffer是干什么用的了。

通过例子我们可以看到,关于SharedArrayBuffer的几个关键用法:

  1. SharedArrayBuffer创建实例时必须使用new关键字。
  2. 我们依然需要postMessage,其参数是SharedArrayBuffer的实例sharedBuffer。而且依然会执行structuredClone()深拷贝,因此sharedBuffer会被复制,但是sharedBuffer所指向的那片共享内存不会被复制。
  3. 如果要对SharedArrayBuffer进行操作,必须通过TypedArray来完成,在例子中用的是Int32Array,这一点和ArrayBuffer一致。

SharedArrayBuffer的历史

Chrome团队在官方博客中承认,SharedArrayBuffer的落地有些太匆忙了。它有点像潘多拉魔盒,一旦打开可能不好驾驭。

2017年6月份SharedArrayBuffer在Chrome落地。

2018年1月份爆出了一个漏洞。该漏洞的根源是新型CPU架构的分支预测和乱序执行等新型功能导致的,虽然这些功能本身并不会造成内存的非法访问,但是结合上时序攻击这种侧信道攻击技术,攻击程序就可以获取到本不应该被它访问到的数据。例如其他网站的密码信息、token信息等。而说到时序攻击则需要精准的时间掌控,而当时的performance.now()可以返回精确到纳秒级别的时间戳,精度上足以用于记录CPU的执行时间,因此performance.now()成了帮凶,导致整个浏览器也成了帮凶。

作为浏览器厂商其实无法从根本上解决该问题,但是浏览器厂商可以通过一些措施来让该攻击至少不要在浏览器中发生,因此performance.now()的精度被降低了,变成了微妙级别的精度。

但是没想到的是,虽然performance.now()的精度降低了,还有一个东西可以用来做时间衡量,而且精度足够高,那就是SharedArrayBuffer,利用SharedArrayBuffer可以做到纳秒级别的时间精度控制,示例代码如下:

UI主线程代码:

var buffer = new SharedArrayBuffer(16);
var counter = new Worker("counter.js");
counter.postMessage([buffer], [buffer]);
var arr = new Uint32Array(buffer);
// 当需要时间戳时,可以这样获取
timestamp = arr[0];

counter.js的代码:

self.onmessage = function(event) {
	var [buffer] = event.data;
	var arr = new Uint32Array(buffer);
	while(1) {
		arr[0]++;
	}
}

例子中counter.jswhile循环的速度是纳秒级别的,而数据又通过SharedArrayBuffer被高效传递给了主线程,同样也是纳秒级别的,最终主线程就获取到了一个纳秒级别的时间戳,循环开始执行就是这个时间戳的起点。

因此SharedArrayBuffer也成了帮凶。

所以SharedArrayBuffer在当时也一起被禁用了。

这就是潘多拉的魔盒,刚打开没多久,就完成了它的首秀。

后来Chrome团队也想出各种办法,来尝试重新开启SharedArrayBuffer,但都不完善。

终于在2020年,负责Web标准的那帮人想出了一个方案,可以让SharedArrayBuffer复出,同时又没有安全威胁。那就是网站必须声明自己只有在不同源的其他的网站同意的情况下才能内嵌其他网站的内容。SharedArrayBuffer又复出了。

下面讲述具体如何声明。

SharedArrayBuffer需要安全环境

需要两个HTTP头

网站必须声明两个HTTP头,SharedArrayBuffer才会变成可用状态,否则SharedArrayBufferundefined
这两个HTTP头是:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Cross-Origin-Embedder-Policy还可以是credentialless,不过目前主流浏览器还没有完成支持。

Cross-Origin-Opener-Policy: same-origin表示所有的弹窗中,只有同源的才会带window.opener

Cross-Origin-Embedder-Policy: require-corp的意思是,如果你要嵌入的内容是跨域的,那就需要对方声明Cross-Origin-Resource-Policy或者CORS

关于安全相关的HTTP头,我们将会另开一个系列进行讲解。

SharedArrayBuffer需要安全上下文

另外还需要在安全上下文中,所谓安全上下文就是:
如果服务在本地:http://127.0.0.1 http://localhost file://等这样的网址。
如果服务在远程服务器:https://或者wss://

说了这么多SharedArrayBuffer,现在该说说Atomics了。

Atomics

SharedArrayBuffer解决了Web Worker之间通信效率的问题,但是带来了新的问题。
Atomics就是来解决这些新问题的。
Atomics对象提供了一组静态的方法对SharedArrayBuffer的对象进行原子操作。

为什么要进行原子操作呢?

咱们继续说上面SharedArrayBuffer用于线程通信的例子,该例子的代码其实有些问题。

我们再复习一下这个例子:

UI主线程:

// 创建Web Worker
const worker = new Worker('worker.js');

// 创建SharedArrayBuffer
const length = 10;
const sharedBuffer = new SharedArrayBuffer(length * Int32Array.BYTES_PER_ELEMENT);

// 用Int32Array初始化sharedBuffer
const sharedArray = new Int32Array(sharedBuffer); 
for (let i = 0; i < length; i++)  {
	sharedArray[i] = 0;
}

// 将sharedBuffer传给Web Worker,采用structuredClone深度拷贝
worker.postMessage(sharedBuffer);


// 模拟从服务端获取数据
setTimeout(function() {
	console.log('开始传递数据处', Date.now());
	// 最关键的一步,将数据放入共享内存
    sharedArray[0] = 100;
}, 500);

worker.js文件:

self.addEventListener('message', (m) => {
    // m.data就是sharedBuffer的拷贝
	// 基于sharedBuffer创建Int32Array
    const sharedArray = new Int32Array(m.data)

	// 最关键的一步,只要sharedArray[0]是0就一直循环等待。注意最后的分号,它代表空语句
    while (sharedArray[0] === 0);
	console.log('收到新数据处', Date.now());
	// sharedArray[0]的值已经变化,可以进行处理了
    console.log('新值是' + sharedArray[0])
});

上面的代码其实有一些丑陋,无论如何,手写死循环都不算是优美的代码,而且还可能有问题。咱们看看会有什么问题。

在没有Web Worker时,下面的这段代码是可以优化的:

while (sharedArray[0] === 0) {...}

如果循环内部不去修改sharedArray[0],那这个循环会被编译器优化成:

const tmp = sharedArray[0];
while (tmp === 0) {
	...
};

这样就不用每次都去访问sharedArray[0],只需要读取临时变量tmp,这会大大提高运行效率。

为什么可以这样优化呢?要是sharedArray[0]变了怎么办?
答案是,在单线程程序中,当执行while循环时,程序根本无暇去执行循环以外的代码,因此sharedArray[0]不会被改变,因此可以把它缓存到tmp中,以便加快访问速度。

但是在多线程中,这个优化就会有问题,因为其他线程有可能在当前线程循环时改变sharedArray[0]所指向的那片共享内存,而优化后的临时变量tmp是无法感知这种变化的,从而导致循环一直无法退出。

因此在多线程中继续使用这种优化就会带来问题。类似的问题还有不少,都是和编译器优化相关的。

不过Chrome等浏览器都会在恰当的时候关闭一些优化,因此你可能不一定再现得出来,但是副作用是性能的下降。

那这些丑陋的代码和性能的下降就没有一个更好的解决方法吗?让大家都去写死循环来做线程间通信,总是感觉不太靠谱。

这时就该Atomics出场了。

while (sharedArray[0] === 0);这个循环的目的就是为了等待sharedArray[0]变成非0,使用Atomics可以这样写:

Atomics.wait(sharedArray, 0, 0);

Atomics.wait可以让线程进入睡眠状态,直到被唤醒。该方法第一要参数是目标TypedArray数组,第二个参数是数组的索引,第三个参数是预期值,只要给定索引处的值和预期值相同,则线程会一直处于睡眠状态,直到收到其他写入线程对值已经变化的通知。

那其他写入线程怎么才能通知呢?可以使用以下API:

Atomics.notify(int32, 0);

在看一下使用这两个Atomics API的新例子代码:

UI主线程:

// 创建Web Worker
const worker = new Worker('worker.js');

// 创建SharedArrayBuffer
const length = 10;
const sharedBuffer = new SharedArrayBuffer(length * Int32Array.BYTES_PER_ELEMENT);

// 用Int32Array初始化sharedBuffer
const sharedArray = new Int32Array(sharedBuffer); 
for (let i = 0; i < length; i++)  {
	sharedArray[i] = 0;
}

// 将sharedBuffer传给Web Worker,采用structuredClone深度拷贝
worker.postMessage(sharedBuffer);


// 模拟从服务端获取数据
setTimeout(function() {
	console.log('开始传递数据处', Date.now());
	// 最关键的一步,将数据放入共享内存
    sharedArray[0] = 100;
	Atomics.notify(sharedArray, 0);
}, 500);

worker.js文件:

self.addEventListener('message', (m) => {
    // m.data就是sharedBuffer的拷贝
	// 基于sharedBuffer创建Int32Array
    const sharedArray = new Int32Array(m.data)

	Atomics.wait(sharedArray, 0, 0);
	console.log('收到新数据', Date.now());
	// sharedArray[0]的值已经变化,可以进行处理了
    console.log('新值是', sharedArray[0])
});

循环被Atomics.wait替代,同时写入线程中多了Atomics.notify

Atomics还有若干其他API,例如Atomics.store Atomics.load,一个对应着读,一个对应着写。

我们考虑如下场景:

A线程和B线程同时向SharedArrayBuffer写入数据,这个写数据的过程并不是不可分割的原子操作,很有可能最终A线程和B线程写入的数据之间相互覆盖,最后SharedArrayBuffer存储的是错误的废数据。

这时就需要Atomics.store,它可以保证每次写操作都是独占式的,每个写操作都必须等待上一个相同位置的写操作完成,才能继续写操作。

再考虑如下场景:
A线程向SharedArrayBuffer写入数据,B线程向同一个位置读取数据,如果写操作还没有完成就读取数据,那将读到脏数据。

这时就需要Atomics.load,它可以保证每次读取都是在Atomics.store写操作完成后进行。

从上面的例子可以看出,当多个线程对同一片内存进行操作时,会有很多问题,而Atomics就是用来解决这些问题的。建议所有对SharedArrayBuffer的操作都通过Atomics的API来完成。

结束语

本篇主要是为了让大家理解SharedArrayBufferAtomics,具体的用法大家可以查 MDN:SharedArrayBufferAtomics

原文链接:https://juejin.cn/post/7328319117601439778 作者:吉灵云

(0)
上一篇 2024年1月28日 上午11:02
下一篇 2024年1月28日 上午11:12

相关推荐

发表回复

登录后才能评论