来源:https://mnt.io/2018/08/22/from-rust-to-beyond-the-webassembly-galaxy/
这篇博客文章是这一系列解释如何将Rust发射到地球以外的许多星系的文章的一部分:
前奏
WebAssembly 星系(当前这一集),
ASM.js星系
c 星系
PHP星系,以及
NodeJS 星系
我们的Rust解析器将探索的第一个星系是WebAssembly (WASM)星系。本文将解释什么是WebAssembly,如何将我们的解析器编译成WebAssembly,以及如何在浏览器中的Javascript或者NodeJS一起使用WebAssembly二进制文件。
如果您已经了解WebAssembly,可以跳过这一部分。
WebAssembly的定义如下:
WebAssembly(缩写:Wasm)是一种基于堆栈虚拟机的二进制指令格式。Wasm被设计为是可移植的目标格式,可将高级语言(如C/ C++ /Rust)编译为Wasm,使客户端和服务器端应用程序能部署在web上。
我还需要说更多吗?也许是的…
WebAssembly是一种新的可移植二进制格式。像C、C++或Rust这样的语言已经能够编译到这个目标格式。它是ASM.js的精神的继承者。我所说的精神继承者,是指都是相同的一群试图扩展Web平台和使Web变得更快的人,他们同时使用这两种技术,他们也有一些共同的设计理念,但现在这并不重要。
在WebAssembly之前,程序必须编译成Javascript才能在Web平台上运行。这样的输出文件大部分时间都很大。因为Web是基于网络的文件必须下载,这是很耗时的。WebAssembly被设计成一种大小和加载时高效的二进制格式。
从很多方面来看,WebAssembly也比Javascript更快。尽管工程师们在Javascript虚拟机中进行了各种疯狂的优化,但Javascript是一种弱动态类型语言,需要解释运行。WebAssembly旨在利用通用的硬件功能以原始速度执行。WebAssembly的加载速度也比Javascript快,因为解析和编译是在二进制文件从网络传输时进行的。因此,一旦完成了二进制文件下载,它就可以运行了:无需在运行程序之前等待解析器和编译器。
当前我们就已经能够编写一个Rust程序,并将其编译在Web平台上运行,我们的博客系列就是一个完美的例子,为什么要这么做呢? 因为WebAssembly已经在所有主流浏览器实现,而且因为它是为Web而设计的:在Web平台上(像浏览器一样)生存和运行。但是,它的可移植性、安全性和沙箱内存设计使其成为在Web平台之外运行的理想选择(请参阅无服务器的WASM框架或为WASM构建的应用程序容器)。
我认为需要强调的时候,WebAssembly并不是用来替代Javascript的。它只是另一种技术,它解决了我们今天可能遇到的许多问题,比如加载时间、安全性或速度。
##Rust?WASM
Rust WASM团队致力于推动通过一组工具集来将Rust编译到WebAssembly。有一本书解释如何用Rust编写WebAssembly程序。
对于Gutenberg Rust解析器,我没有使用像wasm-bindgen这样的工具(这是一个纯粹的gem),因为在几个月前开始这个项目的时候我遇到了一些限制。请注意,其中一些已经被解决了!无论如何,我们将手工完成大部分工作,我认为这是理解这背后工作原理的一个很好的方法。当您熟悉了和WebAssembly交互时,wasm-bindgen是一个非常好的工具,您可以很容易地获得它,因为它抽象了所有交互,让您更能关注代码逻辑。
我想要提醒读者的是Gutenberg的Rust解析器开放了一个AST以及一个root函数(语法的根),相应的定义如下
pub enum Node<'a> {
Block {
name: (Input<'a>, Input<'a>),
attributes: Option<Input<'a>>,
children: Vec<Node<'a>>
},
Phrase(Input<'a>)
}
和
pub fn root(
input: Input
) -> Result<(Input, Vec<ast::Node>), nom::Err<Input>>;
知道了这个我们就可以开始了!
下面是我们的通用设计或者说流程:
Javascript将博客内容解析为WebAssembly模块的内存
传入这个内存指针以及博客长度来调用root函数
Rust从内存中读到博客内容,运行Gutenberg解析器,编译AST的结果到一个字节序列,然后将这个字节序列的指针返回给Javascript
Javascript从这个指针读取内存,解码这一个序列为Javascript对象得到具有友好API的AST
为什么是字节序列?因为WebAssembly只支持整数和浮点数,不支持字符串也不支持数组,也因为Rust解析器恰好也需要字节切片,正好方便使用。
我们使用边界层来表示这部分负责读写WebAssembly内存的代码,它也负责暴露友好的API。
现在我们把焦点放到Rust代码上,它包含四个函数:
alloc
用来分配内存(导出函数),
dealloc
用来释放内存(导出函数),
root
运行解析器(导出函数),
into_bytes
用来转换AST到字节序列
所有的代码都在这里了,大约150行。我们来解读一下。
我们从内存分配器开始。我选择了wee_alloc
来作为内存分配器。它是专为WebAssembly设计的,小巧(1K以内)而高效。
下面的代码描述了内存分配器的构建以及我们代码“前奏”(开启一些编译器功能,比如alloc,声明外部crates,一些别名,还声明了必要的函数比panic,oom等等)。可以认为他们是样板:
#![no_std]
#![feature(
alloc,
alloc_error_handler,
core_intrinsics,
lang_items
)]
extern crate gutenberg_post_parser;
extern crate wee_alloc;
#[macro_use] extern crate alloc;
use gutenberg_post_parser::ast::Node;
use alloc::vec::Vec;
use core::{mem, slice};
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
unsafe { core::intrinsics::abort(); }
}
#[alloc_error_handler]
fn oom(_: core::alloc::Layout) -> ! {
unsafe { core::intrinsics::abort(); }
}
// 这是 `std::ffi::c_void`的定义, 但是在我们这个下面里面 WASM 的运行不需要 std.
#[repr(u8)]
#[allow(non_camel_case_types)]
pub enum c_void {
#[doc(hidden)]
__variant1,
#[doc(hidden)]
__variant2
}
Rust内存就是WebAssembly内存。Rust将会自己负责分配和释放内存,但是Javascipt需要来分配和释放WebAssembly的内存来通信或者说交换数据。因此我们需要导出内存分配和释放的函数。
再一次,这个基本就是样板。alloc函数创建一个空的指定长度的数组(因为它是一个顺序内存段)并且返回这个空数组的指针。
#[no_mangle]
pub extern "C" fn alloc(capacity: usize) -> *mut c_void {
let mut buffer = Vec::with_capacity(capacity);
let pointer = buffer.as_mut_ptr();
mem::forget(buffer);
pointer as *mut c_void
}
注意#[no_mangle]
特性指示Rust编译器不去混淆函数名字,也就是不去重命名符号。用extern "C"
用来导出WebAssembly里面的函数,因此从WebAssembly二进制外面看起来他们就是公开的。
这个代码其实很直观,和我们先前说明的一样: Vec
是分配的一个指定长度的数组,返回值是指向这个数组的指针。重要的部分是mem::forget(buffer)
,这个是必须的,这样Rust在这个数组离开作用域的时候不会去释放它。事实上Rust是强制RAII的,意味着一个对象一段离开作用域,它的析构函数会被调用并且它拥有的资源也会被释放。这种行为是用来防御资源泄露bug的,这也是为什么我们可以不用手动释放内存也不用担心Rust内存泄露(看看RAII的例子)。在这个情况下,我们希望分配内存并且保持甚至到函数结束执行,因此需要调用mem::forget
.
我们来看看dealloc
函数。目标是根据一个指针和其容量长度来重建数组,并且让Rust释放它:
#[no_mangle]
pub extern "C" fn dealloc(pointer: *mut c_void, capacity: usize) {
unsafe {
let _ = Vec::from_raw_parts(pointer, 0, capacity);
}
}
这里Vec::from_raw_parts
函数被标记为unsafe
,因为我们要用unsafe
块来隔离它,让它被Rust认为是安全的。
变量_
包含我们要释放的数据,并且它立即就离开了作用域,所有Rust会自动的释放它。
现在开始绑定的核心部分!root
函数基于指针和长度读取博客内容来,然后解析。如果结果正确它将序列化AST到一个字节序列,也就是让它变得扁平,否则返回空的字节序列。
解析器的流程:左边的input将会被解析为AST,然后这个AST会被序列化为右边扁平的字节序列。
#[no_mangle]
pub extern "C" fn root(pointer: *mut u8, length: usize) -> *mut u8 {
let input = unsafe { slice::from_raw_parts(pointer, length) };
let mut output = vec![];
if let Ok((_remaining, nodes)) = gutenberg_post_parser::root(input) {
// 编译 AST (nodes) 到字节序列.
}
let pointer = output.as_mut_ptr();
mem::forget(output);
pointer
}
input
变量包含了博客文章。它是根据一个指针和其长度得到的内存。output变量是会被作为返回值的字节序列。gutenberg_post_parser::root(input)
开始运行解析器。如果解析成那么节点会被编译为字节序列(现在先忽略不讲)。然后我们可以得到指向这个字节序列的指针,Rust编译器被指定为不去释放它,最后这个指针被返回。再一次想说这个逻辑其实很直观。
现在我们聚焦在AST到字节序列(u8
)的编译上。因为AST里面的数据已经是字节了,所有这个处理过程变得相对简单。我们的目标是扁平化这个AST
开头四个字节表示第一层的节点数量(4*u8
即u32
)
下面,如果这个节点是一个Block(模块):
第一个字节是节点类型:1u8
表示block
第二个字节是模块名字的长度
第三到第六个字节是所有属性的长度
第七个字节是字节点数量
下一个字节是模块名字
再下一个是具体的一些属性(如果没有表示为:&b"null"[..]
),
在下面是字节点的字节序列
如果节点是一个短语:
第一个字节是节点类型:2u8
表示phrase
(短语)
第二到第十五个字节表示短语的长度。
后面的字节是phrase
本身。
补充一些root
函数的代码:
if let Ok((_remaining, nodes)) = gutenberg_post_parser::root(input) {
let nodes_length = u32_to_u8s(nodes.len() as u32);
output.push(nodes_length.0);
output.push(nodes_length.1);
output.push(nodes_length.2);
output.push(nodes_length.3);
for node in nodes {
into_bytes(&node, &mut output);
}
}
下面是into_bytes函数:
fn into_bytes<'a>(node: &Node<'a>, output: &mut Vec<u8>) {
match *node {
Node::Block { name, attributes, ref children } => {
let node_type = 1u8;
let name_length = name.0.len() + name.1.len() + 1;
let attributes_length = match attributes {
Some(attributes) => attributes.len(),
None => 4
};
let attributes_length_as_u8s = u32_to_u8s(attributes_length as u32);
let number_of_children = children.len();
output.push(node_type);
output.push(name_length as u8);
output.push(attributes_length_as_u8s.0);
output.push(attributes_length_as_u8s.1);
output.push(attributes_length_as_u8s.2);
output.push(attributes_length_as_u8s.3);
output.push(number_of_children as u8);
output.extend(name.0);
output.push(b'/');
output.extend(name.1);
if let Some(attributes) = attributes {
output.extend(attributes);
} else {
output.extend(&b"null"[..]);
}
for child in children {
into_bytes(&child, output);
}
},
Node::Phrase(phrase) => {
let node_type = 2u8;
let phrase_length = phrase.len();
output.push(node_type);
let phrase_length_as_u8s = u32_to_u8s(phrase_length as u32);
output.push(phrase_length_as_u8s.0);
output.push(phrase_length_as_u8s.1);
output.push(phrase_length_as_u8s.2);
output.push(phrase_length_as_u8s.3);
output.extend(phrase);
}
}
}
在我看来比较有趣的是这个代码读起来就像上面无序列表很接近。
最让人好奇的当属下面这个函数u32_to_u8s:
fn u32_to_u8s(x: u32) -> (u8, u8, u8, u8) {
(
((x >> 24) & 0xff) as u8,
((x >> 16) & 0xff) as u8,
((x >> 8) & 0xff) as u8,
( x & 0xff) as u8
)
}
好了,alloc
, dealloc
,root
以及into_bytes
四个函数全部完成。
要得到WebAssembly二进制,这个工程需要编译到wasm32-unknown-unknown
这个目标。目前我们需要nightly
工具链来编译我们的项目,当然这在后面可能会变化,因此你要确保用rustup update nightly
命令安装了最新的nightly
版本的rustc和co。我们来运行cargo
$ RUSTFLAGS='-g' cargo +nightly build --target wasm32-unknown-unknown --release
这个WebAssembly二进制有22kb。我们的目标是减小这个尺寸,因此我们需要下面的工具:
wasm-gc
来做垃圾收集,包括没有使用到的imports
,内部函数,类型等等。
wasm-snip
用来标记不可达函数,这个工具对那些链接器没办法删除的未使用代码很有效。
wasm-opt
,是Binaryen项目的一部分,用来优化二进制,
gzip
和brotil
用来压缩二进制。
简单来说,我们就是要做下面的事情
$ # 垃圾收集未使用数据.
$ wasm-gc gutenberg_post_parser.wasm
$ # 标记不可达并移除.
$ wasm-snip --snip-rust-fmt-code --snip-rust-panicking-code gutenberg_post_parser.wasm -o gutenberg_post_parser_snipped.wasm
$ mv gutenberg_post_parser_snipped.wasm gutenberg_post_parser.wasm
$ # 再次垃圾收集未使用数据.
$ wasm-gc gutenberg_post_parser.wasm
$ # 优化二进制大小.
$ wasm-opt -Oz -o gutenberg_post_parser_opt.wasm gutenberg_post_parser.wasm
$ mv gutenberg_post_parser_opt.wasm gutenberg_post_parser.wasm
$ # 压缩.
$ gzip --best --stdout gutenberg_post_parser.wasm > gutenberg_post_parser.wasm.gz
$ brotli --best --stdout --lgwin=24 gutenberg_post_parser.wasm > gutenberg_post_parser.wasm.br
我们最终得到下面的不同大小的文件:
.wasm: 16kb,
.wasm.gz: 7.3kb,
.wasm.br: 6.2kb.
简洁!Brotil已经被大多数浏览器实现,因此如果客户端声称接受Accept-Encoding: br
,服务器就可以返回wasm.br
文件
让你感受一些6.2kb可以表达什么,下面的图片就是6.2kb大小:
WebAssembly二进制马上就可以运行了!
这部分,我们假设Javascript是运行在浏览器里,因此我们需要做下面的流程:
加载和实例化WebAssembly二进制,
写入博客内容到WebAssembly模块内存,
调用解析器的root函数,
读取WebAssembly模块的内存来加载扁平的AST(字节序列)并解码来得到Javascript AST
(用我们自己的对象)。
所有的代码都在这里,大约150行。我不会去解释所有的代码,因为有些代码的目的是为暴露给用户更友好的API。我将更专注于解释主要部分。
WebAssembly API暴露了很多的方法来加载WebAssembly二进制。最理想的一种应该是使用WebAssembly.instanciateStreaming
函数,它会一边下载二进制同时进行编译,没有任何阻塞。这个API依赖Fetch API
。你可能会猜到的是:它是异步的(返回一个promise
)。WebAssembly本身不是异步的,除非你用线程,但是实例化这一步却是异步的。当然也可以不这么做,只是会很奇怪,而且Chrome有一个4kb二进制大小的强限制,这将会使你很快就会放弃其它的尝试。
为了能够流式加载WebAssembly二进制,服务器也必须要发送Content-Type
头为application/wasm MIME
类型。
让我们来实例化我们的WebAssembly
const url = '/gutenberg_post_parser.wasm';
const wasm =
WebAssembly.
instantiateStreaming(fetch(url), {}).
then(object => object.instance).
then(instance => { /* step 2 */ });
WebAssembly已经被实例化好了,我们可以开始下一步了。在运行解析器之前,最后在做点优化打磨
记住我们要在WebAssembly二进制暴露的3个函数: alloc
, dealloc
和 root
。他们可以在导出属性里面被找到,还有memory
也在这里面. 写出来就是这样:
then(instance => {
const Module = {
alloc: instance.exports.alloc,
dealloc: instance.exports.dealloc,
root: instance.exports.root,
memory: instance.exports.memory
};
runParser(Module, '<!-- wp:foo /-->xyz');
});
很好,所有准备工作都已经完成,可以开始些runParser
函数了!
提醒一下,这个函数需要做下面的事情:把输入(博客内容)写入到WebAssembly模块的内存(Module.memory
),调用root
函数(Module.root
),并且从WebAssembly模块的内存读取返回结果。
function runParser(Module, raw_input) {
const input = new TextEncoder().encode(raw_input);
const input_pointer = writeBuffer(Module, input);
const output_pointer = Module.root(input_pointer, input.length);
const result = readNodes(Module, output_pointer);
Module.dealloc(input_pointer, input.length);
return result;
}
具体来讲:
raw_input
通过TextEncoderAPI
被编码成了字节序列,放到了input
中。
然后input
通过writeBuffer
写到了WebAssembly内存,返回对应的指针,
然后root函数被调用,传入input
和长度,返回的指针存到output
然后解码output
最后,input
被释放。解析器的输出output
只有在readNodes
函数里才会被释放,因为在当前这一步它的长度还是未知的。
很好!我们现在有两个函数需要实现:writeBuffer
和readNodes
。
我们重第一个开始,writeBuffer
:
function writeBuffer(Module, buffer) {
const buffer_length = buffer.length;
const pointer = Module.alloc(buffer_length);
const memory = new Uint8Array(Module.memory.buffer);
for (let i = 0; i < buffer_length; ++i) {
memory[pointer + i] = buffer[i];
}
return pointer;
}
解读:
buffer_length
存入buffer
的长度。
内存中开辟一块空间来存buffer
,
然后我们实例化一个unit8
类型的buffer
视图,也就是说我们把这个buffer
看作是一个u8
的序列,这个就是Rust想要的,
最后这个buffer
被循环的复制到内存中,非常普通,然后返回指针。
需要注意的是,不像在C语言里面的的字符串我们需要在结尾加NULL, 这里只需要原始数据(在Rust里面我们只需要用slice::from_raw_parts读就可以了,因为slice是很简单的结构)
output
在这一步,输入input
已经写进了内存,root
函数也得到了调用,也就是说解析器已经运行了。它返回了一个指向输出结果output
的指针,我们现在要做的就是读取并解码它。
记住,前面4个字节编码的是我们要读取的节点数量。开始吧!
function readNodes(Module, start_pointer) {
const buffer = new Uint8Array(Module.memory.buffer.slice(start_pointer));
const number_of_nodes = u8s_to_u32(buffer[0], buffer[1], buffer[2], buffer[3]);
if (0 >= number_of_nodes) {
return null;
}
const nodes = [];
let offset = 4;
let end_offset;
for (let i = 0; i < number_of_nodes; ++i) {
const last_offset = readNode(buffer, offset, nodes);
offset = end_offset = last_offset;
}
Module.dealloc(start_pointer, start_pointer + end_offset);
return nodes;
}
解析:
实例化一个内存的uint8
视图,更准确的是:一个从start_pointer
开始的内存切片
先读取节点数量,然后读取所有节点,
最后,解析器的输出output
被释放。
这里记录一些u8s_to_u32
函数,完全就是和u32_to_u8s
相反的功能:
function u8s_to_u32(o, p, q, r) {
return (o << 24) | (p << 16) | (q << 8) | r;
}
下面我贴出readNode
函数,但是我不会做过多解释。这仅是对解析器输出的解码部分。
function readNode(buffer, offset, nodes) {
const node_type = buffer[offset];
// Block.
if (1 === node_type) {
const name_length = buffer[offset + 1];
const attributes_length = u8s_to_u32(buffer[offset + 2], buffer[offset + 3], buffer[offset + 4], buffer[offset + 5]);
const number_of_children = buffer[offset + 6];
let payload_offset = offset + 7;
let next_payload_offset = payload_offset + name_length;
const name = new TextDecoder().decode(buffer.slice(payload_offset, next_payload_offset));
payload_offset = next_payload_offset;
next_payload_offset += attributes_length;
const attributes = JSON.parse(new TextDecoder().decode(buffer.slice(payload_offset, next_payload_offset)));
payload_offset = next_payload_offset;
let end_offset = payload_offset;
const children = [];
for (let i = 0; i < number_of_children; ++i) {
const last_offset = readNode(buffer, payload_offset, children);
payload_offset = end_offset = last_offset;
}
nodes.push(new Block(name, attributes, children));
return end_offset;
}
// Phrase.
else if (2 === node_type) {
const phrase_length = u8s_to_u32(buffer[offset + 1], buffer[offset + 2], buffer[offset + 3], buffer[offset + 4]);
const phrase_offset = offset + 5;
const phrase = new TextDecoder().decode(buffer.slice(phrase_offset, phrase_offset + phrase_length));
nodes.push(new Phrase(phrase));
return phrase_offset + phrase_length;
} else {
console.error('unknown node type', node_type);
}
}
注意这个代码非常的简单,很容易的被Javascript虚拟机优化。很重要的是这不是最原始的代码,原始的代码比这个优化得更多,但是还是很相似。
好了!我们已经成功的从解析器读取结果并解码!我们只需要实现Block
和Phrase
类:
class Block {
constructor(name, attributes, children) {
this.name = name;
this.attributes = attributes;
this.children = children;
}
}
class Phrase {
constructor(phrase) {
this.phrase = phrase;
}
}
最终的输出将是一个这种类型的对象数组。简单吧!
Javascript和NodeJS版本有下面的一些差异:
在NodeJS中没有Fetch API
,因此WebAssembly二进制文件只能通过buffer
直接实例化,像这样:WebAssembly.instantiate(fs.readFileSync(url), {})
,
TextEncoder
和TextDecoder
也没有在全局对象里面,他们在util.TextEncoder
和 util.TextDecoder
里面.
为了能在这两个环境共享代码, 可以在一个.mjs文件中实现一个边界层(我们写的Javascript代码),也就是ECMAScript模块。我们就能够像下面这样写:import { Gutenberg_Post_Parser } from './gutenberg_post_parser.mjs'
,如果我们之前所有的代码是一个类。在浏览器端,脚本的加载方式是:,在NodeJS端,node需要带参数
--experimental-modules
运行。为了有个更全面的认识,我可以推荐你这个2018年JSConf的演讲:Please wait… loading: a tale of two loaders by Myles Borins
所有的代码在这里。
#结论
我们已经看到了如何容Rust写一个真正的解析器的细节,如何编译成WebAssembly二进制, 以及如何在Javaacript和NodeJS里面使用
这个解析器可以和普通的Javascript代码一起在浏览器端使用,也可以和NodeJS中以CLI的方式运行,也可以在任何支持NodeJS的平台。
加上产生WebAssembly的Rust代码和原生Javascript代码一共只有313行。相比于完全用Javascript来写,这个小小的代码集合更容易审查和维护。
另一个有点争议的点是安全和性能。Rust是内存安全的,我们都知道。它也有很高的性能,但是WebAssembly却不一定有这些特性,对吧?下面的表格展示了Gutenberg项目纯Javascript解析器(基于PEG.js实现)和本文的项目:Rust编译成WebAssembly二进制方案的一个基准测试对比结果:
文件 | Javascript 解析器(毫秒) | Rust 实现的WebAssembly 解析器 (毫秒) | 加速 |
---|---|---|---|
demo-post.html | 13.167 | 0.252 | × 52 |
shortcode-shortcomings.html | 26.784 | 0.271 | × 98 |
redesigning-chrome-desktop.html | 75.500 | 0.918 | × 82 |
web-at-maximum-fps.html | 88.118 | 0.901 | × 98 |
early-adopting-the-future.html | 201.011 | 3.329 | × 60 |
pygmalian-raw-html.html | 311.416 | 2.692 | × 116 |
moby-dick-parsed.html | 2,466.533 | 25.14 | × 98 |
WebAssembly二进制比纯Javascript实现平均快86倍。中位数是98倍。有些边缘的用例很有趣,像moby-dick-parsed.html,纯Javascript版本用了2.5s而WebAssembly只用了25ms
因此,它不仅安全,而且在这个场景下比Javascript快。只有300行代码。
需要注意的是WebAssembly还不支持SIMD:还是这个提案。Rust也在慢慢的支持它(PR #549),他将能显著的提升性能!
在这个系列的后续文章中我们将会看到Rust会到达很多的星系,Rust越多的往后旅行,也会变得更加有趣。
谢谢阅读!