learn-tech/专栏/JavaScript进阶实战课/28性能:如何理解JavaScript中的并行、并发?(下).md
2024-10-16 06:37:41 +08:00

15 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        28 性能如何理解JavaScript中的并行、并发
                        你好,我是石川。

在上一讲中我们初步介绍了并发和并行的概念对比了不同语言对多线程开发的支持。我们也通过postMessage学习了用信息传递的方式在主线程和Worker线程间实现交互。但是我们也发现了JavaScript对比其它语言在多线程方面还有不足似乎信息传递本身不能让数据在不同的线程中真正做到共享而只是互传拷贝的信息。

所以今天,我们再来看看如何能在信息互传的基础上,让数据真正地在多线程间共享和修改。不过更重要的是,这种修改是不是真的有必要呢。

SAB+Atomics模式

前面我们说过对象的数据结构在线程间是不能共享的。如果通过postMessage来做信息传递的话需要数据先被深拷贝。那有没有什么办法能让不同的线程同时访问一个数据源呢答案是有要做到数据的共享也就是内存共享我们就需要用到 SABSharedArrayBuffer和 Atomics。下面我们先从SAB开始了解。

共享的ArrayBuffer

SAB是一个共享的ArrayBuffer内存块。在说到SAB前我们先看看ArrayBuffer是什么这还要从内存说起。我们可以把内存想象成一个储藏室中的货架为了找到存放的物品有从1-9这样的地址。而里面存储的物品是用字节表示的字节通常是内存中最小的值单元里面可以有不同数量的比特bit)比如一个字节byte里可以有8、32或64比特。我们可以看到 bit 和 byte 它俩的英文写法和读音有些相似,所以这里要注意不要把字节和比特混淆。

还有一点需要注意的是计算机在内存中的数据存储是二进制的比如数字2的二进制写法就是00000010用8个比特来表示就如下图所示。如果是字母的话则可以先通过UTF-8这样的方式先转换成数字再转换为二进制。比如字母H转换成数字就是72再转换为二进制就是01001000。

在JavaScript语言当中内存管理是自动的也就是说当我们敲一行代码后我们的虚机会自动地帮助我们在内存中找到剩余的空间把数据放进去存储。并且会追踪这段代码在我们的程序中是否还可以被访问到如果发现已经无法访问了就会做相关的清除处理。这个过程也被称之为垃圾回收。

如果你使用C语言编写再编译成WebAssembly的话那么基于C语言的手动内存管理和垃圾回收的机制你就需要通过内存分配malloc的功能从一个空闲列表free list中找到存放位置来存放使用后再通过释放free的功能将内存释放。

再回到JavaScript的场景中为什么我们前面要介绍自动和手工的内存管理呢

这也就回到了我们上一讲最后留的问题了就是为什么说使用ArrayBuffer的性能更高。这里我们顺便解决下上节课的思考题如果在开发中使用更高级的数据类型并且把数据处理的工作完全交给JavaScript的虚机如V8来处理这样确实能给我们带来方便但同时副作用就是降低了性能极度调优的灵活性。比如当我们创建一个变量的时候虚机为了猜测它的类型和在内存中的表现形式可能要花费2-8倍的内存。而且有些对象创建和使用的方式可能会增加垃圾回收的难度。

但如果我们使用ArrayBuffer这样更原始的数据类型并通过C语言来编写程序并编译成WebAssembly的话可以给开发者更多的控制来根据使用场景更细粒度地管理内存的分配提高程序的性能。

那一个ArrayBuffer和我们经常用的数组有什么区别呢一起来看下面的代码一个普通的数组中可以有数字、字符串、对象等不同类型的数据但是在ArrayBuffer当中我们唯一可用的就是字节。

// 数组 [5, {prop: "value"}, "一个字符串"]

[0] = 5 [1] = {prop: "value"} [2] = "一个字符串"

// ArrayBuffer [01001011101000000111]

这里的字节虽然可以用一串数字表示但是有个问题是机器怎么能知道它的单位呢比如我前面介绍的这串数字本身是没意义的只有根据不同的8、32或者64比特单位它才能具有意义。这时我们就需要一个view来给它分段。

所以一个ArrayBuffer中的数据是不能直接被操作而要通过 TypedArray 或 DataView 来操作。

// main.js var worker = new Worker('worker.js');

// 创建一个1KB大小的ArrayBuffer var buffer = new SharedArrayBuffer(1024);

// 创建一个TypedArray的DataView var view = new Uint8Array(buffer);

// 传递信息 worker.postMessage(buffer);

setTimeout(() => { // buffer中的第1个字节 console.log('later', view[0]); // later 5 // buffer中foo的属性值 console.log('prop', buffer.foo); // prop 32 }, 1000);

// worker.js self.onmessage = ({data: buffer}) => { buffer.foo = 32; var view = new Uint8Array(buffer); view[0] = 5; }

其实一个ArrayBuffer或SAB在初始化的时候也是要用到postMessage和结构化拷贝算法的。但是和信息传递不同的是这里在请求端发起请求时传入的数据被拷贝后如果在接收端做了修改这个修改后的数据的指向和之前的数据是一致的。我们可以对比下普通的postMessage和ArrayBuffer以及SAB的区别。

所以我们在上面SAB的例子中可以发现通过setTimeout而不是onmessage就可以获取在worker.js修改后的buffer的字节和属性了。但这里需要注意的是字节的数量在SAB中是一开始就定好且不可修改的。

Atomics和原子性

说完了SharedArrayBuffer我们再来看看原子性。既然数据要共享就要考虑原子性的问题。

如果你有做过数据库开发的话,应该听过 ACID 的原则,它是原子性、一致性、隔离性、持久性的缩写。原子性指的是一个事务中的所有操作,或者全部完成,或者全部不完成,不会结束在中间环节。任务在执行过程中发生的错误,都会被回滚到初始状态。这样做的结果是,事务不可分割、不可约简。

那为什么在数据库的开发中,会如此重视原子性呢?

你可以想想,如果我们单独看一个客户端的请求,它可能是原子性的,可如果是几个请求,可能就不是原子性的了。但是如果这些请求都属于同一个交易,那么当用户成功付款后,付款结果没能抵达电商接口,这个交易是不完整的,不仅在经济上可能造成损失,并且会给客户带来很不好的体验。所以从这个角度来看,包含这三条请求的整个交易就是一个原子性事务。

同样的,在分布式的设计中,一个网络中的不同节点间的互动也应该保证原子性的原则。那么再回到线程,我们说一个计算机中不同的线程对一个共享的数据也应该保持原子性的操作。

那这时你可能会问如我们之前所说并发中我们的程序就很容易进入一个竞争条件race condition那既然在并发设计中需要让事务保持原子性那在JavaScript中面对并发怎么处理

别担心这个问题可以通过JavaScript提供的原子Atomics来解决。Atomics提供了所需的工具来进行原子性的操作并且提供了线程安全的等待机制。在JavaScript中有一个全局的Atomics对象它自带一些内置的方法。

在SAB的内存管理中上述这些方法可以解决3大类的问题。第一个问题是在单个操作中的竞争条件第二个问题是在多个操作中的竞争条件第三个问题是在指令顺序造成的问题。下面我们依次来看一下。

单个操作中的竞争条件

这里你可能会好奇一个单个操作为什么还会有竞争举个例子如果我们用2个工作线程都对一个数字做+1的增量运算你可能觉得无论谁操作都一样结果都是+1但是问题并没有这么简单。因为在实际计算的时候我们的数据是会从内存中取出放到寄存器里然后通过运算器来运算的这个时候如果有一个数字6同时被工作线程1和2取出然后计算+1那么结果可能就是7而不是8。因为这两个线程在访问内存中的数据计算前收到的都是6所以+1的结果被覆盖计算了2次。

那为了解决这个问题,上面提到的 Atomics.add()、Atomics.sub()、Atomics.and()、Atomics.or()、Atomics.xor()、Atomics.exchange() 等运算就是很好地避免这一类问题的方法。如果要做乘法除法则可以通过Atomics.compareExchange()来创建相关的功能。

多个操作中的竞争条件

说完了单个操作中的竞争条件我们再来看看多个操作中的竞争条件。在JavaScript中我们可以通过 futex 来起到互斥锁的作用。它来源于Linux内核中有一种互斥锁mutex叫做快速用户空间互斥体futexfast userspace mutex的锁。futex中有两个方法一个是Atomics.wait()另外一个是Atomics.wake()。这里也很好基于字面意思来理解,一个代表等待,一个代表唤醒。

在用锁的时候我们要注意前端浏览器中主线程是不允许加锁的在后端的Node中主线程是可以加锁的。之所以在前端浏览器中不能加锁是因为阻碍JavaScript的主线程执行对用户体验的影响实在太大了而对于后端来讲则没有这么直接的影响。

如果在前端主线程想使用wait()也不是完全没办法这里可以使用waitAsync()。相比wait()可以暂停主线程再传递字符串waitAsync要另起线程所以从性能上来说它比wait()会差一些。所以对于热路径hotpath也就是程序中那些会被频繁执行到的代码来说可能不是那么有用但是对于非信息传递类的工作来说比如通知另外的线程它还是有用的。

指令顺序造成的竞争条件

最后,我们再来看看指令顺序造成的竞争条件。如果你对计算机有芯片层面的理解的话,就会知道我们的代码在指令执行的流水线层面会被重新排序。如果在单线程的情况下,这可能不会造成什么问题,因为其它的代码需要在当前的函数在调用栈中完成执行才看到结果。但是如果在多线程下,其它的线程在结果出现前,就可能看到变化而没有考虑后序运行的代码指令结果。那这个问题要怎么解决呢?

这个时候,就要用到 Atomics.store() 和 Atomics.load()。函数中 Atomics.store() 以前的所有变量更新都保证在将变量值写回内存之前完成,函数中 Atomics.load() 之后的所有变量加载都保证在获取变量值之后完成。这样就避免了指令顺序造成的竞争条件。

数据传输的序列化

在使用SAB的时候还有一点需要注意的是数据的序列化也就是说我们在使用它来传递字符串、布尔或对象的时候都需要一个编码和解码的过程。特别是对象因为我们知道对象类型是没法直接传递的所以这个时候我们就需要用到“通过JSON将对象序列化成字符串”的方法所以它更适合用postMessage和onmessage来做传递而不是通过SAB。

Actor Model模式

通过上面的例子我们可以看出直接使用SAB+Atomics的方式还是蛮复杂的稍有不慎可能引起的性能问题远远大于优化的效果。所以除非真的是研发型的项目否则只是纯应用类的项目最好是通过一些成熟的库或者WebAssembly将复杂的内存管理抽象成简单的接口这样的方式会更加适合。另外我们也可以看看SAB+Atomics的一个替代方案Actor Model 模式。

在Actor Model模式中因为Actor是分布在不同的进程里的如我们之前所说进程间的内存是不共享的每个Actor不一定在同一个线程上而且各自管理自己的数据不会被其它线程访问到所以就没有互斥锁和线程安全的问题。Actor之间只能互相传递和接收信息。

这种模式更符合JavaScript的设计因为它本身就对手动内存管理的支持度不高所以在 Actor Model 这种模式下我们只是通过线程做信息传递而不做共享。但是这并不代表主线程和Worker间的影响就仅限于信息传递比如通过Worker传给主线程的数据主线程完全可以自行基于接收到的数据来改变DOM只是在这个过程中需要自己做一些转换的工作。

这里,我们针对数据量较大的信息传递时,应该注意一些优化策略:

我们可以将任务切分成小块儿依次传递; 每次我们可以选择传递delta也就是有变化的部分而不是将全量数据进行传递 如果我们传递的频率过高,也可以将消息捆绑来传递; 最后一点就是通过ArrayBuffer提高性能。

总结

通过这两节课的学习,我们可以看到多线程的开发在前端还有很长的路要走。

我们也看到了SAB+Atomics的模式虽然从某种程度上看在JavaScript中可以实现但实际上Actor Model更易于在JavaScript中的使用特别是在前端场景中。很明显我们不想因为多线程对同一组对象的并行修改而引起竞争条件或者为了数据在内存中的共享增加过于复杂的逻辑来支持数据的编码、解码和转换亦或为了手工的内存管理增加额外的逻辑。

虽然多线程的开发在前端更多处于实验性阶段但是我认为它还是有着很大的想象空间的。因为前端如果有着比较耗内存的需要大量运算的任务可以交给Worker Thread来处理这样JavaScript的主线程就可以把精力放在UI渲染上。特别是通过Actor模式可以大大提高主程序的性能同时又避免副作用。

思考题

我们说过对象在线程间是不能共享的那通过SharedArrayBuffer你觉得可以实现对象的共享吗

欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!