前言
本文探索了使用SharedArrayBuffer(以后简称sab)、web worker、Atomics API进行多线程编程的可行性,并构建了一个视频处理场景进行对比实验。示例代码仅是Demo级别,还有很多改进空间,希望一起交流,共同完善。
背景
很久以前做过一个Web RTC的人脸贴纸功能,当时的痛点是帧率跑不高、主线程CPU占用过高。自然想到了web worker的方案来分担主线程的压力。但为了保证一定帧率,并保证低延迟,主线程与子线程之间需要频繁的通信。在postMessage的copy模式下,数据拷贝的算力消耗占用几乎等同于了人脸识别算力消耗。在transfer的模式下,CPU占用率会大幅降低,但是高帧率会导致高延迟(现在的chrome貌似没有这个问题了)。
受限于当时知识的局限性,也就没再尝试新的方案。最近看到sab重新开放,就想能否利用sab来进行线程间通信,减少通信成本。
什么是sab?
MDN传送门:developer.mozilla.org/zh-CN/docs/…
简单说它就是在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,
})
}
}
// worker
function 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个worker | 2个worker | 3个worker | 4个worker | |
|---|---|---|---|---|---|
| 帧率 | 13帧 | 12帧 | 24帧 | 35帧 | 47帧 |
| CPU占用 | 99.9% | 12% | 24% | 35% | 44% |
其次我们使用wasm进行滤镜效实现,分别在使用主线程和1-4个worker进行比较。用于模拟计算量一般的场景下,sab带来的性能提升
| 主线程 | 1个worker | 2个worker | 3个worker | 4个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计算。