翻译了一篇 WebAssembly 团队成员的博文,主要是介绍了她的新提案:WebAssembly Interface Types。
原文地址 (英文):WebAssembly Interface Types: Interoperate with All the Things!
作者:Lin Clark
原本以为「干翻 JavaScript」仅仅是说说而已,没想到 Mozilla 是认真的?!但也不要高兴的太早,这个提案还处于早期阶段,能不能成还不好说,就算 Mozilla 铁了心的推,能在 2020 年内成为标准也是几乎是不可能的。无论这个提案能走多远,这大概是目前为止关于 WebAssembly 的所有提案中最亦可赛艇的一个了!
狗屁不通翻译法 + 瞎几把乱猜翻译法 警告!
人们对于在浏览器外运行 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 模块)
- 主机系统本身 (例如,由 WASI 提供的连接到操作系统或浏览器 API 的系统接口
通过一个新的早期提案,我们将看到如何让它正常工作™,正如您在本演示中所看到的那样。
(原视频在油管上,我给搬运到了 B 站)
我们可以看到它是如何工作的。不过首先,让我们看看它如今的现状以及我们需要解决的问题。
WebAssembly 与 JS 通信
WebAssembly 不仅限于 Web。但是到目前为止,与 WebAssembly 相关的大多数开发都集中在 Web 上。
当你面对具体使用场景时,往往会有更好的设计。该语言是肯定必须能在 Web 上运行的,因此这是一个很好的起点。
这给出了一个很好的 MVP (Minimum Viable Product,直译过来就是:最低可行产品) 范围。WebAssembly 只需要能够与一种语言 (JavaScript) 进行交互。
这样做相对容易。在浏览器中,WebAssembly 和 JS 都在同一个引擎中运行,因此引擎可以帮助它们有效地相互通信。
不过,当 JS 和 WebAssembly 试图相互通信时,存在一个问题……它们使用的类型不同。
目前,WebAssembly 只能以数值进行通信。JavaScript 中有数值,但同时也有很多的其他类型。
甚至数值类型都不一样。WebAssembly 中有 4 种不同的数值:int32,int64,float32 和 float64。JavaScript 目前只有 Number (虽然很快会有另一种数字类型,BigInt)。
不同之处不仅在于这些类型的名称。这些值也以不同的方式存储在内存中。
首先,在 JavaScript 中,任何值 (无论类型如何) 都被放入一个称为盒子 (box) 的东西中 (我在另一篇文章中解释了 boxing)。
相反,WebAssembly 的数值都是静态类型。因此,它不需要 (也不理解) JS box。
这种差异使得彼此之间难以沟通。
不过,想要将一种数字类型转换另一种数字类型并不复杂。
因为他们如此简单,所以很容易实现。你可以在 WebAssembly’s JS API spec 中找到相关内容。
此映射硬编码在引擎中。
这有点像引擎有一本参考书。每当引擎必须在 JS 和 WebAssembly 之间传递参数或返回值时,它就会从架子上提取该参考书,以了解如何转换这些值。
拥有如此有限的一组类型 (只是数值) 使得这种映射非常容易。这对于 MVP 来说是非常棒的设计。这些限制使得设计者无需作出太多艰难的设计决策。
但是对于使用 WebAssembly 的开发人员来说,事情变得更加复杂了。要在 JS 和 WebAssembly 之间传递字符串,您必须找到一种方法将字符串转换为数值数组,然后将数值数组转换回字符串。我在上一篇文章中对此进行了解释。
这并不困难,但很乏味。因此我们使用了一些工具来将其抽象化。
例如,像 Rust’s wasm-bindgen 和 Emscripten’s Embind 这样的工具会自动用 JS 胶水代码包装 WebAssembly 模块,该代码可以实现从字符串到数值的转换。
这些工具也可以对其他高级类型进行此类转换,例如具有属性的复杂对象。
这个方式可行,但存在一些非常明显的不能很好地工作的情况。
例如,有时你只想通过 WebAssembly 透传字符串。你希望 JavaScript 函数将字符串传递给 WebAssembly 函数,然后让 WebAssembly 将其传递给另一个 JavaScript 函数。
为了达到这个目标,需要做以下事情:
- 第一个 JavaScript 函数将字符串传递给 JS 胶水代码
- JS 胶水代码将该字符串对象转换为数值,然后将这些数值放入线性内存中
- 然后将一个数值 (指向字符串开头的指针) 传递给 WebAssembly
- WebAssembly 函数将该数值传递给另一侧的 JS 胶水代码
- 另一侧的 JS 胶水代码从线性内存中提取所有这些数值,然后将它们解码回字符串对象
- JS 胶水传将字符串递给第二个 JavaScript 函数
因此,一侧的 JS 胶水代码只是翻转了它在另一侧所做的工作。很多工作花费在重建基本相同的对象上。
如果字符串直接通过 WebAssembly 透传而没有任何转换,那将更容易。
WebAssembly 无法使用此字符串执行任何操作——它无法理解该类型。我们不会解决这个问题 (指让 WebAssembly 理解 JS 字符串)。
但它可以在两个 JS 函数间来回传递字符串对象,因为他们确实能理解该类型。
因此,这是我们提出 WebAssembly 引用类型提议的原因之一。该提议添加了一个名为 anyref
的新的基本 WebAssembly 类型。
使用 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 的映射。但这并不像看起来那样简单。
对于简单的 Web IDL 类型,如 boolean
和 unsigned long
(这是一个数字) ,从 WebAssembly 到 Web IDL 有明确的映射。
但在大多数情况下,Web API 参数是更复杂的类型。例如,API 可能需要一个字典,它基本上是一个具有属性的序列 (类似于数组) 对象。
为了在 WebAssembly 类型和 Web IDL 类型之间直接映射,我们需要添加一些更高级的类型。我们正在通过 GC 提案做到这一点。有了它,WebAssembly 模块将能够创建可以映射到复杂的 Web IDL 类型上的 GC 对象 (如结构体和数组)。
但是,如果与 Web API 进行互操作的唯一方法是通过 GC 对象,那么对于像 C++ 和 Rust 这样不会使用 GC 的语言来说,这会更加艰难。只要代码与 Web API 交互,就必须创建一个新的 GC 对象,并将值从其线性内存复制到该对象中。
这只比我们今天的JS胶水代码好一点点。
我们不希望使用 JS 胶水代码构建 GC 对象——这是在浪费时间和空间。出于同样的原因,我们也不希望 WebAssembly 模块这样做。
我们希望使用线性内存的语言 (如 Rust 和 C ++) 调用 Web API 与使用引擎内置 GC 的语言调用 Web API 一样简单。因此,我们需要一种在线性内存中的对象与 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]
告诉编译器将此功能包括在手册中即可。默认情况下,编译器会将其视为储存在线性内存中的字符串,并为我们添加正确的映射。如果我们需要对它进行不同的处理 (例如作为 anyref
),我们必须使用注释告诉编译器。
因此,我们可以在剔除掉中间的 JS。这使得在 WebAssembly 和 Web API 之间传递值更快。此外,这意味着我们不需要交付那么多的 JS。
而且我们不必对我们支持的语言做出任何妥协。可以将所有不同类型的语言编译为 WebAssembly。这些语言都可以将它们的类型映射到 Web IDL 类型 - 无论语言是使用线性内存还是 GC 对象,还是两者都使用。
当我们退后一步重新审视这个解决方案时,我们意识到它解决了一个更大的问题。
让 WebAssembly 互联万物
让我们说回简介中承诺的地方。
有没有一种方法让 WebAssembly 与所有使用不同类型的系统互通呢?
让我们看一下有什么选项。
您可以尝试在引擎中创建硬编码的映射,例如 WebAssembly 到 JS 和 JS 到 Web IDL。
但要做到这一点,对于每种语言,您必须创建一个特定的映射。并且引擎必须明确支持这些映射中的每一个,并在任何一方的语言发生变化时更新它们。这会比你的耳机线更乱。
这是早期编译器的设计方式。每种源语言到每种机器代码语言都有一条管道。我在 first posts on WebAssembly 中对此进行了更多的讨论。
我们不想要这么复杂的东西。我们希望所有这些不同的语言和平台能够相互调用。同时,我们也需要它可扩展。
所以我们需要一种不同的方式来做到这一点…更像现代编译器架构。它们在前端和后端之间分离。前端从源语言到抽象中间表示 (intermediate representation/IR)。后端从 IR 到目标机器代码。
这就是我对 Web IDL 的理解。当你仔细观察它时,你会发现 Web IDL 很像一个 IR。
现在,Web IDL 为 Web 而生。但 WebAssembly 还有很多 Web 之外的野心。因此,Web IDL 并不是一个很好的 IR。
但是,如果你只是使用 Web IDL 作为灵感并创建一组新的抽象类型呢?
这就是我们提出 WebAssembly 接口类型提议 (WebAssembly Interface Types) 的原因。
这里的类型不是指的具体的类型。他们不像今天 WebAssembly 中的 int32
或 float64
类型。WebAssembly 也不能对其进行任何操作。
例如,WebAssembly 中不会添加任何字符串连接操作。相反,所有操作都在两端的具体类型上执行。
有一个关键点使之成为可能:对于接口类型,双方并不试图共享表示。相反,默认是在一侧和另一侧之间复制值。
有一种情况例外:我之前提到的新参考值 (如 anyref
)。在这种情况下,在两侧之间复制的是指向对象的指针。所以两个指针指向同一个东西。理论上,这可能意味着他们需要共享一个表示。
如果引用只是在 WebAssembly 模块中透传 (就像我上面给出的关于 anyref
的示例),双方仍然不需要共享表示。无论如何,模块不会理解该类型……只需将其传递给其他函数即可。
但是有时候双方会希望共享一个表达方式。例如,GC 提案添加了一种创建类型定义的方法,以便双方可以共享表达形式。在这些情况下,共享多少表达形式决于设计 API 的开发人员。
这使得不同语言开发的不同模块间的调用变得容易得多了。
在某些情况下,例如浏览器,从接口类型到主机具体类型的映射将被引入引擎。
因此,一组映射在编译时生成,而另一组在加载时传递给引擎。
但是在其他情况下,例如当两个 WebAssembly 模块彼此交谈时,它们都将自己的小册子递给引擎。各自将函数的类型映射到抽象类型。
要使不同源语言编写的模块能够相互通信,这不是唯一需要作的工作 (我们将来会对此进行更多详细介绍),但这是朝这个方向迈出的一大步。
所以现在你明白了为什么,让我们来看看怎么做。
这些接口类型实际上是什么样的?
在我们讨论细节之前,我必须再强调一遍:该提案仍在制定中。因此,最终提案可能看起来非常不同。
同样,这全部由都是由编译器完成的工作。因此,即使提案最终定案,你也只需要知道工具链希望您在代码中添加哪些注释即可 (就像上面的 #[wasm_bindgen]
示例一样)。您实际上并不需要知道所有这些都是如何运行的。
不过提案的细节相当简洁,我们不妨一起深入研究一下。
需要解决的问题
我们需要解决的问题是当模块与另一个模块 (或直接与主机,例如浏览器) 通信时,在不同类型之间转换值。
我们可能需要四个地方进行转换:
对于导出的功能
- 接受来自调用者的参数
- 将值返回给调用者
对于导入功能
- 将参数传递给函数
- 接受函数的返回值
对于这个问题你可以有两种思路进行解决:
升级;将离开模块的值从具体类型 (concrete type) 变为接口类型 (interface type)
降级;将进入模块的值从接口类型 (interface type) 变为具体类型 (concrete type)
告诉引擎如何在具体类型 (concrete type) 和接口类型 (interface type) 之间转换
因此,我们需要一种方法来告诉引擎哪些转换可以应用于函数的参数和返回值。我们如何做到这一点?
通过定义接口适配器 (interface adapter)。
例如,假设我们有一个编译为 WebAssembly 的 Rust 模块。它导出一个 greeting_
函数,这个函数可以在没有任何参数的情况下被调用并返回问候语。
就像这个样子 (WebAssembly 文本格式)。
因此,如果调用此函数将返回两个整数。
但是我们希望它返回 string
接口类型 (interface type)。因此,我们添加了一个称为接口适配器 (interface adapter) 的东西。
如果引擎了解接口类型,则当看到该接口适配器时,它将使用该接口包装原始模块。
它将不再导出该 greeting_
函数…而是包裹了原始函数的 greeting
函数。这个新 greeting
函数返回一个字符串,而不是两个数字。
这提供了一定的向后兼容性,因为不了解接口类型 (interface type) 的引擎只会导出原始的 greeting_
函数 (返回两个整数的函数)。
接口适配器 (interface adapter) 如何告诉引擎将两个整数转换为字符串?
它使用一系列适配器指令。
上面的适配器指令是提案指定的一小组新指令中的两个。
以下是上述指令作用的说明:
- 使用
call-export
适配器指令来调用原始的greeting_
函数。这是原始模块导出的那个函数,返回了两个整数。这些数字被放在堆栈上。 - 使用
memory-to-string
适配器指令将数字转换为组成字符串的字节序列。我们必须在此处指定”内存“,因为一个 WebAssembly 模块可能拥有多个内存。这告诉引擎要查找的内存。然后引擎从栈的顶部获取两个整数 (即指针和长度),并使用它们来确定要使用的字节。
这可能看起来像一个成熟的编程语言。但是这里没有控制流——没有循环或分支。因此,即使我们给出了引擎指令,它仍然是声明性的。
如果我们的函数需要将字符串作为参数 (例如,要问候的人的名字),会是什么样?
非常相似。我们只是更改适配器功能的接口以添加参数。为此,我们添加了两个新的适配器指令。
这些新指令的作用如下:
- 使用
arg.get
指令获取字符串对象的引用,并将其放在堆栈中。 - 使用
string-to-memory
指令从该对象中提取字节并将其放入线性内存中。同样的,我们必须告诉它将字节放入哪个内存。我们还必须告诉它如何分配字节。为此,我们通过给它一个分配器函数 (这里将是原始模块提供的导出函数) 来实现这一点。
使用这样的指令的好处是:我们可以在将来扩展它们……就像我们可以扩展 WebAssembly 核心中的指令一样。我们认为我们所定义的指令是一个很好的集合,但我们并不是说这是唯一的方法。
如果您想更多地了解这一切的工作原理,the explainer 将进行更详细的介绍。
将这些指令发送给引擎
那么我们如何将其发送给引擎?
这些注释将添加到二进制文件的自定义部分中。
如果引擎知道接口类型 (interface type),则可以使用定制部分。如果不是,引擎会忽略它,而你可以使用 polyfill 来读取自定义部分并创建胶水代码。
这与 CORBA,Protocol Buffers 等有什么不同?
还有其他标准,似乎也可以解决相同的问题——例如 CORBA,Protocol Buffers 和 Cap’n Proto。
那些有什么不同?他们正在解决一个更难的问题。
它们都经过精心设计,以便你可以与不共享内存的系统进行交互。不共享内存可能是因为它在不同的进程中运行,也可能是因为它在整个网络中的完全不同的计算机上。
这意味着你必须能够跨越边界发送 IR。
因此,这些标准需要定义可以有效跨越边界的序列化格式。这是他们标准化的重要组成部分。
这看起来像一个类似的问题,它实际上完全不一样。
对于接口类型 (interface type),这个“IR”从来不需要离开引擎。模块本身甚至都不需要感知到它的存在。
这些模块仅需要查看引擎在处理结束时为它们吐出的内容 (将哪些内容复制到其线性内存中或作为指针提供给它们)。因此,我们不必告诉引擎为这些类型提供哪种布局——这不需要指定。
需要指定的是,和引擎沟通的方式。这就是提供给引擎的手册上写的声明性语言。
这有一个很好的副作用:因为这是声明性的,引擎可以看到何时不需要翻译 (例如两个模块使用相同的数据类型),并跳过翻译工作。
今天你怎么尝试这个?
正如我上面提到的,这是一个早期阶段的提案。这意味着事情会被迅速推进,你千万不要在生产环境中使用它。
但是如果你想开始尝试,我们已经在工具链中实现了它,从编译工具到运行时:
- Rust 工具链
- WASM-BindGen
- Wasmtime WebAssembly运行时
由于我们既是这些工具的维护者又是标准的开发者,所以我们可以在标准不断发展的过程中保持与时俱进。
即使部分标准会发生改变,但我们会同步更新所有的工具。因此,只要你的工具链都保持最新状态就不会有问题。
这是您今天你怎么尝试该提案的方法。要获取最新版本,请查看此示例演示库。
Thank you
- 感谢所有这些语言和运行时整合在一起的团队: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 works in Advanced Development at Mozilla, with a focus on Rust and WebAssembly.
译者:Mogeko
原文作者:Lin Clark
原文链接:WebAssembly Interface Types: Interoperate with All the Things!