【译文】在.NET上通过Wasmtime使用WebAssembly

概述

来自字节码联盟(Bytecode Alliance)的WebAssembly(以下简称wasm)运行时——Wasmtime,最近添加了针对.NET Core的早期预览版本API,开发者可以在他们的.NET程序中使用该API直接编程加载和运行wasm代码。

那么问题来了,.NET Core已经是跨平台的运行时,为什么.NET开发者还需关注wasm?

如果你是.NET开发者,那么这里有几个让你对wasm感到兴奋的地方,这包括在所有平台上使用同一份可执行代码安全地隔离不可信代码,以及通过即将来临的wasm接口类型提案(WebAssembly interface type proposal)来获得无缝的互操作体验等。

在平台间共享更多的代码

.NET编译生成二进制代码已经可以跨平台使用,但使用本地库(例如,通过C或Rust写成的库)却依然比较困难,因为它需要原生的互操作,并且为每一个所支持的平台提供单独的构建。

然而,如果C或者Rust库被编译成wasm模块,那么同一个模块可以被不同的平台和编程环境所使用,其中也包括.NET环境,这将大大简化库和使用这些库的应用的分发。

安全地隔离不可信代码

.NET曾设计使用代码访问安全性(Code Access Security)和应用程序域(Application Domain)技术来沙箱隔离不可信代码,但最终这些技术都未能有效地对不可信代码进行隔离。结果微软最后放弃了沙箱化,并最终将这些技术从.NET Core中移除。

可是,你是否曾经在你的应用中加载不可信插件时,却找不到一种方法来防止插件进行任意系统调用或者直接读取进程的内存。现在,可以通过wasm来达到该目的,因为wasm最初是为任意性很强的Web环境所设计,在Web环境中,每当用户访问网站时,不可信代码都无时不刻在执行。

通过接口类型改进互操作性

wasm的接口类型提案引入了一种新方法,该方法可以减少在托管应用程序和wasm模块之间来回传递更复杂类型所需的粘合代码。新方法的目的是为了wasm更好地与编程语言所集成。

当接口类型最终被wasmtime为.NET所支持后,它将为在wasm和.NET之间交换复杂类型提供无缝的编程体验。

深入研究通过.NET使用wasm

接下来,我们将深入研究,如何使用Wasmtime API在.NET中加载和使用编译为wasm模块的Rust库,因此对C#编程语言稍微熟悉会有所帮助。

这里描述的API相当底层,它意味着,在概念上简单的操作(例如传递或接受字符串)需要大量的粘合代码。

将来,我们还将基于wasm接口类型提供更高级别的API,这将大大减少相同操作所需的代码。使用该API将使你可以像正常.NET程序一样轻松地在.NET中与wasm模块之间进行交互。

还请注意的是,该API仍在开发中,因为我们的目标是保持Wasmtime本身的稳定性,并且可能以向后不兼容的方式发生改变。

如果你不是.NET开发者,那也没问题,请查看Wasmtime的demo代码库,以获取相应的Python,Node.js和Rust等版本的实现。

创建wasm模块

我们将从构建Rust库(pulldown_cmark)开始,该库可用于将markdown文档渲染为HTML。前面已经提到,我们不会将Rust库编译为特定目标体系结构,而是将其编译为wasm格式,使得它们可以在.NET使用。

你并不需要对Rust编程语言熟悉,但是如果是构建wasm模块,那么安装相应的Rust工具链是有用的。有关安装Rust工具链的简便方法,请参考Rustup主页。

此外,我们将使用cargo-wasi,该命令可创建将Rust编译wasm所需的基础代码和编译环境:

1
cargo install cargo-wasi

然后,克隆Wasmtime的demo代码库:

1
2
git clone https://github.com/bytecodealliance/wasmtime-demos.git
cd wasmtime-demos

该代码库包括markdown文件目录和相应的Rust代码,其中Rust代码只是封装了pulldown_cmark

而后使用cargo-wasi构建markdown的wasm模块:

1
2
cd markdown
cargo wasi build --release

此时,target/wasm32-wasi/release目录中应有编译后的markdown.wasm文件。

如果你对所实现的rust代码感兴趣,请参看src/lib.rs文件,它包含如下内容:

1
2
3
4
5
6
7
8
9
10
use pulldown_cmark::{html, Parser};
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn render(input: &str) -> String {
let parser = Parser::new(input);
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
return html_output;
}

该rust代码的功能是export函数render,该函数的功能是将markdown格式字符串作为输入,处理并返回渲染后的HTML格式字符串。

让我们稍微暂停一下,简单地了解这里所做的事情:我们使用了一个现有的Rust crate,并用几行代码将其封装,其功能作为wasm函数进行了export,然后将其编译为可在.NET加载的wasm模块,而这里我们不用再考虑该模块将在什么平台(或体系结构)上运行,很酷啊兄弟,不是么?!

检视wasm模块内部

现在我们已经有了可使用的wasm模块,那么host需要为它需提供怎样的环境,它又为host提供了怎样的功能?

为了弄清楚这一点,让我们使用WebAssembly Binary Toolkit里的wasm2wat工具,将模块反汇编成可读文本的表示形式:

1
wasm2wat markdown.wasm --enable-multi-value > markdown.wat

注意:--enable-multi-value选项提供对多个返回值函数的支持,这对于反编译markdown.wasm模块是必须的。

模块需要host所提供的环境支持

模块的import方式定义了host应为模块提供哪些功能,下面是markdown模块的import段:

1
2
(import "wasi_unstable" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))
(import "wasi_unstable" "random_get" (func $random_get (param i32 i32) (result i32)))

该段申明告诉我们该模块需要host提供两个函数的接口:fd_writerandom_get。这两个函数实际上是具有明确行为定义的WebAssembly System Interface(简称WASI)函数:fd_write用于将数据写入特定的文件描述符中,random_get将用随机数据填充某个缓冲区。

很快我们将为.NET的host环境实现这些函数,但更重要的是要明白模块只能从host调用这些函数,host可以决定如何实现这些函数甚至是是否实现这些函数。

模块为主机提供了怎样的功能

模块的export段定义了它为host提供的功能函数,以下markdown模块的export段:

1
2
3
4
5
6
7
8
9
10
11
12
(export "memory" (memory 0))
(export "render" (func $render_multivalue_shim))
(export "__wbindgen_malloc" (func $__wbindgen_malloc))
(export "__wbindgen_realloc" (func $__wbindgen_realloc))
(export "__wbindgen_free" (func $__wbindgen_free))

...

(func $render_multivalue_shim (param i32 i32) (result i32 i32) ...)
(func $__wbindgen_malloc (param i32) (result i32) ...)
(func $__wbindgen_realloc (param i32 i32 i32) (result i32) ...)
(func $__wbindgen_free (param i32 i32) ...)

首先,模块export了它自身的memory内存段,wasm内存是模块可访问的线性地址空间,并且是模块可以读写的唯一内存区域。由于该模块无法直接访问host地址空间的任何其他区域内存,因此这段export的内存就是host与wasm模块交换数据的区域。

其次,模块export了我们用Rust实现的render函数,但是这里有个问题是,为什么在前面Rust实现的函数只有一个参数和一个返回值,而wasm对应的函数有两个参数和两个返回值?

在Rust中,当编译为wasm时,字符串切片类型(&str)和字符串(String)均表示为初地址和长度(以字节为单位)对的形式。因此,wasm版本的函数由于更底层,便直接采用了这种底层的初地址和长度对形式来表示参数和返回值。值得注意的是,这里的初地址表示的是export内存中的字节偏移量。

那么我们回头看之前的代码,由于Rust代码返回一个String,它是一个owned自有类型,因此render的调用者负责释放包含渲染字符串的返回内存值。

在.NET的host实现过程中,我们将逐一讨论其余的export项。

创建.NET工程

我们使用.NET Core SDK来创建.NET Core工程,所以请确保系统已安装了3.0或更高版本的.NET Core SDK。

为工程创建一个新的目录:

1
2
mkdir WasmtimeDemo
cd WasmtimeDemo

接下来,在目录中创建.NET Core命令行工程:

1
dotnet new console

最后,添加Wasmtime NuGet包的依赖关系:

1
dotnet add package wasmtime --version 0.8.0-preview2

现在,我们已做好使用Wasmtime的.NET API来加载并执行markdown模块的准备。

为wasm导入.NET代码

为wasm导入.NET实现的函数,跟.NET中实现IHost接口一样简单,只需一个公有的[Instance]属性来表示和host绑定的wasm模块。

Import属性被用于标记函数和域,正如wasm模块中的import那样。

我们之前提到,模块需要从host环境中import两个函数:fd_writerandom_get,所以接下来对这两个函数进行实现:

在工程目录中创建一个名为Host.cs的文件,并添加如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using System.Security.Cryptography;
using Wasmtime;

namespace WasmtimeDemo
{
class Host : IHost
{
// These are from the current WASI proposal.
const int WASI_ERRNO_NOTSUP = 58;
const int WASI_ERRNO_SUCCESS = 0;

public Instance Instance { get; set; }

[Import("fd_write", Module = "wasi_unstable")]
public int WriteFile(int fd, int iovs, int iovs_len, int nwritten)
{
return WASI_ERRNO_NOTSUP;
}

[Import("random_get", Module = "wasi_unstable")]
public int GetRandomBytes(int buf, int buf_len)
{
_random.GetBytes(Instance.Externs.Memories[0].Span.Slice(buf, buf_len));
return WASI_ERRNO_SUCCESS;
}

private RNGCryptoServiceProvider _random = new RNGCryptoServiceProvider();
}
}

fd_write实现仅仅只是简单地返回一个错误,表示不支持该操作。它可被模块用于将错误代码写入stderr中,而在我们的demo中则永远不会真正调用。

random_get的实现使用的是随机字节填充请求缓冲区的方式。它将代表整个模块export内存的Span切片,以便.NET的实现可以直接写入请求的缓冲区,而无需进行任何的中间复制操作。Rust标准库中HashMap的实现正是通过调用random_get函数来实现。

以上就是使用Wasmtime的API将.NET函数import到wasm模块的全部步骤。不过,在加载wasm模块并在.NET使用它们之前,我们需要讨论如何将字符串作为参数,将其从.NET的host传递到render函数中。

良好的宿主环境

基于模块化的export,我们知道它export了一块memory区域。从host的角度上来看,即使该模块与host本身共享相同的进程内存,也可以将wasm模块的export内存授权为对外部进程地址空间的权限。

如果你将数据随机写入外部地址空间,则会发生意想不到的后果,因为它很容易对其他程序的状态造成破坏并引起未定义的行为,例如程序崩溃或字节反转。那么主机应如何以安全的方式将数据传递到wasm模块中呢?

Rust程序在内部使用内存分配器来管理其内存,因此,为了使.NET成为wasm模块良好的宿主,在分配和释放wasm模块可访问的内存时,必须使用相同的内存分配器。

值得庆幸的是,Rust程序用来将自身导出为wasm模块的wasm-bindgen工具也为此export了两个函数:__wbindgen_malloc__wbindgen_free。除了__wbindgen_free需要知道内存地址和之前分配的内存大小之外,这两个函数本质上和C语言的mallocfree函数一样。

考虑到这一点,让我们为C#编写这些export函数的一个简单的封装,以便我们可以轻松分配和释放wasm模块可访问的内存大小。因此,在工程目录中创建一个名为Allocator.cs的文件,并添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Wasmtime.Externs;

namespace WasmtimeDemo
{
class Allocator
{
public Allocator(ExternMemory memory, IReadOnlyList<ExternFunction> functions)
{
_memory = memory ??
throw new ArgumentNullException(nameof(memory));

_malloc = functions
.Where(f => f.Name == "__wbindgen_malloc")
.SingleOrDefault() ??
throw new ArgumentException("Unable to resolve malloc function.");

_free = functions
.Where(f => f.Name == "__wbindgen_free")
.SingleOrDefault() ??
throw new ArgumentException("Unable to resolve free function.");
}

public int Allocate(int length)
{
return (int)_malloc.Invoke(length);
}

public (int Address, int Length) AllocateString(string str)
{
var length = Encoding.UTF8.GetByteCount(str);

int addr = Allocate(length);

_memory.WriteString(addr, str);

return (addr, length);
}

public void Free(int address, int length)
{
_free.Invoke(address, length);
}

private ExternMemory _memory;
private ExternFunction _malloc;
private ExternFunction _free;
}
}

这段代码虽然看起来很复杂,但它所做的就是从模块中按名称查找所需的export函数,并将它们封装在易于使用的接口中。我们将使用该辅助Allocator类将输入字符串分配给export的render函数。

现在,我们准备开始渲染markdown。

渲染markdown

在工程目录中打开Program.cs,并将其替换为以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
using System;
using System.Linq;
using Wasmtime;

namespace WasmtimeDemo
{
class Program
{
const string MarkdownSource =
"# Hello, `.NET`! Welcome to **WebAssembly** with [Wasmtime](https://wasmtime.dev)!";

static void Main()
{
using var engine = new Engine();

using var store = engine.CreateStore();

using var module = store.CreateModule("markdown.wasm");

using var instance = module.Instantiate(new Host());

var memory = instance.Externs.Memories.SingleOrDefault() ??
throw new InvalidOperationException("Module must export a memory.");

var allocator = new Allocator(memory, instance.Externs.Functions);

(var inputAddress, var inputLength) = allocator.AllocateString(MarkdownSource);

try
{
object[] results = (instance as dynamic).render(inputAddress, inputLength);

var outputAddress = (int)results[0];
var outputLength = (int)results[1];

try
{
Console.WriteLine(memory.ReadString(outputAddress, outputLength));
}
finally
{
allocator.Free(outputAddress, outputLength);
}
}
finally
{
allocator.Free(inputAddress, inputLength);
}
}
}
}

让我们一步步地看看这段代码做了哪些工作:

  1. 创建Engine对象,该Engine类代表了Wasmtime运行时本身。运行时支持从.NET加载和执行wasm模块;

  2. 然后创建Store对象,这个类是存放所有wasm对象(例如模块及其实例)的地方。Engine中可以有多个Store,但它们的关联对象不能相互影响;

  3. 接下来,基于markdown.wasm文件创建Module对象。Module代表wasm模块本身的数据,例如它import和export的数据。一个模块可以具有一个或多个实例,实例化是wasm模块的运行时的表示形式。它将模块的wasm指令编译为当前CPU体系结构的指令,分配模块可访问的实际内存,以及绑定从主机import的函数;

  4. 使用之前实现的Host类来实例化模块,绑定作为import项的.NET函数;

  5. 查找由模块export的memory段;

  6. 创建一个分配器,然后为需要渲染的markdown内容分配一个字符串;

  7. 以输入字符串为参数,通过将实例转换为dynamic的方式调用render函数。这本是C#的一项特性,在运行时动态绑定函数,可以将其简单地视为搜索并调用export后的render函数的快捷方式;

  8. 通过从wasm模块export的内存中读取返回的字符串,输出渲染后的HTML;

  9. 最后,释放分配的输入字符串和Rust提供给我们的返回字符串的内存。

以上就是代码所实现的步骤,然后继续运行该代码。

运行代码

在运行程序之前,需要将markdown.wasm复制到工程目录中,因为它是我们实际运行程序的地方。可以在构建目录的target/wasm32-wasi/release位置中找到该markdown.wasm文件。

从上面的Program.cs源码中,我们看到该程序对一些markdown进行了硬编码的渲染:

1
# Hello, `.NET`! Welcome to **WebAssembly** with [Wasmtime](https://wasmtime.dev)!

运行程序,将其渲染为HTM格式L:

1
dotnet run

如果一切正常,应该会出现下面的结果:

1
<h1>Hello, <code>.NET</code>! Welcome to <strong>WebAssembly</strong> with <a href="https://wasmtime.dev">Wasmtime</a>!</h1>

Wasmtime for .NET的下一步计划是什么?

从这里例子中,我们可以看到,现在实现该demo还需大量的C#代码,不是吗?

我们计划了从两个主要的功能点来简化代码的实现:

  • 将Wasmtime的WASI实现开放给.NET和其他语言

    在上面Host的实现中,必须手动去编写fd_writerandom_get,但它们实际上是WASI中已有的函数。

    Wasmtime本身包含了WASI的实现,只是目前无法通过.NET的API进行访问。

    一旦.NET的API可以访问和配置Wasmtime的WASI版本实现,则.NET的host环境将无需提供自己的实现。

  • 实现.NET的接口类型

    前面提到,wasm接口类型可以使wasm更加自然地与托管编程语言进行集成。

    一旦.NET的API实现了未来通过后的接口类型提案,便无需像前面那样去还要创建一个辅助功能的Allocator类。

    到那时,使用诸如字符串等类型的函数可很容易办到,而不必在.NET中编写任何粘合代码。

所以希望将来该demo是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using System;
using Wasmtime;

namespace WasmtimeDemo
{
interface Markdown
{
string Render(string input);
}

class Program
{
const string MarkdownSource =
"# Hello, `.NET`! Welcome to **WebAssembly** with [Wasmtime](https://wasmtime.dev)!";

static void Main()
{
using var markdown = Module.Load<Markdown>("markdown.wasm");

Console.WriteLine(markdown.Render(MarkdownSource));
}
}
}

我们都认为这样看起来简洁多了!

结束语

这是在Web浏览器之外利用不同的编程环境(包括微软的.NET平台)使用wasm的兴奋之旅的开始,如果你是.NET开发者,希望您能加入我们的旅程!

本文的.NET示例代码可以在Wasmtime示例代码库中找到。

(译者注:本文原地址为 https://hacks.mozilla.org/2019/12/using-webassembly-from-dotnet-with-wasmtime/ ,原作者为 Peter Huene)