找回密码
 会员注册
查看: 11|回复: 0

WebAssembly工作原理浅析_UTF_8

[复制链接]

7

主题

0

回帖

22

积分

新手上路

积分
22
发表于 2024-9-30 07:31:31 | 显示全部楼层 |阅读模式
1. 前言狭义上的 WebAssembly 是 W3C 标准化组织制定的一个可移植、体积小、加载快并且兼容 Web 的全新二进制格式,在前面章节中已经对此做过详细的阐述;然而,随着 WebAssembly 技术的演进和广泛应用,它的内涵也在不断的外延,广义的 WebAssembly 可以理解为基于 WebAssembly 演化出来的完整生态,如下图 1 所示。图 1. WebAssembly 应用和基础架构总览在上图中,SAPPHIRE 作为全球的创业投资公司,从 WebAssembly 的应用和基础框架两个维度描绘了 WebAssembly 的核心生态[1],其中,应用生态依赖的 WebAssembly 基础核心架构。WebAssembly 的基础核心架构主要分为三部分,首先是可移植的二进制格式本身;其次,由于手写 WebAssembly 是一项极具挑战和开发者不友好的方式,因此,需要提供面向 WebAssembly 的高级编程语言,以及将高级编程语言编译为 WebAssembly 二进制格式的编译工具链和语言核心库,我们将其称之为 WebAssembly 前端 (Frontend);最后,WebAssembly 的可移植性需要通过虚拟机来提供高效的运行环境;如下图 2 所示。图 2. WebAssembly 基础核心架构图WebAssembly 核心架构中二进制规范,语言核心库和编译工具链等内容已经在前面的章节中进行了详细的阐述,本文将从 WebAssembly 运行时的维度来分析和阐述 WebAssembly 核心原理及基础能力。WebAssembly 运行时由模块加载和解析器、执行引擎以及与宿主的系统交互接口(WASI)等关键部分组成。其中,WasmLoader 主要完成 WebAssembly 标准二进制文件 [2]的加载、解码,格式校验,初始化和多模块的链接等多个阶段;当文件加载完成后,WebAssembly 运行时会为二进制文件生成对应的 WebAssembly 模块实例对象,并初始化运行时环境中数据区(WasmRuntime Data Areas),包括全局数据区(global space)、方法区(function space)、间接对象引用区(table space)以及线性内存区(memory space);完成以上两个阶段后,运行时会调度执行引擎 (Execution Engine) 来执行对应 WebAssembly 方法区中函数的字节码,不同的执行引擎会采用不同的技术来执行字节码,其中最典型的是解释执行和运行期编译执行两种类型;此外,执行引擎还需要调度内存管理器来完成内存分配,并利用垃圾回收机制进行运行期复杂对象的管理。WebAssembly 运行时结构如下图 3 所示。图 3. WebAssembly 运行时架构图接下来,我们将从 WebAssembly 模块解析器出发,逐一介绍 WebAssembly 运行时的各个组成部分及其底层工作原理。2. WebAssembly 解析器在上一小节中,我们简单的介绍了 WebAssembly 模块的加载和解析,它主要包含了 WebAssembly 的二进制文件的加载、验证、实例化等阶段,为逻辑功能的执行初始化运行时环境。2.1 模块加载和解码WebAssembly 模块的二进制格式是其抽象语法的压缩线性编码[2],格式由属性文法定义,其唯一的终结符号是字节,当且仅当它是由语法生成时,字节序列才是模块的格式良好的编码;因此,属性文法隐含地定义了解码函数 (即二进制格式的解析函数)。WebAssembly 运行时首先需要加载二进制文件,按照属性文法解析函数将字节流转换为内存中虚拟机可以识别和使用的数据结构。与常用的二进制格式(例如 ELF )类似,WebAssembly 的二进制格式以段 (Section) 编码为模块文件 (Module)。大多数段对应于模块记录的一个组件,此外,各段之间通过依赖关系来共享数据,例如,代码段依赖于函数段定义的类型及函数索引,函数段依赖类型段对函数签名的声明,模块的段结构如下图 4 所示。图 4. WebAssembly 二进制模块结构图WebAssembly 为了减少模块体积,无论是无符号还是有符号整数都使用 LEB128 可变长度整数编码进行编码。按照 WebAssembly 模块的标准格式和编码规则,加载和解码阶段将会把 WebAssembly 二进制文件内容转换为 "WasmRuntime Data Areas" 中的内部数据进行保存,其中最为关键的是全局数据区 (global space)、方法区 (function space)、间接函数引用区 (table space ) 以及线性内存区 (memory space) 四大运行时区域,运行时区域内容如下图 5 所示。图 5. WebAssembly 运行时状态空间示意图以如下模块中函数对象为例,在模块的加载过程中,模块解析器会根据 WebAssembly 二进制格式内容在执行环境中创建对应的 "function space" 内存空间,该内存空间用于保存模块执行时所需要的所有函数对象;解析器按函数在模块中的定义顺序为其分配单调递增的索引,导入函数由被导入的模块提供,暂无法解析,需要在函数索引空间中为其预留空间;模块加载后索引空间如下图 6 所示。图 6. 函数名字空间示意图2.2 模块验证当完成第一阶段的模块加载后,解析器会对模块进行校验。模块验证主要验证 WebAssembly 模块是否格式正确,只有有效的模块才能被实例化。有效性由类型系统在模块及其内容的抽象语法上定义,对于每一段抽象语法,都有一个类型规则来指定适用于它的约束,它描述了有效的模块或指令序列必须满足的约束。Validation Algorithm[3] 提供了根据规范对指令序列进行类型检查的完整算法的框架,该算法在二进制格式操作码序列上进行表达,并且只对其执行一次传递,因此,它可以直接集成到加载器中进行模块验证。图 7. local.tee 验证约束规则示意图以指令 local.tee 为例,上图 7 以形式化符号和文字描述两种形式声明了 local.tee 验证约束规则;该规则的前提条件是当前上下文 (C) 定义了 local 变量 x,该变量 x 的类型为 t(t 可以是类型 i32 | f32 | i64 | f64 | v128 | funcref | externref 中的任一类型),那么当且仅当 local.tee 指令的输入参数为 t 类型并且返回 t 类型的值才是合法的指令。当 local.tee 是合法指令,当前指令序列才能正常执行指令逻辑,进而修改程序运行状态;否则,执行过程将会停止并抛出异常,local.tee 指令执行逻辑如下图 8 所示。图 8. local.tee 指令流程示意图2.3 实例化当完成了上述两个阶段,解析器的最后阶段是完成 WebAssembly 模块实例化,实例化的主要工作是根据 WebAssembly 二进制加载过程中生成的数据结构创建对象实例,并完成对象实例的符号解析和链接过程,如下图 9 所示。图 9. WebAssembly 模块实例化示意图以模块中函数内存空间为例,虽然在加载过程中已经完成了索引空间的创建和布局 (如图 6 所示),但尚未对符号完成解析和链接,因为某些符号并不一定是本模块所定义而需要从外部模块导入,例如,"env.print", "share_ctx.fib","share_ctx.distance" 函数。在 WebAssembly 模块实例化过程中,解析器需要获取被导入的模块,并在模块中解析需要导入的符号对象,并将导入对象保存至模块的对应索引空间中,该过程我们称为 WebAssembly 模块的动态链接 (动态链接详细内容请参见第 9 章),如下图 10 所示。图 10. WebAssembly 函数链接示意图3. 执行引擎执行引擎是一个运行环境的"心脏",它负责目标指令的执行和运行时状态的管理;针对 WebAssembly 基于栈的概念模型,我们从解释器、线性内存管理和垃圾回收这三个维度对执行引擎的原理进行介绍。3.1 栈解释器常用的硬件架构通常采用基于寄存器的 "三地址" (opcode dest, src1, src2 )和 "二地址"(op dest, src in x86)指令集,如 arm,x86,risc 等处理器架构。相比较而言,零地址指令的源与目标都是隐含参数,可以用更少空间存放更多指令,指令"密度"高、代码的传输与存储的开销小、平台移植性好;因此,在空间紧缺的环境中,零地址指令是一种经常被采用的设计。WebAssembly 指令集在整体上是按照零地址形式设计的,它的指令集通过 "基于栈的架构" 来实现。栈是一种 "后进先出" 的数据结构,在基于栈的虚拟机中,大部分指令执行时都会从栈中取出操作数,然后,根据指令逻辑对这些操作数进行相应的运算,并将所得到的结果重新压入栈中;WebAssembly 选择使用零地址的栈虚拟机,除了实现简单、快速外,还在于它便于高效地实现 WebAssembly 模块的验证;WebAssembly 模块执行前不仅需要验证模块的合法性,而且需要对模块逻辑做相应的静态分析、安全性验证、形式化证明等;比如,验证模块是否访问了规定范围外的内存,模块中各个函数的返回值类型是否正确,模块中表达式变量的作用域是否正确等,基于栈架构的模型,可以十分简单和快速地进行这些检查。本文主要目的是介绍 WebAssembly 核心原理及其底层机制,因此,接下来,我们将通过一个简短的实例来说明 WebAssembly 基于栈虚拟机执行模型。voidfoo(){inta=1;intb=2;intc=(a+b)*5;returnc;}首先,我们将上述源代码所表示的逻辑转换为如下对应的 WebAssembly 字节码。(module(type(;0;)(func(resulti32)))(func(;0;)(export"foo")(type0)(resulti32)(locali32i32i32)i32.const1local.set0i32.const2local.set1local.get0local.get1i32.addi32.const5i32.mullocal.tee2return))WebAssembly 函数执行需要一个调用栈,该栈以帧 (frame) 为单位记录函数的执行状态,每调用一个函数就会分配一个新的栈帧压入调用栈上,每从一个函数返回则弹出并撤销相应的栈帧。每个栈帧包括局部变量区、操作数栈,和其它一些信息;局部变量区用于存储方法的参数与局部变量,其中参数按源码中从左到右顺序保存在局部变量区开头的几个槽位中;操作数栈用于保存计算过程的中间结果和调用别的方法的参数等;每个函数所需要的局部变量区与操作数栈大小都能够在编译时确定,并记录在 WebAssembly 二进制文件中;函数调用栈如下图 11 所示。图 11. 函数调用栈示意图基于函数的栈帧结构定义,foo 函数所需要的局部变量区大小为 3 个内存槽,分别用于存放临时变量 a,b,c;由于操作数栈的内存槽可以复用,foo 函数需要的操作数栈大小为 2 个内存槽,用于存放表达式的临时计算结果;如下图 12 右侧结构所示。此外,图 12 以动画的方式展示了 foo 函数中 WebAssembly 字节码的完整执行过程,通过跟踪执行过程,可以轻松地理解字节码是如何指示虚拟机将数据压入或弹出栈,以及数据是如何在栈与局部变量区之间传递的,例如,算数运算指令 iadd 和 imul 指令都需要从求值栈弹出两个值运算,再把结果压回到栈上的。图 12. 栈解析器执行示意图上图 12 中所有数字均以十六进制表示,其中,"字节码" 表示指令的 16 进制数值,"助记符" 则是其对应的文本描述;标记为红色的值是相对上一条指令的执行状态有所更新的值;程序计数器 (PC) 用于记录程序当前执行的位置,以字节为单位记录当前指令相对函数起始位置的偏移量。至此,我们已经详细地介绍了 WebAssembly 基于栈结构模型的指令执行的大致过程;然而,如果按照指令在虚拟机中的执行方式来区分,寄存器型虚拟机模型也会被经常使用到;关于栈式虚拟机和寄存器式虚拟机的讨论在"解释器,树遍历解释器,基于栈与基于寄存器"[4]和"栈式虚拟机和寄存器式虚拟机"[5][6]话题中有很好的讨论和回答;此外,基于 JIT 的高级优化虚拟机技术请参见相关的专业书籍和文档[7][8],不在此展开介绍。3.2 线性内存管理内存是执行引擎的基石,是数据的读写和访问的基础。如上图 3 "WasmRuntime Data Areas" 所示,WebAssembly 内存包含了托管的内存和非托管内存两种类型。托管的内存是指由虚拟机管理的内存,包括全局数据区(global space)、方法区(function space)、间接对象引用区(table space),运行时栈区。非托管内存主要包括线性内存区 (memory space),它允许用户程序进行访问 (load, store) 和管理 (memory.grow)。传统的内存管理主要以进程为单位,通过操作系统提供的 API 来申请、访问和释放,这种模型虽然可以提供高效的内存访问,但带来了比较严重的内存问题,例如,不同的应用共享了相同的虚拟机内存空间,越界访问,非法内存地址的访问,内存数据防窃取等各种内存安全问题无法从根本上解决,此外,应用间无法做到很好的隔离,恶意的软件可以轻易的窃取用户信息。图 13. WebAssembly 线性内存示意图如上图 13 所示,WebAssembly 采用了线性内存的设计,线性内存是一个地址连续的、可进行字节寻址的内存结构,从偏移量 0 一直延伸到最大内存大小,最大内存大小始终是 WebAssembly 页的的整数倍,一个 WebAssembly 内存页被规定为固定的 64KB 大小。每个 WebAssembly 实例都有一个专门指定的默认线性内存,WebAssembly 线性内存类型由模块的内存段 (Memory Section) 进行描述,虚拟机通过获取模块的内存段类型信息来预留最大内存大小,并且读取数据段 (Data Section) 来初始化内存区域。WebAssembly 线性内存被放置在一个完全封闭的沙箱执行环境中,这使得线性内存与进程中其他类型的内存完全分离;并且在同一个进程中和其他应用在隔离环境中共存,甚至可以在现有的 JavaScript 虚拟机中实现。在 web 环境中[9],WebAssembly 将会严格遵守同源策略以及浏览器安全策略。总的来说,基于线性内存的沙箱环境可以让不同应用在进程内共存,又可以在隔离环境中更加安全地使用内部的线性内存。此外,WebAssembly 线性内存是结构化的连续内存区域,线性内存的布局是由编译器来定的,而且,现在 WebAssembly 支持作为多种前端语言的编译产物,在每个编译器有自己的内存布局的时候,会导致不同语言模块之间静态和动态链接的技术挑战。当前,LLVM 作为众多 WebAssembly 编译工具链的后端,其内存布局的实现主要借助链接器 wasm-ld 实现。wasm-ld 链接器将线性内存分为 4 个区域,包括全局静态数据区 (data area)、未初始化数据区 (bss data)、辅助栈区 (stack) 以及堆区 (heap)。wasm-ld 默认将全局静态数据区 (data area) 作为 WebAssembly 线性内存空间的第一个内存区域,但在提供了 "--stack-first" 链接选项的情况下,wasm-ld 会强制将辅助栈区 (stack) 作为线性内存空间的第一个内存区域来布局。wasm-ld 内存布局主要通过 layoutMemory 函数实现,其核心逻辑核心逻辑参见如下源码中的备注[10]。//Fixthememorylayoutoftheoutputbinary.Thisassignsmemoryoffsets//toeachoftheinputdatasectionsaswellastheexplicitstackregion.//Thedefaultmemorylayoutisasfollows,fromlowtohigh.////-initializeddata(startingatConfig->globalBase)//-BSSdata(notcurrentlyimplementedinllvm)//-explicitstack(Config->ZStackSize)//-heapstart/unallocated////The--stack-firstoptionmeansthatstackisplacedbeforeanystaticdata.//Thiscanbeusefulsinceitmeansthatstackoverflowtrapsimmediately//ratherthanoverwritingglobaldata,butalsoincreasescodesizesinceall//staticdataloadsandstoresrequirelargeroffsets.voidWriter::layoutMemory(){uint32_tmemoryPtr=0;autoplaceStack=[&](){/*...skipnon-criticalsourcecode*/memoryPtr+=config->zStackSize;auto*sp=cast(WasmSym::stackPointer);sp->global->global.InitExpr.Value.Int32=memoryPtr;};/*1.fillthefirstmemoryregion*/if(config->stackFirst){/*fillthestackatthefirstmemoryregionwhilelinkingwiththe"-Wl,--stack-first"option*/placeStack();}else{memoryPtr=config->globalBase;log("mem:globalbase="+Twine(config->globalBase));}/*2.setthestartaddressof"dataarea"("__global_base")*/if(WasmSym::globalBase)WasmSym::globalBase->setVirtualAddress(memoryPtr);if(WasmSym::definedMemoryBase)WasmSym::definedMemoryBase->setVirtualAddress(memoryPtr);uint32_tdataStart=memoryPtr;/*...skipnon-criticalsourcecode*//*3.settheendaddressof"dataarea"("__data_end")*/if(WasmSym::dataEnd)WasmSym::dataEnd->setVirtualAddress(memoryPtr);/*...skipnon-criticalsourcecode*//*4.fillthe"stackarea"indefaultmodesetthestartaddressof"startarea"("stack_pointer")*/if(!config->stackFirst)placeStack();/*5.setthestartaddressof"heaparea"("__heap_base")*///Set`__heap_base`todirectlyfollowtheendofthestackorglobaldata.//Thefactthatthiscomeslastmeansthatamalloc/brkimplementation//cangrowtheheapatruntime.if(WasmSym::heapBase)WasmSym::heapBase->setVirtualAddress(memoryPtr);/*...skipnon-criticalsourcecode*//*6.setthe"memorytype"forlinearmemory(limits::={min,max})*/out.memorySec->numMemoryPages=alignTo(memoryPtr,WasmPageSize)/WasmPageSize;/*...skipnon-criticalsourcecode*///Checkmaxifexplicitlysuppliedorrequiredbysharedmemoryif(config->maxMemory!=0||config->sharedMemory){/*...skipnon-criticalsourcecode*/out.memorySec->maxMemoryPages=config->maxMemory/WasmPageSize;}}基于上述实现源码,我们通过一个简单的示例来演示 wasm-ld 在生成 WebAssembly 模块时是如何进行线性内存布局的,示例代码如下所示;其中,源代码中通过字符数组 array 申请了 2KB 的全局静态数据区。/*linear-memory-layout.c*/staticchararray[2048];intlayout(){inta=1;intb=2;intc=(a+b)*5;//avoidDCEoptimizationarray[1000]=1;returnc;}基于上述代码,我们通过如下的编译命令来获取 WebAssembly 模块的默认线性内存布局信息,其中,通过 "-z stack-size=1024" 链接选项指定栈大小。//编译生成*wasm,设置堆栈1K,初始内存64K(1page),最大内存128K(2pages)clang--target=wasm32\-O0-nostdlib\-zstack-size=1024\-Wl,--no-entry\-Wl,--export-all\-Wl,--allow-undefined\-Wl,--initial-memory=65536\-Wl,--max-memory=131072\linear-memory-layout.c-odefault-memory-layout.wasm//将wasm反汇编为文本格式wasm2watdefault-memory-layout.wasm-odefault-memory-layout.watClang 默认生成的 WebAssembly 线性内存布局如下图 14 所示,按源码和编译选项中设置,线性内存初始为 1 页,即 64KB,最大内存为 2 页,即 128KB;全局数据区为 [__global_base, __data_end] 用于 array 全局数组,总计 2KB;栈区域为 ($**stack_pointer, **data_end) 从高地址往低地址增长,总计为 1 KB;堆区的起始地址为 __head_base,紧邻栈区,增长的方向高地址方向。图 14. WebAssembly 默认线性内存布局示意图上图 14 所示的线性内存布局标识符由 WebAssembly 全局(Global)变量进行定义,并在 WebAssembly 代码中进行访问和管理。对应 WebAssembly 模块的详细信息可以参见如下 default-memory-layout.wat 文件所示。;;default-memory-layout.wat;;(memory(;0;)12)(global$__stack_pointer(muti32)(i32.const4096))(global(;1;)i32(i32.const1024))(global(;2;)i32(i32.const3072))(global(;3;)i32(i32.const1024))(global(;4;)i32(i32.const4096))(global(;5;)i32(i32.const0))(global(;6;)i32(i32.const1))(export"memory"(memory0))(export"__data_end"(global2))(export"__global_base"(global3))(export"__heap_base"(global4))(export"__memory_base"(global5))基于上述相同的源代码,我们可以通过增加 "-Wl, --stack-first" 编译选项来改变默认的内存布局, 通过如下的编译命令可以获得栈优先的 WebAssembly 模块线性内存布局信息。//编译生成*wasm,设置堆栈1K,初始内存64K(1page),最大内存128K(2pages)clang--target=wasm32\-O0-nostdlib\-zstack-size=1024\-Wl,--no-entry\-Wl,--export-all\-Wl,--allow-undefined\-Wl,--initial-memory=65536\-Wl,--max-memory=131072\-Wl,--stack-first\linear-memory-layout.c-ostack-first-memory-layout.wasm//将wasm反汇编为文本格式wasm2watstack-first-memory-layout.wasm-ostack-first-memory-layout.watClang 采用栈优先布局编译选项生成的 WebAssembly 线性内存布局如下图 15 所示,如源码和编译选项中设置,线性内存初始为 1 页,即 64 KB,最大内存为 2 页,即 128 KB;栈区作为线性内存的第一个内存区域,在 ($__stack_pointer, __memory_base) 范围内从高地址往低地址增长,总计为 1 KB;全局数据区为 [__global_base, __data_end] 用于 array 全局数组,总计 2 KB;堆区的起始地址为 __head_base,紧邻全局静态数据区,增长的方向高地址方向。图 15. WebAssembly 栈优先线性内存布局示意图上图 15 所示的栈优先线性内存布局标识符由 WebAssembly 全局变量 (Global) 进行定义,并在 WebAssembly 代码中进行访问和管理。对应 WebAssembly 模块的详细信息可以参见如下 stack-first-memory-layout.wat 文件所示。;;stack-first-memory-layout.wat;;(memory(;0;)12)(global$__stack_pointer(muti32)(i32.const1024))(global(;1;)i32(i32.const1024))(global(;2;)i32(i32.const3072))(global(;3;)i32(i32.const1024))(global(;4;)i32(i32.const3072))(global(;5;)i32(i32.const0))(global(;6;)i32(i32.const1))(export"__data_end"(global2))(export"__global_base"(global3))(export"__heap_base"(global4))(export"__memory_base"(global5))3.3 垃圾回收器(GC)对于一个成熟的虚拟机而言,内存管理是其核心功能之一;主流的内存管理方式主要包括手动内存管理 (C/C++) 和垃圾收集器内存管理 (Java, JavaScript 等)两大类。这两种内存管理方式各有优劣,对他们的讨论也从未停止过。手动内管理方式在进行对象动态创建和销毁时,需要手动分配和释放内存,而在大型的软件中手动调用 malloc 和 free,很容易出现内存重复释放导致的非法内存访问,或者忘记释放导致的内存泄露。使用垃圾收集器的编程语言所分配的内存会由垃圾收集器来统一管理,每隔一段时间,垃圾回收器会对内存对象进行扫描,自动识别出来到底哪些内存区域可以被释放。简而言之,垃圾收集器(GC)让开发人员无需过多考虑内存管理,他们可以管理对象引用、传递对象、在函数/变量之间共享对象,并且在不再使用这些对象时依靠 GC 来清理它们。当然,垃圾收集器的问题也是显而易见的,垃圾收集器需要间歇性地扫描内存中可释放的内存区域并回收垃圾,这会产生不受代码控制的 "stop the world" 现象;由于垃圾收集器有比较大的开销只能在特定情况下触发,因此无法即时释放空闲内存区域,导致内存平均水位偏高;此外,垃圾收集器需要完全掌控内存使用情况,这导致处理异构环境或语言边界的对象回收时总是需要更多内存拷贝操作,编程语言之间交互的代码更加繁琐。尽管如此,现在很多编程语言仍然带有垃圾回收器,而系统编程语言或者为解决性能问题的编程语言或者执行环境,一般不使用垃圾回收器来进行内存管理。对 WebAssembly 而言,初期的主要设计目标是提供一个底层的高效二进制格式及其对应的运行环境,并将静态强类型语言 (C/C++) 直接静态编译到字节码,避免在语言层面的额外开销,从而提升性能;而目标编程语言没有采用垃圾收集器,例如 C/C++,Rust 等,因此,WebAssembly 没有垃圾收集器,它只提供了一块可以按字节寻址的线性内存,而没有任何可用于内存管理的工具。对于i32、f32、i64、f64 等基础数据类型,WebAssembly 可以在内存中高效的访问、传递,而对于 Struct、Array 等复杂的数据结构,需要手动负责对象创建和回收对象(类似于 C/C++ )或者采用优化的内存分配器来完成内存的管理工作,例如,dlmalloc、tcmalloc、jemalloc等。作为面向所有语言的一个底层的字节码规范,WebAssembly 在内存管理机制上仅支持现有手动管理方式,还是增加垃圾收集器进行自动管理一直存在不同的观点,在 Garbage collection[11] 提案的 Issue 列表中有过非常广泛的讨论 (It doesn't seem like WebAssembly should have a GC)[12][13]。Garbage collection 提案已经到了第三个实现阶段 (Phase 3 - Implementation Phase (CG + WG)),正如提案中描述的,通过对垃圾收集器的支持可以更好的解决如下 3 方面的问题。首先,采用垃圾收集器,WebAssembly 可以以更快的执行性能,更小的体积支持更广泛的现代高级语言。WebAssembly 目标是作为所有高级语言的编译目标产物,针对需要垃圾回收器的源编程语言,为了生成可用的 WebAssembly 目标模块,编译器只能将垃圾收集器实现也同时编译为 WebAssembly 字节码,并将其作为二进制文件的一部分,例如 AssemblyScript 就在二进制文件中包含了一个 "makeshift GC"。但这样会增加二进制文件的大小,同时 GC 算法的效率也会受到影响。由于 WebAssembly 缺少垃圾收集器,也成为 Scala,Elm,go,Java 等语言还不支持编译成 WebAssembly 的原因之一。因此,实现垃圾收集器及其前置提案可以支持 WebAssembly 作为更多高级语言的高性能执行、体积小的目标产物。其次,垃圾收集器 (GC) 解放了开发人员对内存管理,提高了内存的安全性。很多现代编程语言都带有垃圾回收器,WebAssembly 作为可嵌入的模块,支持快速友好的接入现有工业级的垃圾收集器,既能减轻 WebAssembly 本身的内存管理负担,也能够为宿主的垃圾收集器提供一个 "同构" 的内存对象管理环境,降低异构环境或异构语言对宿主垃圾收集器的干扰,例如,基于 Garbage collection 提案标准,V8 中实现的 WebAssembly 垃圾回收实际是接入到 JavaScript 虚拟机的垃圾收集器,而并没有实现一个自己独立的垃圾收集器。最后,WebAssembly 很多场景本质上是一个异构的多语言环境,统一的垃圾收集器可以实现多种语言之间无缝互操作。例如,JavaScript 环境中使用 WebAssembly 作为模块集成,i32,f32,i64,f64 等基础数据类型可以在语言间方便的进行交互,然而,当宿主与 WebAssembly 进行复杂数据结构交互时,一个方案是将这些对象拷贝到 WebAssembly 的线性内存中进行访问,返回时需要将数据拷贝至宿主内存中,然而,这不可避免的引入性能的开销与复杂度,这是严重次优的解决方案;虽然,WebAssembly 可以通过持有宿主对象的引用来避免数据拷贝,但 WebAssembly 被赋予一个引用时,它并不总是知道它来自哪里以及该引用背后的实际数据类型 (Opaque Type reference)。因此,WebAssembly 为了发挥性能价值,避免这样的开销,必须通过进一步扩展 WebAssembly 标准来解决。为了表述的准确性,本文将 WebAssembly 线性内存中对象称为 "Guest Object",而在宿主环境中的对象称为 "Host Object"。在一般情况下,"Guest Object" 的生命周期可能直接取决于 "Host Object",反之亦然。解决这种生命周期相互依赖的唯一正确方法是跨宿主环境边界扩展 WebAssembly。在 "Guest Object" 依赖于宿主环境的可 GC "Host Object"的场景中,已经取得了很大的进步,例如,宿主对象是一个 JavaScript 对象,而 "Guest Object" 对象是一个 C++ 对象。JavaScript 的 WeakRef[15][16]机制可以为可 GC JavaScript 对象注册一个 "Finalizer" 回调函数,回调函数可以在 "Host Object" 被垃圾回收的时候调用以释放关联的 "Guest Object"。但是,它不提供反向功能,即 "Host Object" 依赖于 WebAssembly 线性内存中的 "Guest Object" 的生命周期,无法直接在 WebAssembly 层面持有宿主环境 GC 可见的 "Host Object" 引用,那么必须有一个对应的宿主对象的强引用来保持它的存活,这又导致内存泄漏,如下图 16 所示。图 16. WebAssembly 宿主对象与线性内存对象绑定示意图Garbage collection[11] 提案将垃圾收集器功能带到 WebAssembly 中,用于管理线性内存,如下图 17 所示。图 17. WebAssembly 线性内存垃圾回收示意图由于垃圾收集器的主要职责是对可回收对象进行全生命周期的管理,包括对象的创建、对象的访问和赋值、以及垃圾对象的销毁。因此, WebAssembly 需要定义完整的类型系统和对象原语(指令)以便创建可回收对象,对象赋值的追踪,对象属性的访问,内存扫描和回收等。这导致 WebAssembly 规范增加了许多内容,包括类型的扩展,例如,在基础数据类型之外,增加了 struct、array、structref、arrayref 等高级数据类型,以及与这些数据类型对应的指令扩展,struct.new | get | set、array.new | get | set | len 等。这个对 WebAssembly 来说是一个重大变化,因为 WebAssembly 和传统的汇编语言一样是无类型的低级语言,这种变化使得 WebAssembly 逐步往类型化汇编语言的方向演进。类型化汇编语言 (Typed Assembly Language) [17][18] 通过类型注释、内存管理原语和一套完善的类型规则扩展了传统的非类型化汇编语言;这些类型规则保证了类型化汇编程序的内存安全、控制流安全和类型安全;此外,类型结构的表现力足以对大多数源语言编程特征进行编码,包括结构、数组、高阶和多态函数、异常、抽象数据类型、子类型和模块,便于实现基于垃圾收集的高级内存管理系统。Garbage collection 提案的另一个目标是实现多种语言之间无缝互操作性。由于 Garbage collection 的复杂性,该提案被分解成更小部分以解决问题的不同组成部分,并以独立提案发布规范。如上文所述,现有的 WebAssembly 规范无法解决 "Host Object" 依赖于 WebAssembly 线性内存中的 "Guest Object" 的生命周期问题;GC 提案假设不同的模块可以自由共享 GC 对象的引用,这一目标是通过 Reference Types[19] 和 Typed Function References[20] 提案完成的;其中 Reference Types[19] 提案增加了 externref、funcref 用于表示和传递宿主中的对象引用,允许多个 table 用于保存和访问对象引用;Typed Function References[20] 提案扩展了引用类型的表示方法,采用 ref $t 表示对类型 $t 的引用;将函数类型作为一等公民对待,可以通过 ref.func $f 访问函数而不是必须放入 table 中间接访问。Interface Types[21] 扩展了函数类型,允许复杂数据结构作为函数参数传递,例如 record,list 等,增强了宿主与 WebAssembly 的交互性;Type Imports[22] 提案允许模块导入类型定义,这样,宿主可以提供自定义类型并导入 WebAssembly 模块中,而模块可以对它进行类型化引用 ref $t。在上述提案的基础上,Garbage collection 提案进一步扩展了类型系统和指令集,使得 WebAssembly 可以通过引用类型及其子类型与宿主进行交互,同时对象之间的引用关系基于 WebAssembly 引用类型做到了无缝的连接,如下图 18 所示。图 18. WebAssembly 宿主对象与线性内存对象引用管理示意图Garbage collection 提案还在不断的改进中,它不断努力尝试找到一种 GC 解决方案,该解决方案对于 Guest 语言来说足够好,以致于在一般情况下他们不需要自带 GC,而是与宿主环境的 GC 集成,与宿主的 GC 集成的选项不仅可以更轻松地将众多高级语言编译为 WebAssembly(Java、C#、Elm、Scala),还可以更轻松地与主机创建的对象进行互操作,从而将所有子系统收敛到一个统一的 GC,而每个子系统可以为自身的子堆 (subheap) 定制特定的垃圾收集算法,与此同时,GC 的使用在 WebAssembly 中是可选的,允许像 Rust 和 C++ 这样的语言仍然使用内存分配器和线性内存。4. WebAssembly 系统接口(WASI)WebAssembly 是一种用于概念机器而非物理机器的低级别语言,平台可移植性和安全性是 WebAssembly 的首要目标之一,即跨所有不同的操作系统安全运行;为此,WebAssembly 需要一个概念操作系统的系统接口,而不是任何特定单一的操作系统,这就是 WebAssembly 平台的系统接口(WASI)[25][26]。为了保护系统资源的安全,操作系统基本上在系统资源周围设置了一道保护屏障,内核是唯一可以访问系统资源的特权模块,因此, WebAssembly 系统接口的主要目的是定义一组可移植、模块化、独立于运行时的 WebAssembly 原生 API,WebAssembly 代码可以使用这些 API 与外界交互,这些 API 通过基于特定功能的接口设计保留了 WebAssembly 的基本沙盒特性,并通过平台的系统调用访问和操作系统资源。WebAssembly 系统接口不是单一的标准系统接口,而是标准化 API 的模块化集合。以模块化的方式制定标准接口,允许 WASI 从最基础的模块 wasi-core 开始制定最小可用标准接口,然后添加其他功能,并根据反馈和经验确定优先级,逐步制定和实施,如下图 19 所示。图 19. WebAssembly WASI 模块示意图为了支持 WebAssembly 在浏览器之外的环境中运行,WASI WorkGroup(WG) 以提案的形式制定了 wasi-core 标准 API[26],它提供了程序运行所需要的基本能力,涵盖了 POSIX 能力的大部分内容,包括文件、网络连接、时钟和随机数等内容。对于其中的许多能力,它将采用与 POSIX 非常相似的方法。例如,它将使用 POSIX 的面向文件的方法,您可以在其中进行系统调用,例如打开、关闭、读取和写入,其他所有内容基本上都提供了增强功能。针对这些能力,WASI 工作组提交和推进了多个 WASI 提案[27],这就是模块化方法的用武之地,这样,我们可以获得良好的标准化覆盖率,同时仍然允许不同的基平台使用对它们有意义的 WASI 部分,WASI 相关核心提案如下图 20 所示。图 20. WebAssembly WASI 核心提案wasm-core 标准接口以 wasi-libc 的形式在 wasi-sdk 库中提供使用支持,wasi-libc 为 WebAssembly 程序提供了广泛的 POSIX 兼容 C API,包括对标准 I/O、文件 I/O、文件系统操作、内存管理、时间、字符串、环境变量、程序启动和许多其他 API 的支持。尽管 wasi-libc 正在继续发展以更好地与 WeAssembly 和 WASI 保持一致,但它依然足够稳定并且可用于多种用途,因为大多数 POSIX 兼容的 API 都是稳定的,如下图 21 所示。图 21. WebAssembly WASI 系统架构示意图5. 总结至此,我们已经从模块加载和解析、模块执行以及与宿主的交互机制等方面对 WebAssembly 运行时原理进行了详细的介绍;此外,基于 WebAssembly 线性内存布局和最新提案,对 WebAssembly GC 机制进行了简要的介绍。虽然,继 WebAssembly 的最小可用版本 ( MVP) 登陆浏览器之后,又发布了 WebAssembly 规范 2.0,但并不意味着 WebAssembly 已经很完善;事实上,情况远非如此,WebAssembly 将提供许多功能,它们将从根本上改变你可以使用 WebAssembly 来完成的工作。WebAssembly 未来所能提供的特性就如一棵技能树[28],我们已经获得了初始的技能来为我们完成工作,然而,这棵技能树还有很多新技能待我们去解锁,以为我们完成很多看起来不可能完成的任务,值得我们持续的关注和投入。6. 参考文献[1]. What’s Up With WebAssembly: Compute’s Next Paradigm Shift: https://sapphireventures.com/blog/whats-up-with-webassembly-computes-next-paradigm-shift/[2]. Binary Format: https://webassembly.github.io/spec/core/binary/conventions.html[3]. Validation Algorithm: https://webassembly.github.io/spec/core/appendix/algorithm.html[4]. 虚拟机随谈(一):解释器,树遍历解释器,基于栈与基于寄存器,大杂烩: https://www.iteye.com/blog/rednaxelafx-492667[5]. 栈式虚拟机和寄存器式虚拟机: https://www.zhihu.com/question/35777031/answer/64575683[6]. Virtual Machine Showdown: Stack Versus Registers: https://www.usenix.org/legacy/events/vee05/full_papers/p153-yunhe.pdf[7]. Principles of Just-In-Time Compilers: https://nbp.github.io/slides/GlobalScope/2021-07/#intro,0[8]. 计算机程序的构造和解释: https://book.douban.com/subject/1148282/[9]. Web Embedding: http://webassembly.org.cn/docs/web/[10]. lld::wasm::Writer: https://github.com/llvm-mirror/lld/blob/master/wasm/Writer.cpp#L190[11]. Garbage collection(Phase 3 - Implementation Phase (CG + WG)): https://github.com/WebAssembly/gc/blob/main/proposals/gc/Overview.md[12]. It doesn't seem like WebAssembly should have a GC: https://github.com/WebAssembly/gc/issues/36[13]. Why does the WebAssembly GC need this enriched type system: https://github.com/WebAssembly/gc/issues/32[14]. WebAssembly, Expanding the Pie: https://www.infoq.com/news/2020/05/webassembly-summit-2020-apie/[15]. JavaScript Garbage Collection with WebAssembly is Possible Today: https://jott.live/markdown/js_gc_in_wasm[16]. WeakRef: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakRef[17]. Typed Assembly Language: https://en.wikipedia.org/wiki/Typed_assembly_language[18]. Typed Assembly Language Compiler: https://www.cs.cornell.edu/talc/[19]. Reference Types: https://github.com/WebAssembly/reference-types[20]. Typed Function References: https://github.com/WebAssembly/function-references[21]. Interface Types: https://github.com/WebAssembly/interface-types[22]. Type Imports: https://github.com/WebAssembly/proposal-type-imports[23]. The road to WebAssembly GC for OCaml: https://medium.com/@sanderspies/the-road-to-webassembly-gc-for-ocaml-bd44dc7f9a9d[24]. AsssemblyScript Garbage Collection: https://assemblyscript.bootcss.com/garbage-collection.html#runtime-interface[25]. Standardizing WASI: A system interface to run WebAssembly outside the web: https://hacks.mozilla.org/2019/03/standardizing-wasi-a-webassembly-system-interface/[26]. WebAssembly System Interface: https://github.com/WebAssembly/WASI/tree/59cbe140561db52fc505555e859de884e0ee7f00[27]. WASI proposals: https://github.com/WebAssembly/WASI/blob/59cbe140561db52fc505555e859de884e0ee7f00/Proposals.md[28]. WebAssembly’s post-MVP future: A cartoon skill tree: https://hacks.mozilla.org/2018/10/webassemblys-post-mvp-future/点击上方关注 · 我们下期再见点击左下方“阅读原文”,或扫描上方二维码,进入专栏阅读《走进 WebAssembly 的世界》完整版。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-15 18:14 , Processed in 0.724980 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表