前端多线程编程探索

有没有人在啊,想请分析下,前端多线程编程探索
最新回答
挂科比挂科难

2024-11-24 10:26:55

前言

本文探索了使用SharedArrayBuffer(以后简称sab)、web worker、Atomics API进行多线程编程的可行性,并构建了一个视频处理场景进行对比实验。示例代码仅是Demo级别,还有很多改进空间,希望一起交流,共同完善。

背景

很久以前做过一个Web RTC的人脸贴纸功能,当时的痛点是帧率跑不高、主线程CPU占用过高。自然想到了web worker的方案来分担主线程的压力。但为了保证一定帧率,并保证低延迟,主线程与子线程之间需要频繁的通信。在postMessage的copy模式下,数据拷贝的算力消耗占用几乎等同于了人脸识别算力消耗。在transfer的模式下,CPU占用率会大幅降低,但是高帧率会导致高延迟(现在的chrome貌似没有这个问题了)。

受限于当时知识的局限性,也就没再尝试新的方案。最近看到sab重新开放,就想能否利用sab来进行线程间通信,减少通信成本。

什么是sab?

MDN传送门:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer

简单说它就是在ArrayBuffer的基础上增加了内容共享功能。当你把一个sab传到一个Web Worker中,它并不是进行了数据的copy,也不是移交了内存的控制权,而是共享了之前的内存,其中一个线程对数据的修改另一个线程是可见的。

小故事:

这实际上是一个非常老的API了,但是由于Spectre漏洞一度被封禁(避免用SharedArrayBuffer实现高精度计时器)。直到一些更安全的跨域策略出现,SharedArrayBuffer才被再次开放。

为什么要用sab

在多线程下,我们的可选方案有postMessage和sab。postMessage会随着传输数据量和传输频率的增加,带来通信延迟和CPU消耗。而且postMessage只能在worker和产生worker的线程之间进行通信,无法在兄弟woker之间进行通信。

sab可以降低线程间的通信延迟,可以实现兄弟worker之间的通信。但是使用sab会增加编码复杂度,尤其对不熟悉多线程编程的前端而言,很容易出现并发读写导致意想不到的bug。而且sab可以结合WebAssembly使用,实现WebAssembly的多线程编程。

探索

场景

从远端导入一个视频,在播放的过程中,实时加入滤镜效果。其中滤镜的处理涉及到了大量的计算。对比单线程与多线程在CPU占用和输出帧率上的区别。我们分别用wasm和js进行滤镜部分的实现,用以模拟在一般计算任务和繁重计算任务。

整体设计

整体的交互过程如下图,接下来会在各个方面对图中的细节进行讲解

线程设计

只设计一个worker进行计算,帧率必然是降低的,因为计算成本没有降低,反而增加了通信成本,每一帧所需的时间肯定是更长的。所以要想看到效果,必然要设计2个以上的计算线程。至于渲染线程,我们可以用主线程,也可以利用OffscreenCanvas设计一个单独的渲染线程。在这里我们使用主线程。

并发的读写可能产生不可预期的结果,而js为了避免这种事情发生,设计为了单线程语言,即使出现了web worker,worker中禁止对dom进行操作,就是为了避免两个线程同时操作一个节点。但是SharedArrayBuffer的出现,使得两个线程可能同时操作同一块内存,从而使程序出现意想不到的错误。为了避免这种事情的发生,我们需要加入锁的机制。

JS的锁机制利用了Atomics API,它提供了一系列的原子操作,比如Atomics.add(),可以用来替代我们熟知的i++,后者并不是一个原子操作,可以拆为读写两个底层命令。

直接使用Atomics并不容易,这里利用一个开源库。这个库主要实现了锁的抢占、线程等待、解锁、唤醒等机制。在某个线程要操作SharedArrayBuffer之前,要确保自己已经抢占到了锁,并在操作完后及时释放。

sab设计

我们将sab设计为如下几段内存区域

其中readBuffer作为原始视频数据写入区域,writeBuffer作为加入滤镜后的视频写入区域,lock作为Lock实例所需的内存,cond作为Cond实例所需内存,status作为程序执行状态。

初始代码

//主线程??function?init()?{??//?初始化内存????imageDataLen?=?video.videoWidth?*?video.videoHeight?*?4?*?2????sab?=?new?SharedArrayBuffer(??????imageDataLen?+?Lock.NUMBYTES?+?Cond.NUMBYTES?+?4,????)????//?初始化锁????Lock.initialize(sab,?imageDataLen)????Cond.initialize(sab,?imageDataLen?+?Lock.NUMBYTES)????//?创建内存读取变量????lock_compute?=?new?Lock(sab,?imageDataLen,?'Main')????cond_compute?=?new?Cond(lock_compute,?imageDataLen?+?Lock.NUMBYTES)????compute_data?=?new?Int32Array(??????sab,??????imageDataLen?+?Lock.NUMBYTES?+?Cond.NUMBYTES,??????1,????)????readImageData?=?new?Uint8ClampedArray(sab,?0,?imageDataLen?/?2)????writeImageData?=?new?Uint8ClampedArray(??????sab,??????imageDataLen?/?2,??????imageDataLen?/?2,????)????//?初始化worker????for?(let?i?=?0;?i?<?workerNumbers;?i++)?{??????const?worker?=?new?Worker('./worker.js')??????workers.push(worker)??????worker.postMessage({????????name:?'Init',????????width:?video.videoWidth,????????height:?video.videoHeight,????????buffer:?sab,??????})????}??}

//?workerfunction?Init(data)?{//?初始化sab的读取变量??width?=?data.width;??height?=?data.height;??let?sab?=?data.buffer;??const?canvasDataLen?=?width?*?height?*?4?*?2;??lock_compute?=?new?Lock(sab,?canvasDataLen,?"Worker");??cond_compute?=?new?Cond(lock_compute,?canvasDataLen?+?Lock.NUMBYTES);??compute_data?=?new?Int32Array(sab,?canvasDataLen?+?Lock.NUMBYTES?+?Cond.NUMBYTES,?1);??writeCanvasData?=?new?Uint8Array(sab,?0,?canvasDataLen?/2);??readCanvasData?=?new?Uint8Array(sab,?canvasDataLen?/2,?canvasDataLen?/2);??const?tmp?=?new?ArrayBuffer(canvasDataLen?/2);??tmpData?=?new?Uint8Array(tmp);??renderLoop();}

线程状态设计

就计算线程而言,它存在5个状态:wait_to_read,reading, computing,wait_to_write, writing。其中wait_to_read是等待渲染线程将原始数据写入,此状态一直处于线程等待状态;reading是从sab中读取原始数据,此状态下需要加锁;computing是滤镜计算,此状态下是可以与其他线程共同执行的;wait_to_write是等待写入计算后的数据;writing是写入计算后的数据,此状态下需要加锁。各状态的流转见下图。

如果以主线程作为渲染线程,它是存在3个状态的,reading,writing,idle。为了避免阻塞主线程,在主线程下是不能执行Atomics.wait()操作的,因此在抢占锁的时候,即使没有抢到,也不能进入线程等待状态,而是要择机再去抢占。因此以idle状态替换了wait状态,也是解放了主线程。渲染不是一个耗时的过程,因此就没有再单独拿出来作为一个状态。各状态流转见下图

程序状态

基于上面对线程状态的设计,我们将程序设计为两对相互独立的状态,有/无原始数据,有/无计算数据。按比特位进行表示,即0:无任何数据,1:有原始数据,2:有计算数据,3:有原始数据和计算数据。

??function?addOriginData()?{????if(Atomics.compareExchange(compute_data,?0,?0,?1)?===?0){??????//?????}?else?if(Atomics.compareExchange(compute_data,?0,?2,?3)?===?2)?{??????//????}??}??function?readComputedData()?{????if(Atomics.compareExchange(compute_data,?0,?3,?1)?===?3){??????//?????}?else?if(Atomics.compareExchange(compute_data,?0,?2,?0)?===?2)?{??????//????}??}??function?addComputedData()?{??if(Atomics.compareExchange(compute_data,?0,?0,?2)?===?0){????//???}?else?if(Atomics.compareExchange(compute_data,?0,?1,?3)?===?1)?{????//??}}function?readOriginData()?{??if(Atomics.compareExchange(compute_data,?0,?1,?0)?===?1){????//???}?else?if(Atomics.compareExchange(compute_data,?0,?3,?2)?===?3)?{????//??}}??function?hasOriginData()?{????const?data?=?Atomics.load(compute_data,?0)????return?data?===?1?||?data?===?3??}??function?hasComputedData()?{????const?data?=?Atomics.load(compute_data,?0)????return?data?===?2?||?data?===?3??}??function?hasNoData()?{????return?Atomics.load(compute_data,?0)?===?0??}

看下主线程的循环代码

??//定义绘制函数;??function?draw()?{??//?主线程下不能直接加锁,只能尝试加锁????if?(!lock_compute.tryLock())?{??????setTimeout(draw,?0)??????return????}????//?是否有可渲染数据????if?(hasComputedData())?{??????//?显示处理结果??????const?_data?=?new?Uint8ClampedArray(imageDataLen?/?2)??????_data.set(readImageData)??????context.putImageData(????????new?ImageData(_data,?video.videoWidth,?video.videoHeight),????????0,????????0,??????)??????readComputedData()??????workingNum--??????const?now?=?performance.now()??????if?(lastTime?!==?0)?{????????vector.push(now?-?lastTime)??????}??????lastTime?=?now??????fpsNumDisplayElement.innerHTML?=?calcFPS(vector)??????cond_compute.wakeOne()????}????//?是否可写入数据,并且有子线程处于空闲状态????if?(workingNum?<?workerNumbers?&&?!hasOriginData())?{??????workingNum++??????offContext.drawImage(video,?0,?0)??????const?pixels?=?offContext.getImageData(????????0,????????0,????????video.videoWidth,????????video.videoHeight,??????)??????writeImageData.set(pixels.data)??????addOriginData()??????cond_compute.wakeOne()????}????lock_compute.unlock()????//更新下一帧画面;????requestAnimationFrame(draw)??}

对于计算线程而言相对简单

function?renderLoop()?{??lock_compute.lock();??//?wait_to_read??while(!hasOriginData())?{????cond_compute.wait();??}??//?reading??tmpData.set(readCanvasData);??readOriginData();??cond_compute.wakeOne();??lock_compute.unlock();??//?computing??jsConvFilter(tmpData,?width,?height,?kernel);??//?wait_to_write??lock_compute.lock();??while(hasComputedData())?{????cond_compute.wait();??}??//?writing??writeCanvasData.set(tmpData);??addComputedData();??cond_compute.wakeOne();??lock_compute.unlock();??setTimeout(()?=>?{??????renderLoop();??})}

效果分析

首先比较使用js进行滤镜效果实现的场景,分别使用主线程和1-4个worker进行比较。用于模拟计算量较为繁重的场景下,sab带来的性能提升

主线程1个worker2个worker3个worker4个worker帧率13帧12帧24帧35帧47帧CPU占用99.9%12%24%35%44%

其次我们使用wasm进行滤镜效实现,分别在使用主线程和1-4个worker进行比较。用于模拟计算量一般的场景下,sab带来的性能提升

主线程1个worker2个worker3个worker4个worker帧率22帧18帧40帧57帧60帧CPU占用99.9%29%38%52%61%

从两次对比可以看出:

1个worker帧率肯定是降低的,因为计算成本不变,增加了通信成本,这是符合预期的

在使用worker的情况下,主线程的CPU使用率基本只和帧率有关,和计算任务的繁重程度无关

增加worker数量可以有效提升帧率

进一步进行性能分析

主线程

就主线程而言,大部分算力还是消耗在了数据读取上,对一个720P的视频进行getImageData操作用掉了12ms。在此场景下,这部分工作不得不放在主线程。这也是帧率和主线程CPU正相关的原因。canvas2D提供了willReadFrequently的参数配置,尝试了下,可以节省一半的读取时间,但是增加了写入时间,整体上是负收益,不适用此场景。

worker

对js计算任务而言而言,计算一帧大概需要70-120ms。对wasm任务而言,计算一帧大概需要20-30ms。计算任务的前后还可能有一段用于多线程冲突检测的额外开销,而且这段开销的耗时并不固定,可以参考下图,4个worker的时候场景

从上面3个图,可以看出:

worker并不是越多越好,会有边际效应,超过一定数量只是在浪费资源

多线程方案也是会引入一定的延迟的,计算任务越繁重,这个延迟的占比就越低,一个好的线程调度方案,也可以降低这部分延迟,但是无法消除

需要说明的是,这个场景只是用来进行对比实验,并不是一个sab最适合的场景,我们还有很多优化方案可以尝试,其中不乏比sab更高效的方案。

存在的问题

两帧之间的时间间隔不稳定,可能还是需要一个帧率预测机制,然后依赖setTimeout来实现;

Safari下兼容挺差的,用到生产环境还早,但如果作为工具网站,还是可以尝试下;

不同性能电脑下差异挺大的,而js不能判断CPU占用情况,很难动态调整

使用场景受限,对跨域策略存在要求

Sab在很多场景不能直接用,比如不能用来构建ImageData,需要先copy一份到私有内存

程序复杂度还是太高了

虽然主线程CPU降低了,但是整体CPU是升高的,无节制的使用用户CPU还是不稳妥的

应用场景和展望

在音视频场景而言,这套方案更多还是应用在RTC场景,在这个场景下对帧率要求不高,一般30帧足以满足;对分辨率要求不高,可以减少getImageData的成本;RTC对实时性要求比较高,需要尽可能降低延迟;可能存在算力消耗比较大的场景,比如人脸贴纸、美颜、背景替换等。

在一些Canvas渲染场景下,我们将渲染也交给子线程,利用sab实现子线程之间的通信,彻底解放主线程。对于Flutter Web而言,可以用多个worker将UI布局计算、交互逻辑、渲染等彻底分开,利用sab实现状态共享。

sab目前和WebGL以及WebAssembly的结合相对较好一些,这两个技术在前端游戏场景应用也比较多,所以sab在大型的游戏场景也会是大有可为的。

高计算场景可以尝试用多个worker加快计算速度,用sab实现生产者与消费者之间的通信,一次写入,多次读取。比如文件MD5计算。

原文:https://juejin.cn/post/7101608088100143118