likes
comments
collection
share

写给JavaScript开发者的WebAssembly入门教程

作者站长头像
站长
· 阅读数 11

JavaScript开发人员的WebAssembly简介

摘要

介绍

WebAssembly是世界范围Web联盟的标准,其最新官方发布是WebAssembly Core Specification, W3C Recommendation, 2019年12月5日。现在已经受到大多数主要浏览器的支持。此标准的主要目的是实现Web浏览器中由Web浏览器执行的JavaScript代码与编译的二进制代码之间的互操作性。WebAssembly模块主要用于实现需要比JavaScript虚拟机的能力更快的执行的算法。这些算法是交互式3D可视化、音频和视频软件以及游戏的基础。

例如,当运行Google Earth网站时,您会发现您的Web浏览器下载了这个WebAssembly模块:

earth.google.com/static/9.13…

本文是JavaScript WebAssembly接口的简介。它介绍了JavaScript代码与WebAssembly模块的交互方式,对于那些希望了解像emscripten生成的包装器的实现方式也很有兴趣。

本文基于WebAssembly JavaScript Interface, W3C Recommendation, 2019年12月5日的规范。

所有示例都在Node.js版本v14.16.0上进行了测试。这些示例可以轻松适应在Web浏览器上运行。示例的源代码可在此处找到,对于每个示例,您会找到:

  • JavaScript代码,
  • WebAssembly模块的字节码,
  • WebAssembly模块的_WAT_语法的源代码,这是字节码的文本表示形式。

实例化

要运行WebAssembly模块,我们必须加载存储在_.wasm_文件中的模块的字节码。从Node.js中,您可以使用_fs_模块加载它:

const fs = require("fs");
let bytecode = fs.readFileSync('add/add.wasm');

或者,您可以通过HTTP请求文件:

let bytecode = await fetch("add/add.wasm");

字节码是程序的中间表示。它可以由虚拟机执行,但WebAssembly的目的是将这个字节码编译成主机机器(例如C语言程序)的二进制代码。

实例化步骤会编译代码,并初始化WebAssembly模块的内部内存:

let wasm = await WebAssembly.instantiateStreaming(bytecode);

注意:对于Web浏览器(Edge、Chrome、Firefox),我们使用_instantiateStreaming_函数,而对于Node.js,我们使用_instantiate_函数,因为前者还不被Node.js v14.16.0支持。

实例化后,我们可以调用任何导出的函数:

let run = async () => {
    try {
        let bytecode = await fetch("add/add.wasm");
        let wasm = await WebAssembly.instantiateStreaming(bytecode);
        console.log(wasm.instance.exports.addInt32(1,2));
    }   
    catch(e) {  
        console.error(e);
    }
};

> run().then();
> 3

源代码

如果您需要在不实例化的情况下编译字节码,以便将模块传递给JavaScript worker,则将使用_WebAssembly.compile_函数编译WebAssembly模块,并使用_WebAssembly.Instance_构造函数在worker中实例化WebAssembly模块:

let module = WebAssembly.compile(bytecode);
let wasm = new WebAssembly.Instance(module);

参数类型

WebAssembly模块接受以下类型的参数,只有JavaScript数字可以作为参数值设置:

WebAssembly类型命名约定typeof()
32位无符号/有符号整数int32"number"
64位无符号/有符号整数n/an/a
32位浮点数float32"number"
64位浮点数float64"number"

如果传递一个数字,而期望传递64位整数,则会引发异常:

let run = async () => {
    try {
        let bytecode = await fetch("add/add.wasm");
        let wasm = await WebAssembly.instantiateStreaming(bytecode);
        console.log(wasm.instance.exports.addInt64(1,2)); 
    }   
    catch(e) {  
        console.error(e);
    }
};

> run().then();
TypeError: wasm function signature contains illegal type

回调

WebAssembly模块可以调用JavaScript函数。从模块的角度来看,这是一个导入(或称为_extern_)函数。例如,WebAssembly echo 模块的实现会使用C编程语言编写如下:

extern void printNumber(int);
void echo(int n) { printNumber(n); }

echo 函数只是调用 printNumber JavaScript函数。当我们实例化WebAssembly模块时,必须设置导入的 printNumber 函数,以便在浏览器和WebAssembly模块之间启用动态链接:

let run = async () => {
    try {
        let bytecode = await fetch("echo/echo.wasm");
        let imports = {
            env: {
                printNumber: (arg) => { console.log(arg); }
            }
        };    
        
        let wasm = await WebAssembly.instantiateStreaming(bytecode, imports);
        wasm.instance.exports.echo(2021); 
    }   
    catch(e) {  
        console.error(e);
    }
 };
> run().then();
2021

源代码

自动描述

有两个内置函数可以用来检查WebAssembly模块的接口,第一个函数描述了 imports 项目,第二个函数描述了 exports 项目:

let run = async () => {
    try {
        let bytecode = await fetch("echo/echo.wasm");
        let imports = {
            env: {
                printNumber: (arg) => { console.log(arg); }
            }
        };    
        
        let wasm = await WebAssembly.instantiateStreaming(bytecode, imports);
        console.log(WebAssembly.Module.imports(wasm.module)); 
        console.log(WebAssembly.Module.exports(wasm.module));
    }   
    catch(e) {  
        console.error(e);
    }
 };
> run().then();
[ { module: 'env', name: 'printNumber', kind: 'function' } ]
[ { name: 'echo', kind: 'function' } ]

然而,这些函数都不会提供函数的签名或元素的文档。

动态链接

当需要多个WebAssembly模块时,它们可能会相互依赖。让我们假设以下设计:

  • 第一个WebAssembly模块 math 导出一个名为 sum 的函数。
  • 第二个WebAssembly模块 app 导入了这个名为 sum 的函数,并导出一个名为 run 的函数。

JavaScript代码必须确保在WebAssembly模块 app 实例化之前先实例化WebAssembly模块 math,以便导入所需的函数 sum

let bytecodeLib = await fetch("math.wasm");
let bytecodeApp = await fetch("app.wasm");
let wasmLib = await WebAssembly.instantiateStreaming(bytecodeLib);
let imports = { math: { sum: wasmLib.instance.exports.sum } };
let wasmApp = await WebAssembly.instantiateStreaming(bytecodeApp, imports);
// 应用程序已准备好。
wasmApp.instance.exports.run();

按照约定,imports 部分的键是模块的名称,这里是 math

在我们的示例中,如果导入来自顶级JavaScript模块,则使用 env 键。

自动启动

您必须注意,WebAssembly模块可能已经定义了一个名为 start 的内部函数。一旦实例化WebAssembly模块,此函数将自动启动处理过程。

全局变量

一些全局变量可以在JavaScript代码和WebAssembly模块之间共享。它们可以在JavaScript端或WebAssembly端定义。与参数一样,全局变量只能作为JavaScript数字类型。

让我们创建一个可变的全局变量,它是一个32位整数,初始值为0:

let counter = new WebAssembly.Global( { value:'i32', mutable:true }, 0);

现在,我们可以在实例化时设置这个全局变量,这样 inc 函数将对其进行递增:

let run = async () => {
    try {
        let bytecode = await fetch("counter/counter.wasm");
        let imports =  { env: { "counter": counter } };
        let wasm = await WebAssembly.instantiateStreaming(bytecode, imports);
        wasm.instance.exports.inc(); 
        wasm.instance.exports.inc();         
        console.log("Counter value is", counter.value); 
    }   
    catch(e) {  
        console.error(e);
    }
 };
    
> run().then();
Counter value is 2

源代码

内存缓冲区

内存缓冲区(或在WebAssembly术语中称为线性内存)是一个字节缓冲区,对JavaScript来说类型为 ArrayBuffer。一个单独的内存缓冲区可用于存储WebAssembly模块和JavaScript代码之间共享的所有数据。

分配内存

内存缓冲区可以在JavaScript端或WebAssembly模块端分配。在JavaScript端,可以通过指定一个初始大小来分配内存,该大小是以页为单位的,每个页面的大小为64kilo-bytes。还可以选择指定最大大小:

let memory = new WebAssembly.Memory( { initial: 1, maximum: 2 } );

一旦分配了内存,我们可以初始化内容。我们需要通过创建一个 "数组视图" 来访问内存缓冲区。例如,要存储32位整数,我们使用 Uint32Array 数组视图:

// 将 memory.buffer 包装为无符号整数数组
let numbers = new Uint32Array(memory.buffer);

现在,我们可以在内存缓冲区中设置数字:

for (let i = 0; i < 10; i++) {
  numbers[i] = i;
}

作为我们刚刚分配的内存缓冲区的一个用例,我们调用了 sum WebAssembly 模块,其接口是用C编程语言编写的:

extern int mem[]; // 导入的内存缓冲区
int sum(int len); // 对存储在 mem 中的 "len" 个整数求和并返回结果

我们在实例化时将此缓冲区传递给WebAssembly模块。

在这里,我们假设预期的内存名称为 mem,并通过 env 键导入。

然后,我们使用数组的大小来调用 sum 函数以进行求和:

let run = async () => {
    try {
        let bytecode = await fetch("sum/sum.wasm");
        let imports = { env: { mem: memory } };
        let wasm = await WebAssembly.instantiateStreaming(bytecode, imports);
        let sum = wasm.instance.exports.sum(10);
        console.log(sum); 
    }   
    catch(e) {  
        console.error(e);
    }
 };
> run().then();
45

源代码

字符串

由于WebAssembly函数的参数只能是数字,因此无法将字符串值用作参数。要传输字符串,我们必须将字符移入内存缓冲区。我们使用 TextEncoder 函数以高效和灵活的方式来执行此操作。但是我们无法避免内存复制:

const hello = "hello world!";
let memory = new WebAssembly.Memory( { initial:1 } );
let buffer = new Uint8Array(memory.buffer, 0, hello.length); // 无需复制
let encoder = new TextEncoder();
encoder.encodeInto(hello, buffer); // 复制

现在,我们可以调用一个 reverse 函数来反转字节顺序。WebAssembly模块的接口是用C编程语言编写的:

extern unsigned char mem[]; // 导入的内存缓冲区
void reverse(int len); // 反转前 len 个字节的字节顺序

请注意,reverse 函数需要约定来定位字符串的末尾。它可以是存储在缓冲区的前几个字节中的长度,也可以是数据末尾的零字节。在这里,我们将输入字符串的长度作为参数指定。最后,我们使用 TextDecoder 类从内存缓冲区中重建字符串结果,这将创建字节的另一个副本:

let run = async () => {
    try {
        let bytecode = await fetch("reverse/reverse.wasm");
        let imports = { env: { mem: memory } };
        let wasm = await WebAssembly.instantiateStreaming(bytecode, imports);
        wasm.instance.exports.reverse(hello.length);
       
        let decoder = new TextDecoder();
        let reverseString = decoder.decode(buffer); // 复制
        console.log(reverseString);
    }   
    catch(e) {  
        console.error(e);
    }
 };
> run().then();   
!dlrow olleh

源代码

共享内存缓冲区

内存缓冲区可以在JavaScript代码和WebAssembly模块之间共享,但我们还可以在WebAssembly模块之间共享内存缓冲区。

我们可以通过两次实例化相同的内存缓冲区来进行演示。我们从第一个WebAssembly实例模块创建一个新的WebAssembly实例模块,并设置相同的内存缓冲区:

let imports = { env: { mem: memory } };
let wasm1 = await WebAssembly.instantiateStreaming(bytecode, imports);
let wasm2Instance = new WebAssembly.Instance(wasm1.module, imports);

然后,让我们通过两个不同的WebAssembly模块在共享内存缓冲区上执行两次反转操作:

let run = async () => {
    try {
        let bytecode = await fetch("reverse/reverse.wasm");
        let imports = { env: { mem: memory } };
        let wasm1 = await WebAssembly.instantiateStreaming(bytecode, imports);
        let wasm2Instance = new WebAssembly.Instance(wasm1.module, imports);
       
        let decoder = new TextDecoder();
        wasm1.instance.exports.reverse(hello.length);       
        let reverseString = decoder.decode(buffer); // 创建一个副本
        console.log("1", reverseString);
    
        wasm2Instance.exports.reverse(hello.length);
        reverseString = decoder.decode(buffer); // 创建一个副本
        console.log("2", reverseString);
    }   
    catch(e) {  
        console.error(e);
    }
 };
> run().then();   
1 !dlrow olleh
2 hello world!

源代码

静态内存缓冲区

由于内存缓冲区是唯一的,我们必须将所有共享数据存储在同一个内存缓冲区中。例如,WebAssembly模块可以分配三个数组,如下所示(使用C编程语言):

int a[3] = { 1, 2, 3 };
char b[6] = { 'A', 'B', 'C', 'D', 'E', 'F' };
int c[2] = { 4, 5 };

在这种情况下,JavaScript代码不定义内存缓冲区,而是将其作为导出属性获取:

let memory = wasm.instance.exports.memory;

内存缓冲区的组织方式如下。

  • 偏移0处,我们可以找到数组a的开头。每个整数都使用小端约定存储,从最低位到最高位的字节。
  • 偏移12处,我们可以找到数组b的开头。字节是字符' A '、' B '、' C '、' D '、' E '、' F '的字符代码。接下来的两个字节设置为0x00,以便在偏移量是4的倍数的偏移量上对齐下一个整数。这是CPU的约束,以确保快速内存访问。
  • 偏移20处,我们可以找到数组c的开头。
       偏移|       字节       | 数组条目
    ----------+-------------------+----------------------
            0 |0x01 0x00 0x00 0x00| a[0]
            4 |0x02 0x00 0x00 0x00| a[1]
            8 |0x03 0x00 0x00 0x00| a[2] 
           12 |0x41 0x42 0x43 0x44| b[0] b[1] b[2] b[3]
           16 |0x45 0x46 0x00 0x00| b[4] b[5] 0x00 0x00
           20 |0x04 0x00 0x00 0x00| c[0]
           24 |0x05 0x00 0x00 0x00| c[1]

从JavaScript方面,我们必须考虑这些偏移量,以将这些数组映射到正确的字节上。Uint32Array 构造函数允许在不从一个数组复制到另一个数组的情况下访问这些数据:

let run = async () => {
    try {
        let bytecode = await fetch("offset/offset.wasm");
        let wasm = await WebAssembly.instantiateStreaming(bytecode);
        let memory = await wasm.instance.exports.memory;
        let a = new Uint32Array(memory.buffer, 0, 3);
        let b = new Uint8Array(memory.buffer, 12, 6);
        let c = new Uint32Array(memory.buffer, 20, 2);

        console.log(a);
        console.log(new TextDecoder().decode(b));
        console.log(c);
    }
    catch (e) {
        console.error(e);
    }
};

> run().then();
Uint32Array(3) [ 1, 2, 3 ]
ABCDEF
Uint32Array(2) [ 4, 5 ]

此映射代码可能来自与WebAssembly模块一起分发的JavaScript包装器。

源代码

动态内存缓冲区

另一种方法是将内存缓冲区视为,可以在其上实现类似于C编程语言的_malloc_函数的内存分配器。

作为示例,我们在JavaScript端实现了一个简单的内存分配器。_MemoryAllocator_类存储了内存缓冲区中下一个可用字节的偏移量,并根据输入长度和当前偏移量在内存缓冲区上映射一个数组。当我们分配一个整数数组时,我们确保偏移量对齐:

class MemoryAllocator {
  
    constructor(buffer) {
        this.buffer = buffer;
        this.offset = 0;
    }
    
    allocUint32Array (len) {
        // Align the offset on 32 bits integers
        let int32Offset = Math.ceil(this.offset / Uint32Array.BYTES_PER_ELEMENT); 

        let beginOffset = int32Offset * Uint32Array.BYTES_PER_ELEMENT;
        let endOffset = (int32Offset + len) * Uint32Array.BYTES_PER_ELEMENT;

        let subArray = new Uint32Array(this.buffer, beginOffset, len);
        this.offset = endOffset;
        return subArray.fill(0);
    }

    allocUint8Array (len) {
        let subArray = new Uint8Array(this.buffer, this.offset, len);
        this.offset += len;
        return subArray.fill(0);
    }
}

源代码

现在,我们可以在堆上分配每个数组:

let run = () => {
  let memory = new WebAssembly.Memory( { initial: 1 } );
            
  let allocator = new MemoryAllocator(memory.buffer);   

  // Allocate and initialize: int a[3] = { 1, 2, 3 };
  let a = allocator.allocUint32Array(3);
  a.set([1,2,3]);

  // Allocate and initialize: char b[6] = { 'A', 'B', 'C', 'D', 'E', 'F' };
  let b = allocator.allocUint8Array(6);
  new TextEncoder().encodeInto("ABCDEF", b);
            
  // Allocate and initialize: int c[2] = { 4, 5 };
  let c = allocator.allocUint32Array(2);
  c.set([4, 5]);
              
  console.log(a);
  console.log(b, new TextDecoder().decode(b));
  console.log(c);
}
> run();
Uint32Array(3) [ 1, 2, 3 ]
Uint8Array(6) [ 65, 66, 67, 68, 69, 70 ] ABCDEF
Uint32Array(2) [ 4, 5 ]

当需要调用WebAssembly函数时,我们将偏移量和数组的长度设置为参数:

// Example of a call to a WebAssembly module
wasm.instance.exports.sum(c.byteOffset, c.length);

如果需要更多的内存,则可以使用_grow_方法增加内存缓冲区的大小。您可以通过将当前大小与目标大小进行比较,并设置新的页面数来决定是否增加内存缓冲区的大小:

const PAGE_SIZE = 64 * 1024;
let currentSize = memory.buffer.byteLength;
if (currentSize < newSize) {
    let nbPages = Math.ceil((newSize - currentSize) / PAGE_SIZE);
    console.log("grow memory up to ", nbPages, " * ", PAGE_SIZE);
    memory.grow(nbPages);
}

超出边界异常

JavaScript,如果我们尝试设置超出数组末尾的数据,将会抛出异常:

> run().then();
RangeError: offset is out of bounds

JavaScript,如果我们尝试获取超出数组末尾的数据,将会得到一个未定义的值。

WebAssembly模块,如果我们尝试获取设置超出内存缓冲区的某些数据,那么将会抛出异常:

> run().then();
RuntimeError: memory access out of bounds

表格

表格是函数引用的数组。一个WebAssembly模块可以定义一个表格。表格允许通过索引来间接调用由WebAssembly模块实现的函数,而不是直接使用函数名称。

例如,如果WebAssembly模块分配并导出了这个表格,您可以从JavaScript中调用一个函数,如下所示:

let table = wasm.instance.exports.table;
// 使用索引间接调用索引0处的函数,带有参数[1, 2, 3]
table.get(0)(1, 2, 3); 

WebAssembly模块还可以在其自己的一侧使用表格进行间接调用。

间接调用的主要目的是替代以下的switch语句:

let choice = ...;
switch (choice) {
    case 0: 
        doActionA(p1, p2); 
        break;
    case 1:
        doActionB(p1, p2);
        break;
    case 2:
        ...
  }    

通过一个简单的间接调用语句来替代,性能会在存在大量测试用例时得到提高,但如果从时间到时间映射_choice_值到函数的映射可能会发生变化,这种替代也会变得强制性:

let table = wasm.instance.exports.table;
let choice = ...
table.get(choice)(p1, p2);

表格的索引可以表示一个状态,或者WebAssembly模块的一个“对象”的标识符。例如,对于我们的WebAssembly game,我们创建了四艘可以朝四个方向移动的船:东、西、北、南。这个模块以C编程语言的方式公开了以下接口:

int positions[4][2]; // 内容示例:{ {0,0}, {0,0}, {0,0}, {0,0} }
void moveToEast(int shipId);
void moveToWest(int shipId);
void moveToNorth(int shipId);
void moveToSouth(int shipId);
void (*table[4])(int id); // 内容示例:{ moveToNorth, moveToNorth, moveToNorth, moveToNorth }
void gameLoop();

接口的成员包括:

  • positions:一个导出的内存缓冲区,用于存储标识为0、1、2、3的4艘船的x、y坐标,
  • moveToXXXX:四个函数,用于设置船的方向
  • table:间接调用表,表的每个索引都是一个船ID,每个条目都是移动船的函数;默认值是_moveToNorth_
  • gameLoop:主要函数,将更新船的位置;它对函数进行了间接调用,以移动每艘船,在每个游戏循环中。在C编程语言中,实现如下:
void gameLoop()
{
    for (int id = 0; id < 4; id++) 
    {
        table[id](id); // 对标识索引的船应用移动
    }
}

JavaScript调用包括使用函数初始化table以移动船朝一个方向,然后调用_gameLoop_两次,每次调用将设置所有船的新位置:

let run = async () => {
    try {
        let bytecode = await fetch("game/game.wasm");
        let wasm= await WebAssembly.instantiateStreaming(bytecode);

        // WASM模块标识4艘船,每艘船用一个整数ID从0到3标识
        // 通过函数表,为每艘船设置一个初始方向
        let exports = wasm.instance.exports; 
        let table = exports.table;
        table.set(0, exports.moveToEast);  // 船 #0 将向东移动
        table.set(1, exports.moveToWest);  // 船 #1 将向西移动 
        table.set(2, exports.moveToNorth); // 船 #2 将向北移动
        table.set(3, exports.moveToSouth); // 船 #3 将向南移动
  
        // 移动船两个周期  
        let gameLoop = exports.gameLoop;
        gameLoop();
        gameLoop();
  
        // 查看当前船的位置
        let positions = new Int32Array(exports.memory.buffer)
        console.log("Ship #0 locate at (" + positions[0] + ", " + positions[1] +")");
        console.log("Ship #1 locate at (" + positions[2] + ", " + positions[3] +")");
        console.log("Ship #2 locate at (" + positions[4] + ", " + positions[5] +")");
        console.log("Ship #3 locate at (" + positions[6] + ", " + positions[7] +")");
    }   
    catch(e) {  
        console.error(e);
    }
 };
> run().then();   
Ship #0 locate at (2, 0)
Ship #1 locate at (-2, 0)
Ship #2 locate at (0, 2)
Ship #3 locate at (0, -2)

源代码

进一步了解

  • Mozilla Developer Network提供的JavaScript WebAssembly参考文档: [https://

developer.mozilla.org/fr/docs/Web/JavaScript/Reference/Global_Objects/WebAssembly](developer.mozilla.org/fr/docs/Web…)