第三章:探索WebAssembly模块

WebAssembly是一种低级别的类汇编代码,设计用于高效执行和紧凑表示。WebAssembly在所有JavaScript引擎中(包括现代桌面和移动浏览器以及Node.js)都能以接近原生的速度运行。二进制的紧凑表示使得生成的二进制尽可能小。

注意

WebAssembly的主要目标是使高性能应用程序成为可能。

每个WebAssembly文件都是一个高效、优化且自足的模块,称为WebAssembly模块WASM)。WASM是安全的,即二进制文件在一个内存安全和沙盒化的环境中运行。WASM没有权限访问沙盒之外的任何内容。WASM是语言无关、硬件无关和平台无关的。

WebAssembly是一个虚拟指令集架构ISA)。WebAssembly规范定义了以下内容:

  • 指令集
  • 二进制编码
  • 验证
  • 执行语义

WebAssembly规范还定义了WebAssembly二进制的文本表示。

在本章中,我们将探索WASM以及JavaScript引擎如何执行WASM。然后我们探索WebAssembly文本格式及其有用之处。理解WASM执行和WebAssembly文本格式将使我们能够轻松理解模块并在JavaScript引擎中调试它。我们将在本章中覆盖以下主要主题:

  • 理解WebAssembly如何工作
  • 探索WebAssembly文本格式

理解WebAssembly如何工作

首先,让我们探索JavaScript和WebAssembly如何在JavaScript引擎内部执行。

理解JavaScript引擎内部的JavaScript执行

JavaScript引擎首先获取完整的JavaScript文件(注意,引擎必须等到整个文件被下载/加载完毕)。

注意

JavaScript文件越大,加载所需时间就越长。不管你的JavaScript引擎有多快或你的代码有多高效。如果你的JavaScript文件很大(即,大于170KB),那么你的应用程序在加载时间上会很慢。

Figure 3.1 – JavaScript execution inside the JavaScript engine

图3.1 - JavaScript引擎内的JavaScript执行

一旦加载完成,JavaScript被解析成抽象语法树ASTs)。这个阶段称为解析。由于JavaScript既是解释型语言也是编译型语言,JavaScript引擎在解析后开始执行。解释器执行代码更快,但它每次都要编译代码。这个阶段称为解释

JavaScript引擎有观察者(在某些浏览器中称为分析器)。观察者跟踪代码执行情况。如果某个特定的代码块频繁执行,观察者就会将其标记为热点代码。引擎使用即时JIT)编译器编译这部分代码。引擎花费一些时间进行编译,比如纳秒级别。在这里花费的时间是值得的,因为下次调用该函数时,执行会更快,因为编译后的版本总是比解释版的快。这个阶段称为优化

JavaScript引擎增加了一层(或两层)更多的优化。观察者继续监控代码执行。然后,观察者将更频繁调用的代码称为非常热的代码。引擎进一步优化这部分代码。这种优化需要较长时间(考虑类似于-O3级别的优化)。这个阶段产生的代码是高度优化的,运行速度非常快。这段代码比之前的优化代码和解释版的运行速度都要快。显然,引擎在这个阶段花费的时间更多,比如毫秒级别。但这通过代码性能和执行频率得到了补偿。

JavaScript是一种动态类型语言,引擎能做的所有优化都基于类型的假设。如果这个假设被打破,那么代码将被解释和执行,并且优化的代码会被移除,而不是抛出运行时异常。JavaScript引擎实现了必要的类型检查,并在假定的类型发生变化时放弃优化代码。但在优化阶段花费的时间就白费了。

我们可以通过使用类似TypeScript的东西来预防这些类型相关的问题。TypeScript是JavaScript的超集。使用TypeScript,我们可以防止多态代码(接受不同类型的代码)。在JavaScript引擎中,单态代码(只接受一种类型的代码)总是比其多态对应物运行得更快。

如果JavaScript文件体积巨大,那么拥有高度优化的单态JavaScript代码也没有用。JavaScript引擎必须等到整个文件下载完成。在网络连接差的情况下,这几乎永远不会发生。

注意

将JavaScript包分割成更小的块很重要。异步包含JavaScript(换句话说,懒加载)可以提升应用程序的性能。我们需要找到正确的平衡,知道哪个JavaScript模块/文件要加载、缓存然后重新验证。较大的文件大小(有效载荷)将极大地降低应用程序的性能。

最后一个步骤是垃圾收集,所有内存中的活动对象都被移除。JavaScript引擎中的垃圾收集是基于引用的。在垃圾收集周期中,JavaScript引擎从根对象(如Node.js中的global)开始。它找到从根对象引用的所有对象,并将它们标记为可达对象。它将剩余的对象标记为不可达对象。最后,它清除不可达对象。由于这是由JavaScript引擎自动完成的,垃圾收集过程效率不高,而且速度要慢得多。

理解JavaScript引擎内的WebAssembly执行

WASM是二进制格式,已经编译和优化。JavaScript引擎获取WASM,然后解码WASM并将其转换为模块的内部表示(即AST)。这个阶段称为解码。解码阶段比JavaScript的解析阶段快得多。

Figure 3.2 – WebAssembly execution inside the JavaScript engine
图3.2 - JavaScript引擎内的WebAssembly执行

接下来,解码后的WASM进入编译阶段。在这个阶段,模块进行验证,验证过程中会检查某些条件以保证模块是安全的,没有任何有害的代码。函数、指令序列和堆栈的使用在验证过程中进行类型检查。然后将已验证的代码编译为机器可执行代码。由于WASM已经被编译和优化,所以这个编译阶段更快。在这个阶段,WASM被转换为机器代码。

编译后的代码随后进入执行阶段。在执行阶段,模块被实例化和调用。在实例化过程中,引擎实例化状态和执行栈(存储与程序相关的所有信息的内存),然后执行模块。

WebAssembly的另一个优势是,模块从第一个字节开始就已准备好编译和实例化。因此,JavaScript引擎不必等到整个模块下载完毕。这进一步提高了WebAssembly的性能。WebAssembly之所以快,是因为它的执行步骤比JavaScript执行少,所以二进制文件已经被优化和编译,且可以进行流式编译。

注意

WASM并不总是提供高性能。在某些情况下,JavaScript表现更佳。因此,了解这一点并在使用WebAssembly之前思考是必要的。

了解更多关于JavaScript性能及其加载时间的影响,请访问 https://medium.com/@addyosmani/the-cost-of-javascript-in-2018-7d8950fbb5d4。

了解更多关于webpack中的分块和代码分割,请访问 https://webpack.js.org/guides/code-splitting/。

我们已经了解了WebAssembly在浏览器内的工作原理;现在,让我们探索WebAssembly文本格式。

探索WebAssembly文本格式

机器理解一串1和0。我们优化二进制文件以使其运行得更快、更高效。指令越简洁和优化,机器的效率和性能就越高。但对人来说,理解和分析一大堆1和0是很困难的。这正是我们开始抽象并创建高级编程语言的原因。

在WebAssembly世界中,我们将人类可读的编程语言(如Rust、Go和C/C++)转换为二进制代码。这些二进制文件是一堆带有操作码和操作数的指令。这些指令使机器高效运行,但从上下文角度让我们难以理解。

为什么我们应该担心生成的二进制文件的可读性?因为它有助于我们理解代码,有助于调试代码。

WebAssembly提供了WebAssembly文本格式,WAST或WAT。WAST是WebAssembly二进制的人类可读格式。JavaScript引擎(无论是在浏览器还是Node.js中),在加载WebAssembly文件时,可以将二进制文件转换为WebAssembly文本格式。这有助于理解代码内容并进行调试。文本编辑器可以以WebAssembly文本格式显示二进制文件,这比其二进制对应物更易于阅读。

基本的WASM二进制格式如下:

00 61 73 6d 01 00 00 00

这段内容翻译成中文如下:

 00 61 73 6d 01 00 00 00
\0  a  s  m  1  0  0  0 (ascii value of the character)
|         |  |
---------  version
    |
Magic Header

这个基本模块有一个魔术头(\0asm),后跟WebAssembly的版本(01)。

文本格式采用S表达式格式编写。S表达式语法中的每个指令/表达式都应该在一对括号()内。S表达式通常用于定义嵌套列表或结构化树。许多关于基于树的数据结构的研究论文都使用这种表示法来展示他们的代码。S表达式从XML中去除了所有不必要的繁文缛节,提供了一种简洁的格式。

注意

这种表达式(在括号内定义一切)看起来熟悉吗?你有没有使用过LISP(或受LISP启发构建的语言)?

模块是WASM中的基本构建块。基本WASM的文本表示如下:

(module ) 

WASM由一个头部和零个或多个部分组成。头部以魔术头和WASM版本开始。在头部之后,WASM可能包含以下零个或多个部分:

  • 类型
  • 函数
  • 内存
  • 全局变量
  • 元素
  • 数据
  • 起始函数
  • 导出
  • 导入

WASM中所有这些部分都是可选的。WASM的结构如下所示:

 module ::= {
    types vec<funcType>,
    funcs vec<func>,
    tables vec<table>,
    mems vec<mem>,
    globals vec<global>,
    elem vec<elem>,
    data vec<data>,
    start start,
    imports vec<import>,
    exports vec<export>
 }  

WASM内的每个部分都是一个向量(数组),包含零个或多个相应类型的值,除了start。我们将在书中后面探索start部分。现在,start保存一个索引,它引用funcs部分中的一个函数。

WASM中的每个部分都采用以下格式:

<section id><u32 section size><Actual content of the section> 

第一个字节指的是唯一的部分ID。每个部分都有一个唯一的部分ID。在唯一的部分ID旁边是一个无符号32位u32)整数,定义了部分的大小(以字节为单位)。其余字节是部分内容。

注意

由于部分大小由u32整数定义,因此部分的最大大小限制为大约4.2GB的内存(即2^32 - 1)。

在WebAssembly文本格式中,我们使用部分的名称来表示部分中的每个段落。

例如,函数部分包含一系列函数。WebAssembly文本格式中的一个示例函数定义如下:

 (func <name>? <func_type> <local>* <inst>* )

与其他表达式一样,我们定义的所有内容都在括号()内。首先,我们用func关键字定义函数块。在func关键字之后,我们添加函数的名称。这里的函数名称是可选的,因为在二进制中,函数由函数部分内的函数块索引标识。

名称后面是func_typefunc_type在规范中被称为type_use。这里的type_use指的是类型定义。func_type保存所有输入参数(及其类型)和函数的返回类型。因此,对于一个接受两个输入操作数并返回结果的add函数,func_type将如下所示:

(param $lhs i32) (param $rhs i32) (result i32) 

注意

类型可以是i32i64f32f64(32位和64位整数或浮点数)。未来,当WebAssembly支持更多类型时,类型信息可能会发生变化。

param关键字表示定义的表达式持有一个参数。$lhs是变量名。注意,在WebAssembly文本格式中定义的所有变量都会以$为前缀。在此之后,我们有参数的类型,i32。同样,我们为第二个操作数$rhs定义了另一个表达式。最后,返回类型被标记为(result i32)result关键字表示表达式是返回类型,后跟类型i32

func_type之后,我们定义函数内部将使用的任何局部变量。最后,我们有一个指令/操作列表。

让我们根据前面的代码片段定义一个add函数:

 (func $add (param $lhs i32) (param $rhs i32) (result i32)
    get_local $lhs
    get_local $rhs
    i32.add)  

整个块都包裹在括号内。函数块从func关键字开始。然后,我们有一个可选的函数名称($add)。WebAssembly二进制模块将使用函数部分内的函数索引来识别函数,而不是名称。然后,我们定义操作数和返回类型。

注意

在二进制格式中,通过type部分定义参数和结果,因为这有助于优化生成的函数。但在文本格式中,为了简洁和易于理解,类型信息将在每个函数定义中显示。

然后,我们有一个指令列表。第一个指令get_local获取(来自堆的)$lhs的局部值。然后,我们获取$rhs的局部值。之后,我们使用i32.add指令将它们相加。最后,闭合括号结束了一切。

没有单独的return语句/表达式。那么,函数如何知道返回什么呢?

正如我们之前所见,WebAssembly是一个堆栈机。当调用一个函数时,它为该函数创建一个空堆栈。然后,函数使用这个堆栈来推送和弹出数据。因此,当执行get_local指令时,它会将值推送到堆栈中。在两次get_local调用之后,堆栈将有$lhs$rhs。最后,i32.add会从堆栈中弹出两个值,执行add操作,并推送元素。当函数结束时,堆栈顶部的元素将被取出并提供给函数调用者。

如果我们想要将这个函数导出到外部世界,那么我们可以添加一个export块:

 (export <export_name> (func <function_reference>))

export块在()内定义。export块以export关键字开始。export关键字后跟函数的名称。在名称之后,我们引用函数。函数块由后面的func关键字组成。然后,我们有function_reference,它指的是模块内定义/导入的函数的名称。

为了导出add函数,我们定义如下:

 (export "add" (func $add)) 

"add"指的是函数在模块外部导出的名称,后跟(func $add),指的是函数。

函数和export部分都应该包裹在module部分内,以使其成为有效的WASM:

(module
    (func $add (param $lhs i32) (param $rhs i32) 
      (result i32)
        get_local $lhs
        get_local $rhs
        i32.add)
    (export "add" (func $add))
)  

这幅插图展示了WebAssembly代码的树形结构。在这个结构中,“module"作为根部,从中延伸出两个主要分支:“function"和"export”。在"function"分支下,有标有"param”、"result"和"local"的子分支。而在"export"分支下,则展示了一个标有"func reference"的子分支。这些分支相互连接,形成一个有机的整体,代表了WebAssembly文本格式的复杂结构。
在这里插入图片描述

在WebAssembly文本格式中构建函数

为此,我们将使用递归的斐波那契数列生成器。我们将编写的斐波那契函数将具有以下格式:

 # Sample code in C for reference
int fib(n) {
    if (n <= 1)
        return 1;
    else
        return fib(n-1)+ fib(n-2);
}  

首先,让我们使用WebAssembly文本格式定义给定fib函数的函数签名。类似于其C语言对应物,fib函数接受一个数字参数并返回一个数字。因此,函数定义在WebAssembly文本格式中遵循相同的签名:

 (func $fib (param $n i32) (result i32)
    ...
) 

我们在括号()内定义函数。函数以func关键字开始。在关键字之后,我们添加函数名称$fib。然后,我们为函数添加参数;在我们的案例中,函数只有一个参数n;我们将其定义为(param $n i32)。然后,函数返回一个数字,(result i32)

WebAssembly没有内存来处理临时变量。为了拥有局部值,我们应该将值推入堆栈,然后检索它。因此,为了检查n<=1,我们必须首先创建一个局部变量并将1存储在其中,然后进行检查。要定义局部变量,我们使用local块。local块以local关键字开始。这个关键字后跟变量的名称。在变量名称之后,我们定义变量的类型:

 (local <name> <type>)

让我们创建一个名为$tmplocal变量:

 (local $tmp i32) 

注意

(local $tmp i32)不是指令。它是函数声明的一部分。记住,前面的函数语法包括local

然后我们必须将$tmp的值设置为1。为了设置值,我们首先必须将值1推入堆栈,之后我们必须从堆栈中弹出该值并将其设置为$tmp

i32.const 1
set_local $tmp  

i32.const创建一个i32常量值并将其推入堆栈。所以,这里我们创建一个值为1的常量并将其推入堆栈。

然后,我们使用set_local设置$tmp中的值。set_local从堆栈顶部取出值,在我们的案例中是1,并将$tmp的值设置为1

现在,我们必须检查给定的参数是否小于2。WebAssembly提供i32.<some_action>来对i32执行某些操作。例如,要添加两个数字,我们使用了i32.add。类似地,要检查它是否小于特定值,我们有i32.lt_s_s表示我们正在检查有符号数字。

i32.lt_s需要两个操作数。对于第一个操作数(即$n),我们使用get_local表达式从$n中提取值并将其放在堆栈顶部。然后,我们使用i32.const 2创建一个2的常量并将2添加到堆栈。最后,我们使用i32.lt_s比较$n值和2

get_local $n
i32.const 2
i32.lt_s 

但我们如何定义if条件?WebAssembly提供br_ifblock

在WebAssembly文本格式中,使用block关键字定义块,并跟随一个名称以标识该块。我们使用end结束块。块的格式如下:

block $block
... ; some code goes in here.
end 

我们将这个块提供给br_ifbr_if在条件成功时调用该块:

get_local $n
i32.const 2
i32.lt_s
br_if $block ; calls the $block` only when the condition
  succeeds. 

到目前为止,WebAssembly文本格式将如下所示:

 (module
  (func $fib (param $n i32) (result i32) (local $tmp i32)
    i32.const 1
    set_local $tmp
    ; block
    block $block
      ; if condition
      get_local $n
      i32.const 2
      i32.lt_s
      br_if $block
    ... ; some code
    end
    ; return value
    get_local $tmp
  )
)  

一切都包裹在module内。在$block结束时,值将存储在$tmp中。我们使用get_local $tmp获取$tmp的值。唯一剩下要做的事情是创建循环。

循环时间

首先,我们将$tmp设置为1

i32.const 1
set_local $tmp  

然后,我们将创建一个循环。在WebAssembly文本格式中,使用loop关键字来创建循环:

loop $loop
end  

loop关键字后跟循环的名称。循环以end关键字结束。loop是一个特殊的块,将一直运行,直到我们使用某些条件表达式(如br_if)退出:

get_local $n
i32.const -2
i32.add
call $fib
get_local $tmp
i32.add
set_local $tmp
get_local $n
i32.const -1
i32.add
tee_local $n
i32.const 1
i32.gt_s
br_if $loop 

我们获取$n并向其添加-2,然后调用fib函数。要调用函数,我们使用call关键字,后跟函数的名称。这里,call $fib返回值并将该值推入堆栈。

现在,使用get_local $tmp获取$tmp。这会将$tmp推入堆栈。然后,我们使用i32.add从堆栈中弹出两个值并将它们相加。最后,我们使用set_local $tmp设置$tmpset_local $tmp从堆栈顶部取出值并将其赋给$tmp。我们获取$n并向其添加-1

这里我们使用tee_local,因为tee_local类似于set_local,但它不是将值推入堆栈,而是返回值。最后,我们循环运行,直到$n大于1。如果它小于1,我们使用br_if $loop跳出循环。完整的WebAssembly文本格式将如下所示:

(module
  (func (export $fib (param $n i32) (result i32) 
    (local $tmp i32)
    i32.const 1
    set_local $tmp
    ; block
    block $block
      ; if condition
      get_local $n
      i32.const 2
      i32.lt_s
      br_if $block
      ; loop
      loop $loop
        get_local $n
        i32.const -2
        i32.add
        call $fib
        get_local $tmp
        i32.add
        set_local $tmp
        get_local $n
        i32.const -1
        i32.add
        tee_local $n
        i32.const 1
        i32.gt_s
        br_if $loop
      end
    end
    ; return value
    get_local $tmp
  )
)

在未来的章节中,我们将看到如何将这种WebAssembly文本格式转换为WASM并执行它。

如果您有兴趣了解更多关于S表达式的信息,请查看https://en.wikipedia.org/wiki/S-expression。

要了解更多关于WebAssembly文本格式设计的信息,请查看规范https://github.com/WebAssembly/design/blob/master/Semantics.md。

在https://webassembly.github.io/spec/core/text/instructions.html查看更多文本指令。

参考各种指令及其操作码https://webassembly.github.io/spec/core/binary/instructions.html。

了解更多关于二进制编码的信息https://github.com/WebAssembly/design/blob/master/BinaryEncoding.md。

总结

在本章中,我们了解了WebAssembly在JavaScript引擎内部的执行方式,并探索了WebAssembly文本格式是什么以及如何使用WebAssembly文本格式定义WASM。在下一章中,我们将探索WebAssembly二进制工具包。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值