人们对在浏览器之外运行 WebAssembly 很兴奋。
这种兴奋不仅仅是关于 WebAssembly 在自己的独立运行时中运行。人们也对从 Python、Ruby 和 Rust 等语言中运行 WebAssembly 感到兴奋。
你为什么要这么做?几个原因
- 使“原生”模块更简单
像 Node 或 Python 的 CPython 这样的运行时通常允许你在底层语言(如 C++)中编写模块。这是因为这些底层语言通常快得多。因此,你可以在 Node 中使用原生模块,或在 Python 中使用扩展模块。但是这些模块通常很难使用,因为它们需要在用户的设备上进行编译。使用 WebAssembly “原生”模块,你可以在不增加复杂性的情况下获得大部分速度。 - 使沙箱化原生代码更容易
另一方面,像 Rust 这样的底层语言不会为了速度而使用 WebAssembly。但是它们可以使用 WebAssembly 来保证安全。正如我们在 WASI 公告 中讨论的那样,WebAssembly 默认情况下提供了轻量级沙箱。因此,像 Rust 这样的语言可以使用 WebAssembly 来对原生代码模块进行沙箱化。 - 跨平台共享原生代码
如果开发人员能够跨不同平台(例如 web 和桌面应用程序之间)共享相同的代码库,他们可以节省时间并降低维护成本。这对脚本语言和底层语言都是如此。WebAssembly 为你提供了一种方法,可以在不降低这些平台性能的情况下做到这一点。
因此,WebAssembly 真的可以帮助其他语言解决重要问题。
但是,使用当今的 WebAssembly,你不会想以这种方式使用它。你可以在所有这些地方 *运行* WebAssembly,但这还不够。
目前,WebAssembly 只用数字说话。这意味着这两种语言可以相互调用函数。
但是,如果一个函数接受或返回除数字以外的任何东西,事情就会变得复杂。你可以选择
- 发布一个具有非常难用的 API 的模块,该 API 只能用数字说话……让模块用户的生活变得艰难。
- 为你想让这个模块运行的每个环境添加粘合代码……让模块开发人员的生活变得艰难。
但事实并非如此。
应该可以发布一个 *单一的* WebAssembly 模块,并使其在任何地方运行……而不会让模块用户或开发人员的生活变得艰难。
因此,同一个 WebAssembly 模块可以使用丰富的 API(使用复杂类型)与以下内容进行通信
- 在自己的原生运行时中运行的模块(例如在 Python 运行时中运行的 Python 模块)
- 用不同源语言编写的其他 WebAssembly 模块(例如,在浏览器中一起运行的 Rust 模块和 Go 模块)
- 主机系统本身(例如,为操作系统或浏览器的 API 提供系统接口的 WASI 模块)
有了新的早期提案,我们正在看到如何使这项工作正常进行™,正如你在这个演示中看到的那样。
所以,让我们来看看这将如何工作。但首先,让我们看看我们今天所处的位置以及我们试图解决的问题。
WebAssembly 与 JS 通信
WebAssembly 不限于 web。但到目前为止,WebAssembly 的大部分开发都集中在 Web 上。
这是因为当专注于解决具体的用例时,你可以做出更好的设计。该语言肯定会需要在 Web 上运行,因此这是一个很好的起点。
这使 MVP 拥有了一个很好的封闭范围。WebAssembly 只需要能够与一种语言进行通信——JavaScript。
这相对容易做到。在浏览器中,WebAssembly 和 JS 都在同一个引擎中运行,因此该引擎可以帮助它们 高效地相互通信。
但是,当 JS 和 WebAssembly 尝试相互通信时,存在一个问题……它们使用不同的类型。
目前,WebAssembly 只能用数字说话。JavaScript 有数字,但也有很多其他类型。
甚至数字也不一样。WebAssembly 有 4 种不同的数字类型:int32、int64、float32 和 float64。JavaScript 目前只有 Number(尽管它很快就会有另一种数字类型,BigInt)。
差异不仅仅体现在这些类型的名称上。它们的值在内存中的存储方式也不同。
首先,在 JavaScript 中,任何值(无论其类型如何)都放在一个称为盒子的东西中(我在另一篇文章中详细解释了 装箱)。
相比之下,WebAssembly 为其数字提供了静态类型。因此,它不需要(也不理解)JS 盒子。
这种差异使得它们难以相互通信。
但是,如果你想将一个值从一种数字类型转换为另一种类型,有一些非常简单的规则。
由于它非常简单,因此很容易写下来。你可以在 WebAssembly 的 JS API 规范 中找到这种写法。
这种映射在引擎中是硬编码的。
这有点像引擎有一本参考书。每当引擎需要在 JS 和 WebAssembly 之间传递参数或返回值时,它就会从书架上取下这本书,看看如何转换这些值。
使用如此有限的类型集(只有数字)使这种映射变得非常容易。这对 MVP 来说很棒。它限制了需要做出多少艰难的设计决策。
但这使得使用 WebAssembly 的开发人员更加复杂。为了在 JS 和 WebAssembly 之间传递字符串,你必须找到一种方法将字符串转换为数字数组,然后将数字数组转换回字符串。我在之前的 文章 中解释了这一点。
这并不难,但很乏味。因此,构建了工具来抽象掉这一点。
例如,像 Rust 的 wasm-bindgen 和 Emscripten 的 Embind 这样的工具会自动使用 JS 粘合代码包装 WebAssembly 模块,这些代码会执行从字符串到数字的转换。
这些工具也可以对其他高级类型进行这种类型的转换,例如具有属性的复杂对象。
这有效,但在一些非常明显的用例中效果并不好。
例如,有时你只想将一个字符串传递到 WebAssembly 中。你希望一个 JavaScript 函数将一个字符串传递给 WebAssembly 函数,然后让 WebAssembly 将其传递给另一个 JavaScript 函数。
以下需要发生的事情才能使其正常工作
- 第一个 JavaScript 函数将字符串传递给 JS 粘合代码
-
JS 粘合代码将该字符串对象转换为数字,然后将这些数字放入线性内存中
-
然后将一个数字(指向字符串开头的指针)传递给 WebAssembly
-
WebAssembly 函数将该数字传递给另一侧的 JS 粘合代码
-
第二个 JavaScript 函数从线性内存中提取所有这些数字,然后将其解码回一个字符串对象
-
它将其传递给第二个 JS 函数
因此,一侧的 JS 粘合代码只是反转了它在另一侧所做的工作。为了重新创建基本上相同的对象,这需要做很多工作。
如果字符串可以像那样直接通过 WebAssembly 传递而无需任何转换,那将更容易。
WebAssembly 将无法对该字符串做任何事情——它不理解该类型。我们不会解决这个问题。
但它可以将字符串对象在两个 JS 函数之间来回传递,因为它们 *确实* 理解该类型。
因此,这是 WebAssembly 引用类型提案 的原因之一。该提案添加了一个新的基本 WebAssembly 类型,名为 anyref
。
使用 anyref
,JavaScript 只需向 WebAssembly 提供一个引用对象(基本上是一个不公开内存地址的指针)。该引用指向 JS 堆上的对象。然后 WebAssembly 可以将其传递给其他 JS 函数,这些函数知道如何使用它。
因此,它解决了与 JavaScript 的互操作性方面最令人讨厌的问题之一。但这并不是要解决的唯一互操作性问题。
浏览器中还有另一组更大的类型。如果我们想获得良好的性能,WebAssembly 需要能够与这些类型进行互操作。
WebAssembly 直接与浏览器通信
JS 只是浏览器的一部分。浏览器还拥有许多其他函数(称为 Web API),你可以使用它们。
在幕后,这些 Web API 函数通常用 C++ 或 Rust 编写。它们有自己的内存中对象存储方式。
Web API 的参数和返回值可以是多种不同的类型。手动为每种类型创建映射将非常困难。因此,为了简化问题,有一种标准方法来描述这些类型的结构——Web IDL。
当使用这些函数时,你通常是从 JavaScript 中使用它们。这意味着你传递的是使用 JS 类型的值。JS 类型如何转换为 Web IDL 类型?
就像 WebAssembly 类型到 JavaScript 类型的映射一样,JavaScript 类型到 Web IDL 类型的映射也是如此。
因此,这就像引擎还有另一本参考书,展示了如何从 JS 到 Web IDL。这种映射也是在引擎中硬编码的。
对于许多类型,JavaScript 和 Web IDL 之间的这种映射非常简单。例如,像 DOMString 和 JS 的 String 这样的类型是兼容的,可以相互直接映射。
现在,当尝试从 WebAssembly 调用 Web API 时会发生什么?在这里,我们遇到了问题。
目前,WebAssembly 类型和 Web IDL 类型之间没有映射。这意味着,即使对于像数字这样简单的类型,你的调用也必须通过 JavaScript。
以下是它的工作原理
- WebAssembly 将值传递给 JS。
- 在此过程中,引擎将该值转换为 JavaScript 类型,并将其放在 JS 堆中的内存中
- 然后,将该 JS 值传递给 Web API 函数。在此过程中,引擎将 JS 值转换为 Web IDL 类型,并将其放在内存的另一个部分,即渲染器的堆中。
这比需要做的工作更多,也使用了更多内存。
有一个明显的解决方案——创建从 WebAssembly 到 Web IDL 的映射。但这并不像看起来那么简单。
对于像 `boolean` 和 `unsigned long`(表示数字)这样的简单 Web IDL 类型,从 WebAssembly 到 Web IDL 有明确的映射关系。
但大多数情况下,Web API 参数是更复杂的类型。例如,一个 API 可能接受一个字典,它本质上是一个带有属性的对象,或者一个序列,它类似于数组。
为了在 WebAssembly 类型和 Web IDL 类型之间建立直接映射,我们需要添加一些更高级的类型。我们正在通过 GC 提案 来实现这一点。有了它,WebAssembly 模块将能够创建 GC 对象(例如结构体和数组),这些对象可以映射到复杂的 Web IDL 类型。
但是,如果与 Web API 交互的唯一方式是通过 GC 对象,那么对于像 C++ 和 Rust 这样的不会使用 GC 对象的语言来说,这会让生活变得更加困难。每当代码与 Web API 交互时,它都必须创建一个新的 GC 对象并将值从其线性内存复制到该对象中。
这比我们今天使用 JS 胶水代码的情况只稍微好一点。
我们不希望 JS 胶水代码必须构建 GC 对象 - 那是浪费时间和空间。我们也不希望 WebAssembly 模块这样做,原因相同。
我们希望使用线性内存(如 Rust 和 C++)的语言调用 Web API 与使用引擎内置 GC 的语言一样容易。因此,我们需要一种方法来创建线性内存中的对象和 Web IDL 类型之间的映射关系。
但是,这里有一个问题。每种语言在线性内存中以不同的方式表示事物。我们不能只选择一种语言的表示方式。那会让其他所有语言效率低下。
但即使这些事物的内存中的确切布局通常不同,它们通常也有一些共同的抽象概念。
例如,对于字符串,语言通常有一个指向内存中字符串起始位置的指针,以及字符串的长度。即使字符串有更复杂的内部表示,它通常也需要在调用外部 API 时将字符串转换为这种格式。
这意味着我们可以将此字符串简化为 WebAssembly 理解的类型… 两个 i32。
我们可以在引擎中硬编码这样的映射。因此,引擎将拥有另一本参考书,这次用于 WebAssembly 到 Web IDL 的映射关系。
但是,这里有一个问题。WebAssembly 是一种类型检查语言。为了保持事情 安全,引擎必须检查调用代码是否传递了与被调用方要求的类型匹配的类型。
这是因为攻击者可以通过利用类型不匹配来让引擎执行它不应该做的事情。
如果您调用的是接受字符串的函数,但尝试传递一个整数,引擎会提醒您。它应该提醒您。
因此,我们需要一种方法让模块明确地告诉引擎:“我知道 Document.createElement() 接受一个字符串。但是当我调用它时,我会传递两个整数。使用这些整数从我的线性内存中的数据创建 DOMString。使用第一个整数作为字符串的起始地址,使用第二个整数作为长度。”
这就是 Web IDL 提案所做的。它为 WebAssembly 模块提供了一种在它使用的类型和 Web IDL 的类型之间建立映射关系的方法。
这些映射不是在引擎中硬编码的。相反,模块会附带自己的一个小册子,其中包含映射关系。
因此,这为引擎提供了一种方法,可以这么说:“对于此函数,进行类型检查,就像这两个整数是一个字符串一样。”
这个小册子与模块捆绑在一起,还有另一个好处。
有时,通常将字符串存储在线性内存中的模块可能希望在特定情况下使用 `anyref` 或 GC 类型… 例如,如果模块只是将从 JS 函数(例如 DOM 节点)获取的对象传递给 Web API。
因此,模块需要能够在函数级别(甚至参数级别)选择如何处理不同的类型。由于映射关系由模块提供,因此它可以针对该模块进行定制。
您如何生成这个小册子?
编译器为您处理此信息。它向 WebAssembly 模块添加了一个自定义部分。因此,对于许多语言工具链,程序员不需要做太多工作。
例如,让我们看看 Rust 工具链如何处理最简单的案例之一:将字符串传递给 `alert` 函数。
#[wasm_bindgen]
extern "C" {
fn alert(s: &str);
}
程序员只需告诉编译器使用 `#[wasm_bindgen]` 注解将此函数包含在小册子中即可。默认情况下,编译器会将其视为线性内存字符串,并为我们添加正确的映射关系。如果我们需要以不同的方式处理它(例如,作为 `anyref`),我们需要使用第二个注解告诉编译器。
有了它,我们就可以省略中间的 JS。这样可以加快 WebAssembly 和 Web API 之间的值传递速度。此外,这意味着我们不需要发布那么多的 JS。
我们没有在支持的语言类型上做出任何妥协。可以有各种编译成 WebAssembly 的语言。这些语言都可以将它们的类型映射到 Web IDL 类型 - 无论语言使用的是线性内存、GC 对象还是两者兼而有之。
在我们退一步观察了这个解决方案之后,我们意识到它解决了一个更大的问题。
WebAssembly 与所有事物进行通信
这里我们回到引言中的承诺。
是否存在一种可行的方法,让 WebAssembly 使用所有这些不同的类型系统与所有这些不同的东西进行通信?
让我们看看可选项。
您可以尝试创建在引擎中硬编码的映射关系,例如 WebAssembly 到 JS 和 JS 到 Web IDL 的映射关系。
但是,要做到这一点,对于每种语言,您都必须创建一个特定的映射关系。引擎必须明确地支持这些映射关系,并在两侧的语言发生变化时对其进行更新。这会造成真正的混乱。
这有点像早期的编译器的设计方式。每种源语言到每种机器代码语言都有一个管道。我在 关于 WebAssembly 的第一篇博文中 对此进行了更详细的讨论。
我们不想要这么复杂的东西。我们希望所有这些不同的语言和平台能够相互通信。但我们也需要它具有可扩展性。
因此,我们需要一种不同的方法来做到这一点… 更像是现代编译器架构。它们在前端和后端之间进行了分离。前端将源语言转换为抽象的中间表示(IR)。后端将该 IR 转换为目标机器代码。
这就是 Web IDL 的见解所在。当你眯着眼睛看它时,Web IDL 看起来有点像 IR。
现在,Web IDL 对 Web 来说非常具体。WebAssembly 在 Web 之外还有很多用例。因此,Web IDL 本身并不是一个很好的 IR。
但如果您只是将 Web IDL 作为灵感并创建一组新的抽象类型,会怎样?
这就是我们如何获得 WebAssembly 接口类型提案的。
这些类型不是具体类型。它们不像 WebAssembly 中的 `int32` 或 `float64` 类型。WebAssembly 中没有对它们的任何操作。
例如,WebAssembly 中不会添加任何字符串连接操作。相反,所有操作都在两端的具体类型上执行。
有一个关键点让这成为可能:使用接口类型,两侧不会尝试共享表示方式。相反,默认情况下是在两侧之间复制值。
有一个情况似乎是该规则的例外:我之前提到的新的引用值(例如 `anyref`)。在这种情况下,在两侧之间复制的是指向对象的指针。因此,两个指针都指向同一件事。理论上,这意味着它们需要共享表示方式。
在引用只是通过 WebAssembly 模块传递的情况下(如我上面给出的 `anyref` 示例),两侧仍然不需要共享表示方式。模块不需要理解该类型,只需要将其传递给其他函数即可。
但有时两侧会想要共享表示方式。例如,GC 提案添加了一种 创建类型定义 的方法,以便两侧可以共享表示方式。在这些情况下,如何共享表示方式的选择由设计 API 的开发人员决定。
这使得单个模块与多种语言进行通信变得容易得多。
在某些情况下,例如浏览器,从接口类型到主机具体类型的映射关系将被嵌入引擎中。
因此,一组映射关系是在编译时嵌入的,另一组是在加载时传递给引擎的。
但在其他情况下,例如当两个 WebAssembly 模块相互通信时,它们都会发送自己的小册子。它们各自将它们函数的类型映射到抽象类型。
这不是实现不同源语言编写的模块相互通信的唯一要求(我们将在未来对此进行更多介绍),但它是在这一方向上迈出的重要一步。
所以,现在您已经了解了原因,让我们来看看方法。
这些接口类型到底是什么样子的?
在我们查看详细信息之前,我应该再次说明:该提案仍在开发中。因此,最终的提案可能看起来非常不同。
此外,所有这一切都是由编译器处理的。因此,即使提案最终确定,您只需要知道您的工具链希望您在代码中添加哪些注解(如上面的 wasm-bindgen 示例)。您不需要真正了解它的内部机制。
但 提案的详细信息 非常不错,所以让我们深入研究一下目前的思考。
要解决的问题
我们需要解决的问题是在一个模块与另一个模块(或直接与主机,如浏览器)通信时,在不同类型之间转换值。
有四个地方可能需要进行转换
对于导出函数
- 接受来自调用者的参数
- 返回值给调用者
对于导入函数
- 传递参数给函数
- 接受函数的返回值
您可以将这些中的每一个视为朝两个方向之一进行
- 提升,用于离开模块的值。这些从具体类型变为接口类型。
- 降低,用于进入模块的值。这些从接口类型变为具体类型。
告诉引擎如何转换具体类型和接口类型
因此,我们需要一种方法来告诉引擎对函数的参数和返回值应用哪些转换。我们该怎么做呢?
通过定义接口适配器。
例如,假设我们有一个编译为 WebAssembly 的 Rust 模块。它导出一个名为 `greeting_` 的函数,可以被调用,没有任何参数,并返回问候语。
这是它现在的外观(在 WebAssembly 文本格式中)。
因此,现在这个函数返回两个整数。
但我们希望它返回 `string` 接口类型。因此,我们添加了一些称为接口适配器的东西。
如果引擎理解接口类型,那么当它看到这个接口适配器时,它将用这个接口包装原始模块。
它不再导出 `greeting_` 函数… 而是导出包装原始函数的 `greeting` 函数。这个新的 `greeting` 函数返回一个字符串,而不是两个数字。
这提供了向后兼容性,因为不理解接口类型的引擎只会导出原始的 `greeting_` 函数(返回两个整数的函数)。
接口适配器如何告诉引擎将两个整数转换为字符串?
它使用一系列适配器指令。
上面的适配器指令是从提案指定的少量新指令集中提取的两个指令。
以下是上面指令的作用
- 使用 `call-export` 适配器指令调用原始 `greeting_` 函数。这是原始模块导出的函数,它返回两个数字。这些数字被放到堆栈上。
- 使用 `memory-to-string` 适配器指令将这些数字转换为构成字符串的字节序列。我们必须在这里指定“mem”,因为 WebAssembly 模块将来可能具有多个内存。这告诉引擎要查看哪个内存。然后,引擎从堆栈顶端获取两个整数(它们是指针和长度),并使用它们来确定要使用哪些字节。
这可能看起来像一门成熟的编程语言。但这里没有控制流——你没有循环或分支。所以,即使我们给引擎提供指令,它仍然是声明式的。
如果我们的函数还接受字符串作为参数(例如,要问候的人的姓名),它会是什么样子?
非常相似。我们只需更改适配器函数的接口以添加参数。然后,我们添加两个新的适配器指令。
以下是这些新指令的作用
- 使用 `arg.get` 指令获取对字符串对象的引用并将其放到堆栈上。
- 使用 `string-to-memory` 指令获取该对象中的字节并将其放入线性内存中。再次,我们必须告诉它将字节放入哪个内存中。我们还必须告诉它如何分配字节。我们通过向它提供一个分配器函数来做到这一点(这将是原始模块提供的导出)。
使用这种指令的一个好处是:我们可以将来扩展它们… 就像我们可以扩展 WebAssembly 核心中的指令一样。我们认为我们正在定义的指令是一套不错的指令,但我们并没有承诺这些是永远唯一的指令。
如果您想更多地了解这一切是如何工作的,解释器提供了更多详细信息。
将这些指令发送到引擎
现在,我们如何将它发送到引擎?
这些注释将被添加到二进制文件中的自定义部分。
如果引擎了解接口类型,它可以使用自定义部分。如果不是,引擎可以忽略它,并且可以使用一个 polyfill,它将读取自定义部分并创建粘合代码。
这与 CORBA、Protocol Buffers 等有什么不同?
还有其他标准似乎解决了相同的问题——例如 CORBA、Protocol Buffers 和 Cap’n Proto。
它们有什么不同?它们正在解决一个更难的问题。
它们都是为了让你能够与一个你没有共享内存的系统交互而设计的——要么是因为它运行在不同的进程中,要么是因为它位于网络上的完全不同的机器上。
这意味着你必须能够将中间的东西——对象的“中间表示”——发送到该边界。
因此,这些标准需要定义一种序列化格式,它可以有效地跨越边界。这是它们标准化工作的重要组成部分。
即使这看起来像一个类似的问题,但实际上它几乎是完全相反的。
使用接口类型,这个“IR”永远不需要离开引擎。它甚至对模块本身不可见。
模块只看到引擎在过程结束时为它们吐出的东西——复制到它们线性内存中的东西,或者作为引用给它们的。因此,我们不必告诉引擎这些类型的布局——不需要指定。
指定的是你与引擎对话的方式。这是你发送给引擎的这份手册的声明性语言。
这有一个很好的副作用:因为这一切都是声明式的,所以引擎可以识别何时不需要转换——比如当两侧的模块使用相同的类型时——并完全跳过转换工作。
你现在可以玩玩这个吗?
正如我上面提到的,这是一个早期阶段的提案。这意味着事情会快速变化,你不想在生产中依赖它。
但如果你想开始玩玩它,我们已经在整个工具链中实现了它,从生产到消费
- Rust 工具链
- wasm-bindgen
- Wasmtime WebAssembly 运行时
而且,由于我们维护所有这些工具,并且由于我们正在研究标准本身,因此我们可以随着标准的发展而跟进。
即使所有这些部分都会继续变化,我们也确保同步对它们的更改。因此,只要你使用所有这些的最新版本,事情就不会出现太大问题。
因此,以下是你现在可以玩玩这个的多种方法。有关最新版本,请查看这个 演示仓库。
谢谢
- 感谢将所有这些语言和运行时中的所有部分整合在一起的团队:Alex Crichton、Yury Delendik、Nick Fitzgerald、Dan Gohman 和 Till Schneidereit
- 感谢提案的共同负责人及其同事为提案做出的努力:Luke Wagner、Francis McCabe、Jacob Gravelle、Alex Crichton 和 Nick Fitzgerald
- 感谢我出色的合作者 Luke Wagner 和 Till Schneidereit 对本文提供的宝贵意见和反馈
关于 Lin Clark
Lin 在 Mozilla 的高级开发部门工作,专注于 Rust 和 WebAssembly。
一条评论