前端多线程编程探索

avatar
FE @字节跳动

前言

本文探索了使用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才被再次开放。

image.png

为什么要用sab

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

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

探索

  • 场景

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

  • 整体设计

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

image.png

  • 线程设计

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

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

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

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

  • sab设计

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

image.png 其中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是写入计算后的数据,此状态下需要加锁。各状态的流转见下图。

image.png

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

image.png

  • 程序状态

基于上面对线程状态的设计,我们将程序设计为两对相互独立的状态,有/无原始数据,有/无计算数据。按比特位进行表示,即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. 1个worker帧率肯定是降低的,因为计算成本不变,增加了通信成本,这是符合预期的
  1. 在使用worker的情况下,主线程的CPU使用率基本只和帧率有关,和计算任务的繁重程度无关
  1. 增加worker数量可以有效提升帧率

进一步进行性能分析

  • 主线程

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

  • worker

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

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

  1. worker并不是越多越好,会有边际效应,超过一定数量只是在浪费资源
  1. 多线程方案也是会引入一定的延迟的,计算任务越繁重,这个延迟的占比就越低,一个好的线程调度方案,也可以降低这部分延迟,但是无法消除

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

存在的问题

  1. 两帧之间的时间间隔不稳定,可能还是需要一个帧率预测机制,然后依赖setTimeout来实现;
  1. Safari下兼容挺差的,用到生产环境还早,但如果作为工具网站,还是可以尝试下;
  1. 不同性能电脑下差异挺大的,而js不能判断CPU占用情况,很难动态调整
  1. 使用场景受限,对跨域策略存在要求
  1. Sab在很多场景不能直接用,比如不能用来构建ImageData,需要先copy一份到私有内存
  1. 程序复杂度还是太高了
  1. 虽然主线程CPU降低了,但是整体CPU是升高的,无节制的使用用户CPU还是不稳妥的

应用场景和展望

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

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

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

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