WebAssembly多线程支持的内部原理

几年前WebAssembly刚刚发布时还是一个MVP(最小可行产品),只有很少的一组功能来提供基本的可用性和实用性。彼时这个MVP缺少一个重要特性,就是多线程支持。而如今WebAssembly的多线程支持已经非常成熟了,可在工具和Chrome中使用。这篇博文探讨了此功能的内部机制与新的指令集,并介绍了这一功能如何为多线程应用程序提供支持。

多线程和并发

在深入研究WebAssembly多线程规范的细节之前,我们先来简单了解一下并发和多线程技术,看看它们的涵义、我们使用这些技术的原因以及它们带来的挑战。如果你是这方面的老手,大可直接跳过这部分!

现代计算机有多个CPU(而且每颗CPU有多个内核——但为了简单起见,我将它们都简称为CPU),每个CPU都能够独立执行程序。我们可以通过多种方式利用这一优势。例如,如果你要处理一个计算密集型任务(如图像处理),则可以将任务拆分到多个CPU上以更快地完成工作;或者如果你的任务需要花费的时间不算短(比如说一两秒),那么最好不要把这个任务放到负责刷新应用UI的CPU上执行,而是把它放到另一个CPU上运行以保持60fps的UI帧速率。

线程是编程结构的底层,使你可以在这些CPU之间分配工作。线程和CPU之间没有直接映射关系,但实践中你需要创建多个线程来实现并发处理。

创建充分利用并发能力的应用程序是很有挑战性的,你必须推断每个执行的“线程”,每个线程都具有本地和共享状态的组合。你还需要其他一些工具,例如“(线程)锁”,用来防止多个线程执行同一段代码。但是这些工具又会引入进一步的挑战,例如过度锁定影响并发性或死锁等问题。

现代工具、框架和架构方法一般都会隐藏并发性,这很容易理解。NodeJS、Lambda函数和主流浏览器都表现为单线程环境。个人来说,我必须承认自己上一次创建一个线程池是好几年以前的事情了!

虽然现在使用多线程的情况不太常见,但也有时候你还是要用它才行。

Web Worker和浏览器中的并发性

有许多API支持JavaScript开发人员在浏览器中使用并发能力。我们简单看一下这些API和它们的现状。

Web浏览器本质上是单线程的(从开发人员的角度来看),你的应用程序逻辑与UI呈现引擎共享相同的线程。因此长时间运行的计算将导致UI锁定或挂起。这种方法以较少的代价换来了极大的便利,所以众多UI框架(例如WPF、WinForms、Cocoa/iOS)都采用了这种方法。

Web Worker API可以让你生成“Worker”线程,这种API已经流行多年。你可以用它创建在给定文件中执行JavaScript的Worker。尽管Worker可以发出XHR请求并使用WebSockets,但它们没有DOM访问权限。Worker不能与主(UI)线程共享可变数据,而是依赖于异步消息传递:

// ## main.js

const worker = new Worker("worker.js");
// pass a message to the worker
worker.postMessage("Hello World!");

// ## worker.js

// The onmessage property uses an event handler to retrieve information sent to  a worker.
onmessage = event => {
  console.log("Received message " + event.data);
};

Web Worker提供了一个非常简单的API,可以避免许多与并发相关的棘手问题。但实际上只有粗粒度的并发方法能这么简单地处理——所谓粗粒度是指传递给worker的都是相对较大的任务。

最近,新引入的SharedArrayBuffer原子操作使开发人员能跨多个线程使用共享的内存了。这样以来就能实现更细粒度的并发算法。Lin Clark写了一份精彩的卡通指南介绍了原子操作的重要性。

遗憾的是,虽然共享数组缓存之前已得到了广泛支持,但Spectre和Meltdown漏洞(使数据在进程之间泄漏的时序攻击)的威胁迫使所有浏览器厂商迅速停止了支持,以减少这些漏洞带来的风险。目前只有Chrome浏览器通过站点隔离和跨源读取屏蔽技术控制了相关风险,进而重新启用了这些功能(2018年末)。如果有合适的风险控制措施的话,我认为其他浏览器也会恢复支持的。

WebAssembly多线程提案

那么WebAssembly中应该在哪里应用多线程支持呢?最早的MVP版本没有任何与多线程相关的结构,因为人们认为这并不是MVP的必要功能——这一选择显然很正确,因为已有的功能集合已经足够人们创造出各种有趣和有用的事物了。

我个人认为WebAssembly的多线程支持很重要,原因有二: 首先,WebAssembly是处理计算密集型任务的理想技术,若能将这些任务分配到多个CPU上会有很大好处;其次,大多数将WebAssembly作为编译目标的语言都有自己的多线程结构,因此通过这一功能可以充分发挥这些语言的潜力。

WebAssembly与JavaScript有类似的提案/规范制定过程(后者是通过TC39)。多线程支持目前是第2阶段提案,意味着规范草案已完成,实现也是可用状态。当前的规范草案可以访问GitHub来获取。基本上来说,它已经准备好让开发者使用了!

WebAssembly多线程规范包含以下内容:

  1. 共享线性内存
  2. 原子操作
  3. wait/notify操作符

第一项是共享线性内存,它很像JavaScript的SharedArrayBuffer,允许多个WebAssembly模块(和JavaScript主机)直接访问相同的“内存块”。

并发内存访问可能会导致内存损坏(memory corruption),具体来说就是一个线程读取一个值的时候,另一个线程却在写入这个值。这里就需要用到原子操作了。原子操作指的是一组确保原子化的简单指令(读、写、增量…),也就是说其他线程只能在原子操作完成时看到它们的结果。这是一个基本但至关重要的构建块,它为更高级别的并发概念(如锁和屏障)铺平了道路。

最后,wait/notify操作符提供了一种挂起线程的机制(不需要干等),还能从另一个线程重新唤醒它。

你可能意识到这份多线程规范没有提供产生新线程的机制。听起来是很不可思议,但这个设计是故意的。实际上托管方(例如执行WebAssembly模块的环境)需要自己来创建线程。这份规范提供了在多线程环境中安全有效地使用WebAssembly所需的各种工具。

深入探索

理论部分讲得够多了,该来点实战了!

要试用WebAssembly的多线程功能,首先我需要一个适合并发的任务。我选择的是渲染Mandelbrot分形这个经典问题,这是一个非常简单但需要密集计算的任务,很容易分成多个可以并行处理的部分。

渲染Mandelbrot集的传统方法是迭代图像的每个像素,(基于定义该集合的基础方程的迭代次数)计算这个像素的值,然后为其上色。原理非常简单:

for (let y = 0; y < 1200; y++) {
  for (let x = 0; x < 800; x++) {
    const value = mandelbrot(x, y);
    setpixel(x, y, value);
  }
}

每个像素的值都完全独立于其他像素,所以我们可以很容易跨多个线程共享负载来加速上述操作。一种可能的方法是让每个线程计算一部分像素的值,然后将各个部分的结果返回主线程以生成最终图像。这不需要任何共享状态,因此可以使用Web Worker实现,无需SharedArrayBuffer。但它不太适合用来测试WebAssembly的多线程功能。

另一种方法是使用单个索引来表示需要计算的下一个像素的位置,并使用一个while循环来连续获取该值,然后计算并上色像素,以此类推——如下所示:

let index = 0;

while (index < 1200 * 800) {
  index++;
  const value = mandelbrot(x, y);
  setpixel(index % 1200, Math.floor(index / 1200), value);
}

可以将多个线程设置为执行上述while循环,这样就能更快完成全部任务。但在这种情况下,index值的状态会在多个线程之间共享,进而引发一些问题,我们之后会处理。不管怎样,我们将采用这种方法并将其转换为WebAssembly实现。

手写的并发程序

我喜欢手写WebAssembly代码(例如直接在原生指令集下用WebAssembly文本格式编写代码,而不是从更高级的语言编译过来);本文的示例非常适合手写代码,因为我想看看运行时到底提供了哪些多线程功能。

我没有手写整个应用程序,而是走了一些捷径,使用WebAssembly Studio将基本算法从AssemblyScript编译成wasm。

下面是用AssemblyScript编写的mandelbrot算法:

function iterateEquation(x0: f64, y0: f64, maxiterations: u32): u32 {
  let a = 0.0,
    b = 0.0,
    rx = 0.0,
    ry = 0.0,
    ab: f64;
  let iterations: u32 = 0;
  while (iterations < maxiterations && rx * rx + ry * ry <= 4) {
    rx = a * a - b * b + x0;
    ab = a * b;
    ry = ab + ab + y0;
    a = rx;
    b = ry;
    iterations++;
  }
  return iterations;
}

WebAssembly代码如下:

(func $iterateEquation  (param $p0 f64) (param $p1 f64) (param $p2 i32) (result i32)
  (local $l0 i32)
  (local $l1 f64)
  (local $l2 f64)
  (local $l3 f64)
  (local $l4 f64)
  loop $L0
    get_local $l4
    get_local $l4
    f64.mul
    get_local $l1
    get_local $l1
    f64.mul
    f64.add
    f64.const 0x1p+2 (;=4;)
    f64.le
    i32.const 0
    get_local $l0
    get_local $p2
    i32.lt_u
    select
    if $I1
      get_local $l2
      get_local $l3
      f64.mul
      set_local $l1
      get_local $l2
      get_local $l2
      f64.mul
      get_local $l3
      get_local $l3
      f64.mul
      f64.sub
      get_local $p0
      f64.add
      tee_local $l4
      set_local $l2
      get_local $l1
      get_local $l1
      f64.add
      get_local $p1
      f64.add
      tee_local $l1
      set_local $l3
      get_local $l0
      i32.const 1
      i32.add
      set_local $l0
      br $L0
    end
  end
  get_local $l0
)

使用上面的函数和一些类似的实用程序(如颜色扩展、座标转换等)后,我就能构建一个简单的循环使用上述策略渲染Mandelbrot了,具体来说就是获取下一个索引、递增、计算并设置像素值这样的循环。

这个函数有三个参数,$cx、$cy和$diameter,它们只用来指示mandelbrot集合中的位置:

(func $run (param $cx f64) (param $cy f64) (param $diameter f64)
  (local $x i32)
  (local $y i32)
  (local $loc i32)

  (block $myblock
    (loop
      ;; load the next location that is being computed
      (set_local $loc
        (i32.load
          (i32.const 0)
        )
      )

      ;; store the incremented version
      (i32.store
        (i32.const 0)
        (i32.add
          (get_local $loc)
          (i32.const 1)
        )
      )

      ;; loop for 1200 * 800
      (br_if $myblock
        (i32.ge_u
          (get_local $loc)
          (i32.const 960000)
        )
      )

      ;; convert to coordinates
      (set_local $y
        (i32.div_u
          (get_local $loc)
          (i32.const 1200)
        )
      )
      (set_local $x
        (i32.rem_u
          (get_local $loc)
          (i32.const 1200)
        )
      )

      ;; compute the next mandelbrot step and store
      (i32.store
        (call $offsetFromCoordinate
          (get_local $x)
          (get_local $y)
        )
        (call $colour
          (call $mandelbrot
            (get_local $cx)
            (get_local $cy)
            (get_local $diameter)
            (get_local $x)
            (get_local $y)
          )
        )
      )

      ;; repeat the current loop
      (br 0)
    )
  )
)

我不打算深入探讨上面的代码示例中各种指令的细节,希望大家能一看就懂。值得一提的是一些应用程序状态的存储位置。

索引是用来指示下一个要计算的像素的,从加载索引并设置位置$loc变量的代码可以看出,它是存储在位置0的线性内存中的。Mandelbrot本身也会写入线性内存,而$offsetFromCoordinate提供所需的偏移量(从位置4开始,以免覆盖上述索引!)。

那么该怎样改动这些代码来实现并发计算呢?

计算mandelbrot结果的函数和其他实用程序不是问题所在——它们是无状态的,也就是说它们肯定是线程安全的。此外,存储每个像素结果的写操作也不是问题,因为这些写操作永远不会写到同一个位置上。实际上唯一的问题是读取、递增和写入当前索引这部分内容,它使用内存地址0,所以会受到并发读/写的影响,并且为了避免内存损坏需要原子化。

目前的多线程规范提供了原子加载、存储、读取-修改-写入和比较-交换操作。本例中,加载、递增和写入索引的全部过程都需要原子化,正好可以用上原子读取-修改-写入操作。

执行此步骤的原有(非线程安全)代码如下:

;; load the next location that is being computed
(set_local $loc
  (i32.load
    (i32.const 0)
  )
)

;; store the incremented version
(i32.store
  (i32.const 0)
  (i32.add
    (get_local $loc)
    (i32.const 1)
  )
)

它可以用原子化等效操作替换如下:

(set_local $loc
  (i32.atomic.rmw.add
    (i32.const 0)
    (i32.const 1)
  )
)

这里i32.atomic.rmw.add执行的是原子读取-修改-写入的原子添加操作,上面示例中0和1两个参数会使位于第0个存储器地址的值加1。

很简单嘛!那么我们如何编译和运行这段代码呢?

WebAssembly二进制工具包有许多用于处理wasm模块的相对底层的工具,其中就有wat2wasm;这是一种将WebAssembly文本格式转换为发送到浏览器的二进制模块格式的工具。此工具有自己的功能标志,可用来转换使用post-MVP功能的模块,如下所示:

wat2wasm --enable-threads mandelbrot.wat -o mandelbrot.wasm

Wasm线性内存既可以由模块本身创建,也可以在创建模块时导入。在本例中我们需要将内存标记为“共享”并将其提供给多个模块(每个模块都位于不同的Web worker中)。线性内存内部使用了一个SharedArrayBuffer,这也就是为什么这段代码目前只能跑在Chrome上的原因之一。

下面的代码创建了一个共享内存实例,生成许多Web worker,然后等待它们全部通报完成状态,最后将内存中的内容渲染到画布上:

const memory = new WebAssembly.Memory({
  initial: 80,
  maximum: 80,
  shared: true
});

// https://commons.wikimedia.org/wiki/File:Mandel_zoom_04_seehorse_tail.jpg
const config = {
  x: -0.743644786,
  y: 0.1318252536,
  d: 0.00029336
};

const workerCount = 4;
let doneCount = 0;

// spawn the required number of workers
for (let i = 0; i < workerCount; i++) {
  const worker = new Worker("worker.js");
  worker.onmessage = e => {
    doneCount++;
    // have they all finished?
    if (doneCount === workerCount) {
      // copy the contents of wasm linear memory to a canvas element
      const canvasData = new Uint8Array(memory.buffer, 4, 1200 * 800 * 4);
      const context = document.querySelector("canvas").getContext("2d");
      const imageData = context.createImageData(1200, 800);
      imageData.data.set(canvasData);
      context.putImageData(imageData, 0, 0);
    }
  };
  worker.postMessage({ memory, config });
}

上面的代码使用postMessage发送这段共享内存,还使用了config对象描述要渲染给每个worker的位置。

下面是worker.js脚本:

onmessage = ({ data }) => {
  const {
    memory,
    config: { x, y, d }
  } = data;

  fetch("mandlbrot.wasm")
    .then(response => response.arrayBuffer())
    .then(bytes =>
      WebAssembly.instantiate(bytes, {
        env: {
          memory
        }
      })
    )
    .then(({ instance }) => {
      instance.exports.run(x, y, d, id);
      postMessage("done");
    });
};

它只用来实例化WebAssembly模块,提供共享内存,然后让它运行,最后将消息发送回主线程。让人感到惊喜的是,只要写这么少的代码就能把负载分配在多个线程上了。

回顾一下,每个线程不断获取要从共享内存计算的下一个像素位置,然后将结果写入同一段共享内存中。所有像素都计算完毕后所有线程都会终止,最终生成一幅经典的mandelbrot分形:

那么,这种方法有多快?

我的机器有4个核心,我测得的成绩如下:

  • 1个线程 - 11秒
  • 2个线程 - 5.7秒
  • 4个线程 - 4.2秒

速度显著提升!

最后我想换种方法重新渲染一遍,这次每个像素根据计算它的线程来上色。

下面是完整的mandelbrot结果:

如果你不熟悉Mandelbrot集合的工作机制的话,注意这里每个像素的颜色取决于基础方程“逃逸”所需的迭代次数。暗区是等式永远不会逃逸并无限迭代的地方(直至达到上界)。正因如此,黑暗区域需要花费更多的计算时间。

下面是由线程上色的过程:

我发现这是一幅引人入胜的画面!

在图像顶部,你可以看到第一个线程在分形的一些较简单的部分上进展迅速。接下来你会看到有些区域中各个线程一个像素接一个像素地轮流工作。渲染到暗区时方程会无限迭代,所有四个线程都会轮流运算,而第一个算完的线程会一路跑下去画完这条线——如右边的条纹图案所示。

结论

WebAssembly多线程支持是一个非常有趣的新功能,它为WebAssembly带来了共享内存、原子操作和wait/notify操作符。可惜这个功能与SharedArrayBuffer有一些关联,因此我们要等到Chrome以外的浏览器解决相关漏洞,才能看到这一功能得到广泛支持。等到那一天来临时,我相信浏览器中会出现一些非常强大的应用程序!

如果你想深入研究相关代码的话,所有内容都放到了GitHub上。你还可以在Chrome浏览器中运行代码

英文原文:
https://blog.scottlogic.com/2019/07/15/multithreaded-webassembly.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章