WebAssembly的一知半解

共 8962字,需浏览 18分钟

 ·

2022-01-16 22:00

随着互联网的发展,网络应用变得越来越复杂,如3d可视化、音视频软件以及大型网络游戏。因此,代码的效率和安全性变得更加重要。WebAssembly 是一个可移植的底层字节码,它通过提供紧凑的表示、高效的验证和编译以及低开销甚至零开销的安全执行来满足这些需求。它不仅是一个特定的编程模型,而且是独立于语言和平台的一个现代硬件抽象。

1. 缘起

由于历史的偶然性,JavaScript 是 Web 上唯一天生支持的编程语言。由于其在实现中的普遍性、快速的性能改进,或许由于纯粹的需要,它已经成为许多其他语言的编译目标。然而,JavaScript 有不一致的性能和各种其他的问题。

WebAssembly (简称“ Wasm”)用底层代码解决了 Web上的安全、快速和可移植问题。从 ActiveX 到 Native Client 再到 asm.js,都没有达到这种代码格式应该具有的属性:

  • 安全、快速、可移植性的语义:可以安全而快速地执行,并且与语言、硬件和平台无关,具有确定性结果并易于推理,同时与 Web 平台能够简单地互操作

  • 安全有效的表达形式:机构紧凑,很容易解码,验证和编译,对开发者来说很容易生成,支持流式和并行处理。

为什么这些目标很重要呢? 为什么又很难呢?

安全性

代码的安全性在 Web 上是至关重要的,因为代码往往来自不可信的源。代码的保护在传统上是通过提供托管语言运行时来实现的,如浏览器的 JavaScript 虚拟机或语言插件。托管增强了内存的安全性,防止程序损害用户数据或系统状态。然而,托管的运行时在传统上并没有为底层代码提供更多的内容,例如c/c++的程序。

快速

类似于c/c++的静态语言,编译器会对底层代码提前进行优化。本机代码,无论是手写的还是编译器优化后的输出,都可以充分利用机器的性能。运行时托管和沙箱技术通常会给这些代码带来巨大的性能开销。

统一性

除了不可避免的硬件限制之外,存在着大量的优秀编程范例,这些范例都不应该受到代码格式的限制。然而,大多数的运行时托管被设计成能很好地支持特定的语言或者编程范式,同时又给其他语言带来了巨大的成本。

便于移植

Web 不仅跨越了许多设备,还跨越了不同的机器体系结构、操作系统和浏览器。针对 Web 的代码必须独立于硬件和平台,以允许应用程序以相同的确定性行为跨所有的浏览器和硬件类型运行。以前的低级别代码解决方案都绑定在单一体系结构上,或者存在着其他可移植性问题。

紧凑的机构

通过网络传输的代码应该很小,以减少负载、节省带宽并提高整体的响应能力。Web 上的代码通常以 JavaScript 源代码的形式传输,即使对其进行了压缩,也远不如二进制格式紧凑。二进制代码格式也并不总是针对大小进行优化。

WebAssembly 是第一个针对 Web 的低级别代码解决方案,它实现了上述所有设计目标,是所有主要浏览器供应商和在线社区为构建高性能应用程序的通用解决方案进行协作的一个结果。

虽然 Web 是 WebAssembly 的缘起之地,但它的设计避免了对 Web 的任何依赖。它是一个开放标准,能够在嵌入到各种各样的环境中,也许是第一个从开始就用形式语义学来设计的工业级语言。

2. 语言概述

尽管 WebAssembly 是一种二进制代码格式,本质上仍然是一种具有语法和结构的编程语言,这使得它更容易解释和理解。

2.1. 基础

以下是WebAssembly 中的一些基本概念。

模块

WebAssembly 二进制文件采用了模块的形式。它包含函数、全局变量、表和内存的定义,这些定义可以通过导入、导出用于复用。

虽然模块对应于程序的静态表示,但模块的动态表示是一个实例,具有完整的可变状态。实例化一个模块需要为所有导入提供定义,这些导入可能是从以前创建的实例导出的,通过调用导出函数来启动计算。模块提供了封装和沙盒,客户端只能访问模块的导出,其他内部构件受到保护而不被篡改; 同时,模块只能通过客户端提供的导入与其环境交互,因此客户端对给定模块的功能拥有完全的控制权。这两个方面都是代码安全的重要组成部分。

函数

模块中的代码被组织成单独的函数,获取参数并返回由其函数类型定义的结果。函数可以相互调用,包括递归调用,运行中的 WebAssembly 程序不能直接访问执行调用的堆栈。

指令

WebAssembly 在概念上是基于堆栈的机器,函数的代码由操作堆栈上值的指令序列组成。然而,类型系统的布局可以在代码中的任何点静态确定,因此可以直接编译指令之间的数据流,而无需实现操作堆栈。堆栈的组织仅仅是实现紧凑表达的一种方式,它比基于寄存器的机器尺寸要小。

trap异常

某些指令可能会产生一个异常的trap,这会立即中止当前的计算。异常的trap可以不由 WebAssembly 代码处理,一个嵌入器通常会提供处理这种情况的方法,例如,将它们具体化为 JavaScript 异常。

数值类型

WebAssembly 只有四个基本值类型可以计算。这些是整数和浮点数,每个都有32位或64位之分,可以在普通硬件中使用。大多数 WebAssembly 指令对这些数值类型提供了简单的操作符,例如一元和二元运算符、比较和转换。与硬件一样,WebAssembly 不区分有/无符号整数类型。

变量

函数可以声明可变局部变量,这实际上提供了一组零初始化的虚拟寄存器。模块还可以声明类型化的全局变量,这些变量可以是可变的,也可以是不可变的,并且需要显式的初始值设定项。导入全局变量允许一种有限的可配置性,例如链接。像 WebAssembly 中的所有实体一样,变量通过整数索引引用。

2.2 内存

WebAssembly 的主要存储器是大量的字节数组、线性存储器或简单存储器。通过加载和存储指令访问内存,其中地址只是无符号整数。

创建与扩展

每个模块最多只能定义一块内存区域,可以通过导入/导出与其他实例共享。创建的内存区域具有初始大小,但可以动态增长。增长单元是一个页,它被定义为64kb,这将允许在硬件上重用虚拟内存硬件进行边界检查。页大小是固定的,而不是系统特定的,以防止可移植性的危险。

字节次序

由于大多数现代硬件都集中在 little endian 上,或者至少可以同样很好地处理它,WebAssembly 的内存区域同样是little endian的字节顺序。因此,内存访问的语义在所有引擎和平台之间是完全确定和可移植的。

内存安全

所有内存访问都是根据内存大小动态检查的,越界访问将导致异常trap。线性内存与代码空间、执行堆栈和引擎的数据结构是分离的,因此,编译后的程序不能破坏它们的执行环境,不能跳转到任意位置,或执行其他未定义行为。要以高性能的方式与不受信任的 JavaScript 和各种 Web API进行交互,就必须实现快速的进程内隔离。同时,WebAssembly 引擎安全地嵌入到其他托管语言运行时中。

2.3. 控制流

WebAssembly 表示的控制流与大多数基于堆栈的机器不同。它不提供任意跳转,而是提供更类似于编程语言的结构化控制流。通过这种构造确保了控制流不会形成不可约减的循环,不会包含堆栈高度不对齐的块分支,或者不会分支到多字节指令的中间。这些属性允许在一次传递中验证 WebAssembly 代码,在一次传递中编译。

控制结构

块、循环和 if 结构必须由结束操作码终止,并且必须正确嵌套才能被认为是格式良好的结构。这些结构中的内部指令序列形成一个块。注意,循环不会自动迭代,但允许使用显式分支手动构造循环。每个控件结构都带有一个函数的类型注释,描述其对堆栈的影响、类型化的Pop/Push值。

分支

分支可以是无条件的、条件的或索引的。它们具有“标签”的即时性,不表示指令流中的位置,而是通过相对嵌套深度引用外部控制结构。因此,标签有效地限定了作用域: 分支只能引用它们嵌套在其中的构造。如果一个分支从该构造的块中断开,切效果取决于目标的构造: 对于一个块,或者如果它是一个向前跳转到它的结束(如 break 语句) ; 对于一个循环,它是一个向后跳转到它的开始(如 continue 语句)。分支通过隐式弹出所有未使用的操作符来解除对操作符堆栈的纠缠,类似于函数调用的返回。

表达式

结构化控制流似乎是一个严格的限制,但大多数高级控制结构都可以通过合适的块嵌套轻松表达。例如,c 样式 switch 语句,对于无序条件之间的失败,需要更多的技巧。各种形式的循环同样可以用分支组合来表示。

将非结构化的控制流转换为结构化形式是开发者的责任。这是 Web 编译的既定方法,其中 JavaScript 也被限制为结构化控件。这种限制的好处是,引擎中的许多算法更简单、更快速。

2.4. 函数调用和表

函数体是一个块。执行可以通过以函数在堆栈上的结果值到达块的末尾来完成,也可以通过退出函数块的分支来完成,返回指令只是后者的简写。

调用

函数可以使用调用指令直接调用,指令可以用函数指针来模拟,该指令将运行时索引引用到模块定义的函数表中。表中的函数不需要具有相同的类型。相反,在不匹配的情况下,将根据提供的指令和trap的预期类型动态检查函数的类型,保护了执行环境的完整性。表的异构性允许函数指针更准确地表示,并简化了动态链接。为了进一步帮助动态链接的场景,可以通过外部API改变导出的表。

外部调用

函数可以导入到模块中,直接和间接调用都可以调用导入的函数,并且通过导出/导入,多个模块实例可以通信。此外,导入机制作为一个安全的外部函数接口( , WebAssembly 程序通过它可以与其嵌入环境通信。例如,在 Web 上导入的函数可能是由 JavaScript 定义的宿主函数。跨越语言边界的值将根据 JavaScript 规则自动转换。

2.5. 确定性结果

WebAssembly 试图在不牺牲性能的情况下为低级代码提供一个可移植的目标。硬件行为不同的地方通常包括整数除以零,溢出或浮点转换以及对齐等。WebAssembly 的设计以最小的执行开销为所有这些硬件提供确定性语义。

然而,依赖于实现的行为仍然有三个来源可以被视为非确定性的:

  • NaN有效载荷:WebAssembly 遵循 IEEE 754标准进行浮点运算。但是,IEEE 并没有在所有情况下为 NaN 值指定精确的位模式,cpu 之间存在显著差异,而在每个数值操作之后进行规范化的开销太大。基于 JavaScript 引擎的经验,可以提供足够的保证来支持像 NaN-tagging 这样的技术。

  • 资源耗尽:资源总是有限的,而且在设备之间差异很大。特别是,引擎可能会出现内存不足,调用指令也可能由于堆栈溢出而产生异常trap,但是,WebAssembly 本身无法观察到这些情况,只是中止了计算。

  • 宿主函数:WebAssembly 程序可以调用本身不确定或者更改 WebAssembly 状态的宿主函数。当然,调用宿主函数的结果也超出了 WebAssembly 的语义范围。

2.6. 二进制格式

WebAssembly 作为抽象语法的二进制编码进行传输,这种编码被设计为最小化尺寸大型和解码时间。二进制文件表示一个单独的模块,并根据其中声明的不同类型的实体被划分为若干部分。函数体的代码被推迟到所有声明之后的一个单独的部分,以便在函数体开始通过网络到达时启用流式编译。引擎还可以并行编译函数体。。该格式还允许用户自定义的部分,这些部分可能会被引擎忽略。

3. 语义的感知

WebAssembly 语义由两部分组成: 定义验证的静态语义和定义执行的动态语义。在这两种情况下,对于声明性规范来说都是方便而有效的工具。最后,形式化能够轻松地证明WebAssembly 规范,机器验证结果的正确性,以及构建一个可证明的正确解释器。

执行

执行是根据一个标准的小步骤缩减关系来定义的,其中每个计算步骤都被描述为一系列指令的重写规则。堆栈只是由一个指令序列中所有前导标识的指令组成,当指令序列被减少为与结果值堆栈相对应的常量时,执行终止

为了处理控制构造,使用少量辅助管理的指令扩展语法,这些辅助指令只在还原过程中临时出现,框架本质上是函数调用的调用框架。

存储区为程序的全局状态建模,并记录已分配的函数、全局、表和内存实例的列表。存储组件之一的索引称为地址,模块实例将指令中出现的静态索引映射到存储中各自的动态地址。为此,除了函数本地变量的状态之外,每个帧还携带一个到它所在的模块实例的链接,实现可以通过将生成的机器代码专门化为模块实例来消除这些闭包。

存储、帧和指令序列的三元组一起构成一个配置,表示 WebAssembly 抽象机器在给定时间点的完整状态。一般的约简规则是重写配置,而不仅仅是指令序列。

验证

在 Web 上,代码是从不可信的来源获取的,必须经过验证。Webasembly 的验证规则简洁地定义为类型系统。这种类型系统,在设计成在一个单一的线性有效检查。

指令的类型是指定其所需输入堆栈和提供的输出堆栈的函数类型。每条规则由一个结论和一个可能是空的前提列表组成。它可以被解读为: 如果所有前提都成立,结论就成立。每个指令都有一个规则,定义何时类型良好。当且仅当规则能归纳地推导出它是类型良好的程序时,该程序才有效。

例如,常量和数值运算符的规则是公理,甚至不需要一个前提。控制构造的规则要求它们的类型匹配显式注释,并且在检查内部块时使用本地标签扩展上下文。当键入分支指令时,会在上下文中查找标签类型,这需要堆栈上的适当操作符来匹配连接点上的堆栈。

可靠性

WebAssembly 系统类型具有标准的可靠性属性。约简规则实际上涵盖了有效程序可能出现的所有执行状态。这意味着没有类型安全的违规,如无效调用或非法访问局部变量,它保证了内存安全,并确保了代码地址或调用堆栈的不可访问性。它还意味着操作符堆栈的使用是结构化的,其布局在所有程序点上都是静态确定的,这对于在基于寄存器的机器上的高效编译至关重要。此外,它还建立了内存和状态封装,即模块和函数边界上的抽象属性,这些属性不会泄漏信息。

机械化证明

WebAssembly 中的引用解释器包括了将形式规则直接转译为可执行代码。虽然这两个任务基本上都很简单,但它们总是容易出现测试没有发现的细微错误。机器化语义验证不仅在于验证 WebAssembly 本身,还在于为其他形式化方法的应用程序提供了基础,例如验证针对 WebAssembly 的编译器或证明程序的性质、程序等价性和安全性。

4. 标准化

WebAssembly 的形式化语义能够促进标准化的形成。

核心语言

WebAssembly语言的定义遵循形式化,并指定抽象语法、类型规则、约简规则和抽象存储。二进制格式和文本格式作为属性文法给出,准确地描述了它们产生的抽象语法。对于工业级语言来说,这种严谨和精确程度可能是前所未有的。

  • 形式化:一个广泛使用的标准不能假定所有读者都熟悉语义的形式化符号,将正式规则集中放在标准文档中,即使不直接阅读这些规则的开发者也会从中受益。

  • 引用解释器:随着浏览器对 WebAssembly 的产品化实现,引用解释器近似于“可执行的规范”被用来开发测试套件,来测试具体的实现和形式规范,以及构建新特性的原型。

显然,形式化的语义并非在所有情况下都是直截了当的。WebAssembly 的实现包括了多阶段提案流程。在提案的不同阶段,维护者必须提供: (1)非正式的描述,(2)非正式规范,(3)原型实现,(4)全面的测试套件,(5)形式规范,(6)引用解释器中的实现,(7)独立生产系统中的实现。

嵌入执行环境

WebAssembly 类似于虚拟指令集的程序架构,因为它不定义程序如何加载到执行引擎,也不定义程序如何执行 i/o。这种设计分离可以将 WebAssembly 实现嵌入到执行环境中。嵌入机制定义了模块如何加载、导入和导出如何解析、trap如何处理,并提供用于访问环境的外部函数。

为了加强平台独立性,WebAssembly标准被分层为单独的文档: 核心规范只定义了虚拟指令集架构,单独的嵌入规范定义了它与具体主机环境的交互。

在浏览器中,可以通过 JavaScript API 加载、编译和调用 WebAssembly 模块。粗略的方法是(1)从给定源获取二进制模块,例如,作为网络资源,(2)实例化它,提供必要的导入,(3)调用所需的导出函数。由于编译和实例化可能比较慢,因此它们作为异步方法提供,其结果包装在承诺中。JavaScript API 还允许在外部创建和初始化内存或表,或者作为导出访问它们。

作为一种底层语言,WebAssembly 不提供任何内置的对象模型,这种设计为开发者提供了最大的灵活性,并且不像以前的虚拟机那样,不锁定任何特定的编程范式/对象模型。生产者可以在 WebAssembly 之上定义通用的 ABI,这样模块就可以在不同的应用程序中进行互操作了。这个关注点的分离对于将 WebAssembly 作为一种通用的代码格式至关重要。

5. 实现中的一些考量

WebAssembly 的主要设计目标是在不牺牲安全性和可移植性的情况下实现高性能。

V8(Chrome)、 SpiderMonkey (Firefox)和 JavaScriptCore (WebKit)重用其JS编译器来提前编译 WebAssembly 模块。这样可以获得可预测的高性能,启动速度更快,并且可能降低内存消耗。在这些实现中,使用了相同的使用抽象控制和操作符堆栈的算法策略。在解码过程中,对传入字节码进行一次验证,不需要额外的中间表示。

SpiderMonkey 引擎包括了两个 WebAssembly 编译层。第一种是快速的基线 JIT,JIT 不创建中间表示(Intermediate Representation,IR) ,但是会跟踪寄存器状态,并尝试在前进传递中执行简单的寄存器分配。基线 JIT 仅用于快速启动,而优化 JIT 在后台并行编译模块。V8在原型配置中包含类似的基线 JIT。优化 JIT 可以获得 JavaScript 的顶级执行,并将其重用于 WebAssembly。V8和 SpiderMonkey 都使用基于SSA的中间表示。WebAssembly 的结构化控制流对此有很大的帮助,使得解码算法更简单、更有效,并且避免了通常 JIT 的局限性。

通过设计,可以通过动态边界检查保证 WebAssembly 中的所有内存访问是安全的,这相当于根据内存的当前大小检查地址。引擎将从进程中的某个基址开始,在一个很大的连续范围内分配内存。为了快速访问,基址可以存储在一个专用的机器寄存器中,一个更积极的策略是将每个实例的机器代码专门化到一个特定的基地址,将它作为一个常量直接嵌入到代码中。

在64位平台上,引擎可以利用虚拟内存来完全消除内存访问的边界检查。引擎只是保留8GB的虚拟地址空间,除了启动附近的有效内存部分,所有页标记为不可访问。由于 WebAssembly 内存地址和偏移量是32位整数加上一个静态常数,任何访问都不能超过8GB的地址空间。因此,JIT 可以简单地发出普通的加载/存储指令,并依靠硬件保护机制来捕获越界访问。

通过超前编译, WebAssembly 模块的编译可以并行化,将各个函数分配到不同的线程,这样做明显地提高了性能。例如,v8和 SpiderMonkey 使用8个编译线程,在编译速度上都提高了5-6倍。此外,WebAssembly 二进制格式的设计支持流媒体,在加载完整的二进制文件之前,引擎可以开始编译单个函数。当与并行化结合时,这最小化了冷启动时间。

代码缓存除了冷启动时间之外,热启动时间也很重要,因为用户可能会反复访问相同的 Web 页面。IndexedDB 数据库的 JavaScript API 允许 JavaScript 操作和编译 WebAssembly 模块,并将其编译后的表示作为一个不透明的 blob 存储。这允许 JavaScript 应用程序在下载和编译它之前,首先向 IndexedDB 查询 WebAssembly 模块的缓存版本。在 v8和 SpiderMonkey 中,这种机制可以使启动时间提高数秒的数量级。字节码验证的速度和简单性是获得良好性能和高保证的关键,WebAssembly 不像 JVM 字节码验证那样有150页说明,而只是一页形式化符号。

6. 小结

WebAssembly 侧重于支持底层代码,特别是从 c/c + + 编译而来的代码。通过包含相关的原语(如尾调用、堆栈切换或协同程序) ,WebAssembly 可能会发展成为高级语言,但是,一个非常重要的目标是提供对内置在所有 Web 浏览器中的垃圾收集器的访问,从而消除在编译 Web 与 JavaScript 时候的相关缺陷。除了 Web 之外,WebAssembly 还可能在其他领域找到广泛的用途,例如内容传输网络中的沙盒,智能合约或区块链上的去中心化计算,作为移动设备的代码格式,甚至仅仅作为提供便携式运行时的单独引擎。


【参考文献与关联阅读】


浏览 41
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报