WebAssembly 相关API

·  阅读 109

WebAssembly 浏览器加载流程

一个 Wasm 二进制模块需要经过怎样的流程,才能够最终在 Web 浏览器中被使用。这些流程可以被粗略地划分为以下四个阶段。

首先是 “Fetch” 阶段。作为一个客户端 Web 应用,在这个阶段中,我们需要将被使用到的 Wasm 二进制模块,从网络上的某个位置通过 HTTP 请求的方式,加载到浏览器中。这个 Wasm 二进制模块的加载过程,同我们日常开发的 Web 应用在浏览器中加载 JavaScript 脚本文件等静态资源的过程,没有任何区别。对于 Wasm 模块,也可以选择将它放置到 CDN 中,或者经由 Service Worker 缓存,以加速资源的下载和后续使用过程。

接下来是 “Compile” 阶段。在这个阶段中,浏览器会将从远程位置获取到的 Wasm 模块二进制代码,编译为可执行的平台相关代码和数据结构。这些代码可以通过 “postMessage()” 方法,在各个 Worker 线程中进行分发,以让 Worker 线程来使用这些模块,进而防止主线程被阻塞。此时,浏览器引擎只是将 Wasm 的字节码编译为平台相关的代码,而这些代码还并没有开始执行。

紧接着便是最为关键的 “Instantiate” 阶段。在这个阶段中,浏览器引擎开始执行在上一步中生成的代码。Wasm 模块可以通过定义 “Import Section” 来使用外界宿主环境中的一些资源。在这一阶段中,浏览器引擎在执行 Wasm 模块对应的代码时,会将那些 Wasm 模块规定需要从外界宿主环境中导入的资源,导入到正在实例化中的模块,以完成最后的实例化过程。这一阶段完成后,我们便可以得到一个动态的、保存有状态信息的 Wasm 模块实例对象。

最后一步便是 “Call”。顾名思义,在这一步中,我们便可以直接通过上一阶段生成的动态 Wasm 模块对象,来调用从 Wasm 模块内导出的方法。

接下来,我们将围绕上述流程中的第二步 “Compile 编译” 与第三步 “Instantiate 实例化”,来分别介绍与这两个阶段相关的一些 JavaScript API 与 Web API。

Wasm JavaScript API

模块对象

我们如何在 JavaScript 环境中表示刚刚说过的 “Compile 编译” 与 “Instantiate 实例化” 这两个阶段的 “产物” ?为此,Wasm 在 JavaScript API 标准中为我们提供了如下两个对象与之分别对应:

WebAssembly.Module

WebAssembly.Instance

不仅如此,上面这两个 JavaScript 对象本身也可以被作为类型构造函数使用,以用来直接构造对应类型的对象。也就是说,我们可以通过 “new” 的方式并传入相关参数,来构造这些类型的某个具体对象。比如,可以按照以下方式来生成一个 WebAssembly.Module对象:

这里的 WebAssembly.Module 构造函数接受一个包含有效 Wasm 二进制字节码的 ArrayBuffer 或者 TypedArray 对象。WebAssembly.Instance 构造函数的用法与 WebAssembly.Module 类似,只不过是构造函数的参数有所区别。

导入对象

通过 “Import Section” Section,模块可以在实例化时接收并使用来自宿主环境中的数据。Web 浏览器作为 Wasm 模块运行时的一个宿主环境,通过 JavaScript 的形式提供了可以被导入到 Wasm 模块中使用的数据类型,这些数据类型包括函数(Function)、全局数据(Global)、

线性内存对象(Memory)以及 Table 对象(Table)。其中除“函数”类型外,其他数据类型分别对应着以下由 JavaScript 对象表示的包装类型:

WebAssembly.Global

WebAssembly.Memory

WebAssembly.Table

而对于函数类型,我们可以直接使用 JavaScript 语言中的“函数”来作为代替。

同理,我们也可以通过“直接构造”的方式来创建上述这些 JavaScript 对象。以 “WebAssembly.Memory” 为例,我们可以通过如下方式,来创建一个WebAssembly.Memory 对象:

这里我们通过为构造函数传递参数的方式,指定了所生成 WebAssembly.Memory 对象的一些属性。比如该对象所表示的 Wasm 线性内存其初始大小为 10 页,其最大可分配大小为 100 页。需要注意的是,Wasm 线性内存的大小必须是“Wasm 页”大小的整数倍,而一个“Wasm 页”的大小在 MVP 标准中被定义为了“64KiB”(注意和 64 KB 的区别。KiB 为 1024 字节,而 KB 为 1000 字节)。

错误对象

除了上述介绍的几个比较重要的 JavaScript WebAssembly 对象之外,还有另外几个与“Error”

有关的表示某种错误的“错误对象”。这些错误对象用以表示在整个 Wasm加载、编译、实例化及函数执行流程中,在其各个阶段中所发生的错误。这些错误对象分别是:

WebAssembly.CompileError 表示在 Wasm 模块编译阶段(Compile)发生的错误,比如模块的字节码编码格式错误、魔数不匹配。

WebAssembly.LinkError 表示在 Wasm 模块实例化阶段(Instantiate)发生的错误,比如导入到 Wasm 模块实例 Import Section 的内容不正确。

WebAssembly.RuntimeError 表示在 Wasm 模块运行时阶段(Call)发生的错误,比如常见的“除零异常”。

上面这些错误对象也都有对应的构造函数,可以用来构造对应的错误对象。

模块实例化方法

最后一个需要重点介绍的 JavaScript API 主要用来实例化一个 Wasm 模块对象。该方法的

原型如下所示:

WebAssembly.instantiate(bufferSource, importObject)

整个方法接受两个参数。除第一个参数对应的 ArrayBuffer 或 TypedArray 类型外,第二个参数为一个 JavaScript 对象,在其中包含有需要被导入到 Wasm 模块实例中的数据,这些数据将通过 Wasm 模块的“Import Section”被导入到模块实例中使用。

方法在调用完成后会返回一个将被解析为 ResultObject 的 Promise 对象。ResultObject对象包含有两个字段 ,分别是“module”以及“instance”。其中 module 表示一个被编译好的 WebAssembly.Module 静态对象;instance 表示一个已经完成实例化的 WebAssembly.Instance 动态对象。所有从 Wasm 模块中导出的方法,都被“挂载”在这个 ResultObject 对象上。

需要注意的是,WebAssembly.instantiate 方法还有另外的一个重载形式,也就是其第一个参数类型从含有 Wasm 模块字节码数据的 bufferSource,转变为已经编译好的静态WebAssembly.Module 对象。这种重载形式通常用于 WebAssembly.Module 对象已经被提前编译好的情况。

模块编译方法

上面讲到的 WebAssembly.instantiate 方法,主要用于从 Wasm 字节码中一次性进行 Wasm 模块的编译和实例化过程,而这通常是我们经常使用的一种形式。当然你也以将编译和实例化两个步骤分开来进行。比如单独对于编译阶段,可以使用下面这个JavaScript API:

WebAssembly.compile(bufferSource)

该方法接收一个含有有效 Wasm 字节码数据的 bufferSource,也就是 ArrayBuffer 或者TypedArray 对象。返回的 Promise 对象在 Resolve 后,会返回一个编译好的静态WebAssembly.Module 对象。

Wasm Web API

Wasm 的 JavaScript API 标准,主要定义了一些与 Wasm 相关的类型和操作,这些类型和操作与具体的平台无关。为了能够在最大程度上利用 Web 平台的一些特性,来加速Wasm 模块对象的编译和实例化过程,Wasm 标准又通过添加 Wasm Web API 的形式,为 Web 平台上的 Wasm 相关操作提供了新的、高性能的编译和实例化接口。

模块流式实例化方法

不同于 JavaScript API 中的 WebAssembly.instantiate 方法,Web API 中定义的“流式接口”可以让我们提前开始对 Wasm 模块进行编译和实例化过程,也可以称此方式为“流式编译”。

比如下面这个 API 便对应着 Wasm 模块的“流式实例化”接口:

WebAssembly.instantiateStreaming(source, importObject)

为了能够支持“流式编译”,该方法的第一个参数,将不再需要已经从远程加载好的完整 Wasm 模块二进制数据(bufferSource)。取而代之的,是一个尚未 Resolve 的 Response 对象。Response 对象(window.fetch 调用后的返回结果)是 Fetch API 的重要组成部分,这个对象代表了某个远程 HTTP 请求的响应数据。

通过这种方式,Web 浏览器可以在从远程位置开始加载 Wasm 模块文件数据的同时,也一并启动对 Wasm 模块的编译和初始化工作。相较于上一个 JavaScript API 需要在完全获取 Wasm 模块文件二进制数据后,才能够开始进行编译和实例化流程的方式,流式编译无疑在某种程度上提升了 Web 端运行 Wasm 应用的整体效率。

模块流式编译方法

既然存在着模块的“流式实例化方法”,便也存在着“流式编译方法”。如下所示:

WebAssembly.compileStreaming(source)

该 API 的使用方式与 WebAssembly.instantiateStreaming 类似,第一个参数为 Fetch API 中的 Response 对象。API 调用后返回的 Promise 对象在 Resolve 之后,会返回一个编译好的静态 WebAssembly.Module 对象。

同 Wasm 模块的“流式实例化方法”一样,“流式编译方法”也可以在浏览器加载 Wasm 二进制模块文件的同时,提前开始对模块对象的编译过程。

Wasm 运行时(Runtime)

在这个阶段中,我们可以调用从 Wasm 模块对象中导出的函数。每一个经过实例化的 Wasm 模块对象,都会在运行时维护自己唯一的“调用栈”。所有模块导出函数的实际调用过程,都会影响着栈容器中存放的数据,这些数据代表着每条 Wasm 指令的执行结果。当然,这些结果也同样可以被作为导出函数的返回值。

调用栈一般是“不透明”的。也就是说,我们无法通过任何 API 或者方法直接接触到栈容器中存放的数据。因此,这也是 Wasm 保证执行安全的众多因素之一。

Wasm 内存模型

除了调用栈,每一个实例化的 Wasm 模块对象都有着自己的(在 MVP 下只能有一个)线性内存段,准确来讲,也就是由“Memory Section”和“Data Section”共同“描述”的一个线性内存区域。在这个区域中,以二进制形式存放着模块所使用到的各种数据资源。这些资源可以是来自于对 Wasm 模块导出方法调用后的结果,即通过 Wasm 模块内的相关指令对线性内存中的数据进行读写操作;也可以是在进行模块实例化时,我们将预先填充好的二进制数据资源以 WebAssembly.Memory 导入对象的形式,提前导入到模块实例中进行使用。

浏览器在为 Wasm 模块对象分配线性内存时,会将这部分内存与 JavaScript 现有的内存区域进行隔离,并单独管理,可以参考下面的这张图。在以往的 JavaScript Memory 中,我们可以存放 JavaScript 中的一些数据类型,这些数据同时也可以被相应的 JavaScript / Web API 直接访问。而当数据不再使用时,它们便会被 JavaScript 引擎的 GC 进行垃圾回收。

相反,图中绿色部分的 WebAssembly Memory 则有所不同。这部分内存可以被 Wasm 模块内部诸如 “i32.load”与“i32.store”等指令直接使用,而外部浏览器宿主中的JavaScript / Web API 则无法直接进行访问。不仅如此,分配在这部分内存区域中的数据,受限于 MVP 中尚无 GC 相关的标准,因此需要 Wasm 模块自行进行清理和回收。

事实上,每一个 Wasm 实例所能够合法访问的线性内存范围,仅限于上面的这一部分内存段。对于宿主环境中的任何变量数据,如果 Wasm 模块实例想要使用,一般可以通过以下两种常见的方式:

1. 对于简单(字符 \ 数字值等)数据类型,可以选择将其视为全局数据,通过“Import Section”

导入到模块中使用;

2. 对于复杂数据,需要将其以“字节”的形式,拷贝到模块实例的线性内存段中来使用。

在 Web 浏览器这个宿主环境中,一个内存实例通常可以由 JavaScript 中的 ArrayBuffer 类型来进行表示。ArrayBuffer 中存放的是原始二进制数据,因此在读写这段数据时,我们需要使用 TypedArray 以某个具体类型作为数据的“解读方式”,来操作 ArrayBuffer 中的数据。根据实际需要,一个字符可能会占用 1 个字节到多个字节不等的大小。而这个“占用大小”便是数据的“解读方式”。

可以通过下面这张图,来理解一下 Wasm 模块线性内存与 Web 浏览器宿主环境,或者说与 JavaScript 之间的互操作关系。

当拥有了填充好数据的 ArrayBuffer 或 TypedArray 对象时,便可以构造自己的WebAssembly.Memory 导入对象。然后在 Wasm 模块进行实例化时,将该对象导入到模块中,来作为模块实例的线性内存段进行使用。

局限性

无法直接引用 DOM

在 MVP 标准下,我们无法直接在 Wasm 二进制模块内引用外部宿主环境中的“不透明”(即数据内部的实际结构和组成方式未知)数据类型,比如 DOM 元素。因此目前通常的一种间接实现方式是使用 JavaScript 函数来封装相应的 DOM 操作逻辑,然后将该函数作为导入对象,导入到模块中,由模块在特定时机再进行间接调用来使用。但相对来说,这种借助 JavaScript 的间接调用方式,在某种程度上还是会产生无法弥补的性能损耗。

复杂数据类型需要进行编解码

还是类似的问题,对于除“数字值”以外的“透明”数据类型(比如字符串、字符),当我们想要将它们传递到 Wasm 模块中进行使用时,需要首先对这些数据进行编码(比如UTF-8)。然后再将编码后的结果以二进制数据的形式存放到 Wasm 的线性内存段中。模块内部指令在实际使用时,再将这些数据进行解码。因此我们说,就目前 MVP 标准而言,Wasm 模块的线性内存段是与外部宿主环境进行直接信息交换的最重要“场所”。

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改