Hello AssemblyScript

2,583 阅读14分钟

作者介绍

缘一,专有钉钉前端团队成员,负责专有钉钉 PC 客户端、端上应用、端上模块插件化的开发。

学习前的疑惑

文章源于对内的一场 AssemblyScript 入门分享,目标更多聚焦于团队成员的快速了解,细节之处如有错误,欢迎在评论区处指正。

笔者在学习前,完全没有接触过 AssemblyScript,对其了解只知道是用 JS 来写 WASM。因此带着工作上的疑问去了解了一下 AS,下面是笔者学习前的疑问:

  • WebAssembly 在前端技术中扮演的角色是什么?
  • 为什么 WebAssembly 作为贴近前端的技术,为啥不是无脑选贴近前端的语种 AssemblyScript?
  • 为什么官方还要推这么多语种?如 Rust/ C++,它们的应用方向是什么?

Web Assembly 简介

在学习 AssemblyScript,我们得先知道一下 WebAssembly 为何物? 2019/11,Mozilla, 英特尔,红帽和Fastly今天宣布启动字节码联盟(Bytecode Alliance),这是一个全新的开源组织。目标致力于 WebAssembly 和 WebAssembly System Interface(WASI)等标准创建一个安全、高效和模块化的新运行引擎(Runtime)和支持多语言的编译工具链,同时推广让尽可能多的平台和设备使用它们。目前四款主流浏览器包括IE、Firefox、Google Chrome、Safari 已实现对 WebAssembly 的支持。

WebAssembly 的许多特性在不同环境下可以带来各种价值,下面简单列举一二:

  1. 安全沙箱: WASM 模块运行在其私有的沙箱中。沙箱中的程序不能访问沙箱以外的地址空间。
  2. 跨端应用:WebAssembly 交付是面向虚拟机,只需要对应的 WASM Runtime 进行编译,不与特定架构的平台和环境绑定。
  3. 轻量化: WASM 规范的设计充分考虑了在浏览器上需要通过网络从服务器端下载并即时运行的需求,操作码的设计相当精简,可以非常快地完成加载并进入运行状态,创建一个运行实例只需要很少量的资源。
  4. 高性能:WebAssembly 的字节码设计充分考虑了即时编译的友好性,不仅可以达到很快的编译速度,还可以获得很高的运行速度,其 WAMR 的 AOT 模式运行性能几乎可以达到 Native 的性能。

AssemblyScript 介绍

AssemblyScript 则类似于一种 TypeScript WebAssembly 的编译器。与 TypeScript 相比,最大的区别是为 JavaScript 添加了类型这一概念。它在随后成为了一门十分受欢迎的语言,而即使对 TypeScript 不太熟悉,AssemblyScript 也可以容易地去上手, 毕竟它只使用了 TS 的有限的一部分子集。

正是由于其与 JavaScript 的相似性,AssemblyScript 允许 Web 开发者们轻松地将 WebAssembly 集成到站点中, 过程中并不需要另一门完全陌生的语言的参与。

image.png

AssemblyScript 简单实用

下面从官方示例来简单了解一下 AssemblyScript 的开发流程,首先写一段最最简单的加法函数,写法为 AssemblyScript 代码,它和 TypeScript 很像,唯一不一样的是 i32 这个 AssemblyScript 类型。它在此处代表了 32 位整型类型。

export function add(a: i32, b: i32): i32 {
  return a + b;
}

然后运行 tnpm run build 命令,AssemblyScript 代码会在 build 目录中生成了 WebAssembly 代码。现在我们在普通 js 代码中使用 WebAssembly 的加法函数,下面函数的功能是验证加法函数的测试用例,表达 1 + 2 = 3。

const assert = require("assert");
const myModule = require("./build"); // 引用 wasm 产物
assert.strictEqual(myModule.add(1, 2), 3); // 调用加法函数

AssemblyScript 高效的原因

我认为从标准库开始, 才是 AssemblyScript 真正的学习开始,正如 C++ 的 STL 标准库是如此的经典,AssemblyScript 作为竞争的语种,必然也会在此处发力,语法的学习相比之而言只是比较浅的一层。

由于 WebAssembly 的一大场景就是算法优化,如果语言本身没有提供强大的底层标准库能力来支持,这一方面势必会落后,这方面 AssemblyScript 的编译器源码采用了 js 来编写,对于前端同学来理解十分顺手 。

强大的标准库

下面我们简单看一下 AssemblyScript 的标准库目录,我们可以发现很多在 JS 中同样存在的类型,如 Array、Date、Error、Map、Math、String。几乎每个类型都会在文档中包含这么一句:is very similar to JavaScript's。但是和 JavaScript 中的同等的那些类型其实并不是一回事。

image.png

尽管非常相近,但是细看之下,又会发现这些基础类型的能力比 JS 更强,更精细,以数字类型来分析。

首先我们可以发现 AS 对基础类型的内存空间要求的更加精细,以基本的整型为例子,有 1 位、8位、16位、32 位、64 位、128 位等等。

image.png

每个类型背后所分配的内存空间大小也是不一样的,这种对内存空间大小的精细化体现在每个基础类型上。这样的细节可以帮助我们设计比 JS 更加高性能的代码,我们可以对自己写的程序做到很细粒度的把控。

image.png

譬如以这段非常简单的 hello 函数为例子,我们简单写一个 AssemblyScript 版本的 Hello 。需要注意的是,此处这里的 string 和 js 中的 String 类型是不等价的,这里的 string 代表了 AssemblyScript 中的 string 类型。

export function hello(name: string): string {
  return "Hello, " + name + "!";
}

然后使用 exportRuntime 的编译参数来暴露一些 WebAssembly 运行时方法。

npm run asbuild:optimized -- --exportRuntime

下面在普通的 js 文件中使用 WebAssembly 代码。在我们使用 hello 函数,会发现我们无法直接使用 hello("Tomas") 来直接调用函数,而是先使用 __newString 来构建一个 AssemblyScript 字符类型,然后才能当参数去传入。

同理,在获取返回值的时候,我们也是无法直接使用 hello 的返回值,必须得使用 __getString,来将从内存将 AssemblyScript 转为 JS 的字符串类型。

var { hello,
  __newString,
  __getString } = wasm.exports;

// allocate string in memory
var pti = __newString("Tomas");
 
var pto = hello(pti);
 
// retain string from memory
var str = __getString(pto);

因此 AssemblyScript 真的只是语法比较贴近于 TS,它的另一部分语言能力更接近于它的竞争语种 C++/Rust。

譬如我们可以在 AssemblyScript 的源码中找到它重新定义了操作符重载,可以重新实现不同类型的 >= 、==、 < 等实现,这个在 C++ 中有类似的能力,但是在 JS 中我们几乎无法重新实现操作符重载。

image.png

使用编译器

正如 TypeScript 使用 tscts 编译为 jsAssemblyScripts 使用 ascas 编译为 wasm。依然以最简单的加法函数为例:

export function add(a: i32, b: i32): i32 {
  return a + b;
}

我们同时运行 npm run asbuild:untouched 来构建 非优化态 和运行 npm run asbuild:optimized 来构建优化态

  "asbuild": "npm run asbuild:untouched && npm run asbuild:optimized -- --exportRuntime"

很快我们就在构建目录看到下面生成的产物,.map 文件是帮助我们 debug 的 sourcemap 文件,.wasm 是我们可以直接读取运行的 wasm 二进制产物,.watwasm 二进制所对应的文本表示,为了方便我们理解和阅读,毕竟二进制文件是无法直接阅读的,有了 .wat 我们就可以更深入自己所写的代码背后的运行效率:

image.png

wat 文件来分析性能

下面我们来简单读一下 add 产生的 wat 的可阅读编译中间态。

module 表示一棵根节点为“模块”的树,使用 type 声明了 add 函数的参数类型和返回类型,然后在非编译优化的状态下,会全局声明 data_endstack_pointer 栈指针地址、heap_base 堆起始地址。

接着使用 memory 声明了一块内存,使用 table 来存储存储函数的引用。elem 来初始化表格区域。然后导出 add 函数。接着就是声明函数的部分:

虽然浏览器把 wasm 编译为某种更高效的东西,但是,wasm 的执行是以栈式机器定义的。也就是说,其基本理念是每种类型的指令都是在栈上执行数值的入栈出栈操作。

local.get 读取函数的参数变量值压入到栈上,然后 i32.add 从栈上取出两个 i32 类型的参数值计算它们的和,最后把结果压入栈上。

(module
 (type $i32_i32_=>_i32 (func (param i32 i32) (result i32)))
 (global $~lib/memory/__data_end i32 (i32.const 8))
 (global $~lib/memory/__stack_pointer (mut i32) (i32.const 16392))
 (global $~lib/memory/__heap_base i32 (i32.const 16392))
 (memory $0 0)
 (table $0 1 funcref)
 (elem $0 (i32.const 1))
 (export "add" (func $assembly/index/add))
 (export "memory" (memory $0))
 (func $assembly/index/add (param $0 i32) (param $1 i32) (result i32)
  local.get $0
  local.get $1
  i32.add
 )
)

这一部分官方有更加详细的关于 wat 文件如何阅读学习的文档,参考:developer.mozilla.org/zh-CN/docs/…

编译优化

然后我们接着尝试使用 asc 的编译优化选项来优化下面一段代码,我们将 add 的实现加入了 c、d、e 三个冗余实现。然后看下编译器的操作:

export function add(a: i32, b: i32): i32 {
  const c = a + b;
  const d = c - b;
  const e = d + b;
  return e;
}

可以看到非优化的版本,和之前非常像,只是过程中我们因为 c、d、e 而多压栈了几次临时变量,多执行了几遍 local.get、 local.set 的操作,虽然冗余,但是原原本本还原了我们自己写的代码。

(module
 (type $i32_i32_=>_i32 (func (param i32 i32) (result i32)))
 (global $~lib/memory/__data_end i32 (i32.const 8))
 (global $~lib/memory/__stack_pointer (mut i32) (i32.const 16392))
 (global $~lib/memory/__heap_base i32 (i32.const 16392))
 (memory $0 0)
 (table $0 1 funcref)
 (elem $0 (i32.const 1))
 (export "add" (func $assembly/index/add))
 (export "memory" (memory $0))
 (func $assembly/index/add (param $0 i32) (param $1 i32) (result i32)
  (local $2 i32)
  (local $3 i32)
  (local $4 i32)
  local.get $0
  local.get $1
  i32.add
  local.set $2
  local.get $2
  local.get $1
  i32.sub
  local.set $3
  local.get $3
  local.get $1
  i32.add
  local.set $4
  local.get $4
 )
)

接着我们再来看看优化后的版本,我们代码明显减少了很多行,特别是减少了 c、d、e 的 local.set 操作,我们不再每次存储 + 号表达式运算的结果,然后放置在 3 个临时变量上,而是直接使用运算后的值参与下一次运算。

这里只是简单介绍一个优化的案例,背后编译器还有很多的优化,可以从源码中了解。

(module
 (type $i32_i32_=>_i32 (func (param i32 i32) (result i32)))
 (memory $0 0)
 (export "add" (func $assembly/index/add))
 (export "memory" (memory $0))
 (func $assembly/index/add (param $0 i32) (param $1 i32) (result i32)
  local.get $1
  local.get $0
  local.get $1
  i32.add
  local.get $1
  i32.sub
  i32.add
 )
)

更细粒度地操纵内存

与使用线性内存的其他语言类似,直到新的 GC 方案可用前,AssemblyScript 中的所有数据目前都以特定偏移量存储在线性内存中,以便程序的其他部分可以读取和修改它。

静态内存

编译器在编译程序时遇到的字符串和数组(常量值)会以静态内存来分配。与其他语言不同,AssemblyScript 自身没有堆栈的概念,而是完全依赖 WebAssembly 的执行堆栈。

静态内存开始于保留内存地址之后,结束于 _heap_base 堆起始地址。而动态内存地址始于堆起始地址,即静态内存结束之处。

动态内存

动态内存(通常称为堆)在运行时由垃圾收集器管理。当程序请求新对象的空间时,运行时的内存管理器会保留一个合适的区域,并将指向该区域的指针返回给程序。一旦对象不再需要并且无法访问,垃圾收集器就会将对象的内存返回给内存管理器以供重用。

譬如下面标准库中的 heap 部分,我们可以使用堆的 api 来动态申请内存,就像 C 一样,同时我们也可以自由的 free 我们之前申请的内存块。

image.png

Wasm 使用与其他程序隔离的特定内存偏移量中存储的线性内存。

AssemblyScript 中,在编译时已知的静态数据存储在静态内存中,然后再运行时在堆上管理的动态内存。程序通过指针访问内存块。动态内存由运行时的垃圾收集器跟踪,并在程序不再需要时重用。

上面的 heap api 主要是在 AssemblyScript 中使用,对外的普通 js 来,我们还可以使用 AssemblyScript 对外提供的方法,如 __newString 分配 AS 字符串的内存,当然也可以使用 __new 来分配,这部分方法有 AssemblyScript Loader 的部分提供,下面会介绍:

var { hello, memory,
  __new, __pin, __unpin } = wasm.exports;
 
var input = "Tomas";
var length = input.length;
 
// 申请内存 (usize, String (id=1))
var pt = __new(length << 1, 1);
 
// 从内存中加载 bytes
var ibytes = new Uint16Array(memory.buffer);
for (let i = 0, p = pt >>> 1; i < length; ++i)
  ibytes[p + i] = input.charCodeAt(i);
 
// 固定 object
var pti = __pin(pt);
 
// 调用 wasm
var pto = __pin(hello(pti));
 
// 检索字符串长度
var SIZE_OFFSET = -4;
var olength = new Uint32Array(memory.buffer)[pto + SIZE_OFFSET >>> 2];
 
// 从内存中加载字符串
var obytes = new Uint8Array(memory.buffer, pto, olength);
var str = new TextDecoder('utf8').decode(obytes);
 
// 解除固定 objects for GC
__unpin(pti);
__unpin(pto);
 
console.log(str);

使用大小固定的数据结构去存储的,这样就能最小化构建和取值开销,也能尽可能降低内存空间的占用。

GC(垃圾回收)

这部分机制跟随版本而变动,包括未来还会变化,这里只是简单介绍,详细可以在官网处了解。

AssemblyScript 0.17:

reference-counting based garbage collector 根据引用计数,目前已废弃

AssemblyScript 0.18:

下面三种内存管理方式都可以在编译阶段,选择不同的编译参数来指定:

Incremental runtime:将 ITCMS 垃圾回收机制与 TLSF 内存管理机制相结合。

其中 ITCMS 是 java的内存回收机制,根据黑白灰的状态来决定内存回收与否,可异步,暂停时间短。

而 TLSF: 是通过管理一组链表来分配内存。

在使用增量运行时,固定内存块(指 pin )尤其重要,因为每当 WebAssembly 中发生分配内存的行为时,它也可能会回收内存块。如果不固定内存块就访问很可能会随机导致“使用释放后内存块”之类的错误,而且很难调试。

var aPtr = exports.__pin(exports.__newString("hello")); // next line may collect
var bPtr = exports.__newString("world"); // allocates
var cPtr = exports.__pin(exports.stringConcat(aPtr, bPtr)); // puts args on stack
exports.__unpin(aPtr);
// ... do something with cPtr ...
exports.__unpin(cPtr);

Minimal runtime:

它是基于 TLSF 之上构建的更简单的双色标记和扫描(TCMS)垃圾收集器,它通常是一种折衷的垃圾回收方案。

缺点是暂停时间长。

与 Incremental runtime 不同,Minimal runtime 不会在代码中交错运行(指不断的 pin),但可以由外部来调用去执行一次完整的垃圾收集循环(指 collect 方法)。但是不要过多调用 __collect() 以避免标记过多。如果使用得当,Minimal runtime 通常比 Incremental runtime 具有更好的吞吐量。

Incremental runtime 和 Minimal runtime 之间的一个重要用法差异在于何时需要固定内存块。使用 Minimal runtime 我们就可以手动控制 GC 的运行时间,而 Incremental runtime 可能会在 WebAssembly 代码中发生分配内存时的同时释放内存的现象。例如,以下代码段在 Minimal runtime 运行良好,但在 Incremental runtime 可能会随机失败:

var cPtr = exports.stringConcat(
  exports.__newString("hello"),
  exports.__newString("world")
);
// ... do something with cPtr ...
exports.__collect();
// don't use cPtr anymore

Stub runtime

只分配不释放内存。

function compute() {
  exports.doSomeHeavyWorkProducingGarbage()
}
compute()

未来

WebAssembly GC 这项提议仍在进行中,未来会全面切换到这个方案,所以上面的方案目前仍然是临时方案。

github.com/WebAssembly…

AssemblyScript 的限制

  • 无法访问 DOM API:由于其应用场景,WebAssembly 的设计更多偏向于高效、高性能场景,而不是全面取代 JS,因此在语言层上屏蔽了类似 DOM API 等访问能力。也正是由于阉割了 DOM API,它不具备破坏渲染界面的能力,因此 WebAssembly 也会被当做沙箱场景考虑。
  • 无法使用动态特性:其产物为编译后的 Assembly 二进制码,为了高性能去除了动态的场景特性。
  • 重新定义类型和标准库:虽然和 JS 在语法上很像,但是实际使用、标准库、以及实现和 JS 的内核相差巨大。

Assembly Loader

最后的最后,我们来介绍一下,如何在普通 js 中使用 wasm 的代码,官方推出了 AssemblyLoader 来辅助我们加载 wasm 文件。

它本质是一个小型模块加载器,可以在不牺牲效率的情况下尽可能方便地使用 AssemblyScript 模块。它的实现使用了 WebAssembly API 的相关代码,同时还提供了分配和读取字符串、数组和类对象类型的内存等操作。我们之前介绍的 __newString, __getString 都是在 @assemblyscript/loader 中所实现的。

const fs = require("fs");
const loader = require("@assemblyscript/loader/umd");
const imports = { 
  "assembly/index": {
    declaredImportedFunction: function() { }
  }
};
const wasmModule = loader.instantiateSync(fs.readFileSync(__dirname + "/build/optimized.wasm"), imports);
module.exports = wasmModule.exports;

来看一下最核心的 loader.instantiateSync 加载 wasm 模块的实现,其实是使用 WebAssembly.Instance 来进行了一次封装:

function instantiateSync(source, imports = {}) {
    const module = isModule(source) ? source : new WebAssembly.Module(source);
    const extended = preInstantiate(imports);
    const instance = new WebAssembly.Instance(module, imports);
    const exports = postInstantiate(extended, instance);
    return {
      module,
      instance,
      exports
    };
  }

WebAssembly 语言选型

在上面大致有个基础的理解之后,我们现在再来重新回顾一下 WebAssembly 的语言技术选型:

Rust:

  • 优势:

    • Rust -> WebAssembly 的成熟度是比 AssemblyScript 高得多
    • 无 GC 中断、零开销抽象,这能够给内存占用、运行性能都带来质变级的提升。
  • 劣势:

    • Rust 语言过于底层、学习成本高、编程范式对开发者的约束性极强等缺点
    • 可维护性相对没那么高。部分公司对 Rust 的关注度不高

应用面:更适合性能提升的场景。算法优化,图片处理,文件处理等纯优化场景。

AssemblyScript:

  • 优势:

    • AS 运行时具有 GC,且支持绝大多数 OOP 写法,相比偏底层的 Native 语言在开发效率上有质的提升。
    • 最接近前端语种,在前端生态上最容易被接受。
  • 劣势:

    • AS 在语言上并不够成熟,没有 Virtual Overload 支持、有限的闭包支持、异常处理比较简陋。
    • 性能和灵活度不如 Rust/ C++
    • AssemblyScript 生态中的工具库还不完善

应用面:适合更偏前端业务开发场景。如沙盒场景、插件场景、小程序执行线程场景。

WebAssembly 实践案例

Figma

Figma 是一款简单好用的在线协同设计软件,支持在线多人协作。它的界面使用了 WebAssembly 支持了布局算法和文件解析,运行速度飙升了 3 倍。实际体验下来效果与 sketch 差不多,有些地方反而更加流畅。WebAssembly 在 Figma 作为了核心竞争力而存在,算是比较好的落地场景。

同时 Figma 也研究过 WebAssembly 是否可作为插件沙箱的场景。文章非常不错:www.figma.com/blog/how-we…

image.png

Bilibili Web 投稿系统

bilibili 的投稿系统也使用了 WebAssembly + AI 来通过读取本地的视频来自动挑选封面,正常的实现方式是上传到服务器之后在获取封面。

image.png

WebAssembly 显著的提升了性能,用户体验得到了很大的提升。WebAssembly 负责读取本地视频,生成图片,服务端兜底,万无一失的优化。

区块链上的 WebAseembly

WebAssembly 已经成为当前 EVM 虚拟机的有力替代选项。它的主机独立性、安全沙箱和整体简洁性等特性使其成为智能合约的理想运行时。此外,它还允许使用多种现代编程语言(Rust、C++、JavaScript 等)开发合约。以太坊团队一直在试用一个基于 WebAssembly 的合约引擎 eWASM,并计划在 2021 年的某个时候正式发布它。

应用面总结

  • 文件处理相关:大文件上传、切片。
  • 图片处理:如封面生成,二维码生成。
  • AI 方向
  • 区块链方向
  • 沙箱方向
  • 跨端方向:如 WebAssembly 可以作为 JS 执行环节,作为小程序的逻辑线程。