该提案是与@fmccabe 、 @thibaudmichaud 、 @lukewagner和@kripken合作
该提案的目的是在 JavaScript promises 和 WebAssembly 之间提供相对高效且相对符合人体工程学的互操作,但在唯一的变化是对 JS API 而不是核心 wasm 的约束下工作。
期望堆栈切换提案最终将扩展核心 WebAssembly 的功能,以直接在 WebAssembly 中实现我们在本提案中提供的操作,以及许多其他有价值的堆栈切换操作,但是这个堆栈切换的特殊用例具有仅通过 JS API 就有足够的紧迫性来获得更快的路径。
有关更多信息,请参阅2021 年 6 月 28 日 Stack Subgroup Meeting的笔记和幻灯片,其中详细介绍了我们考虑的使用场景和因素,并总结了我们如何得出以下设计的基本原理。
更新:根据堆栈子组从 TC39 收到的反馈,该提案只允许暂停 WebAssembly 堆栈——它没有对 JavaScript 语言进行任何更改,特别是没有间接启用对分离的asycn
/ await
在 JavaScript 中。
这(松散地)取决于js-types提案,它引入了WebAssembly.Function
作为Function
的子类。
提议是将以下接口、构造函数和方法添加到 JS API,并在下面详细介绍它们的语义。
interface Suspender {
constructor();
Function suspendOnReturnedPromise(Function func); // import wrapper
// overloaded: WebAssembly.Function suspendOnReturnedPromise(WebAssembly.Function func);
WebAssembly.Function returnPromiseOnSuspend(WebAssembly.Function func); // export wrapper
}
以下是我们希望人们如何使用此 API 的示例。
在我们的使用场景中,我们发现考虑 WebAssembly 模块在概念上具有“同步”和“异步”导入和导出是很有用的。
当前的 JS API 仅支持“同步”导入和导出。
Suspender 接口的方法用于包装相关的导入和导出以实现“异步”,而 Suspender 对象本身显式地将这些导入和导出连接在一起以促进实现和可组合性。
WebAssembly ( demo.wasm
):
(module
(import "js" "init_state" (func $init_state (result f64)))
(import "js" "compute_delta" (func $compute_delta (result f64)))
(global $state f64)
(func $init (global.set $state (call $init_state)))
(start $init)
(func $get_state (export "get_state") (result f64) (global.get $state))
(func $update_state (export "update_state") (result f64)
(global.set (f64.add (global.get $state) (call $compute_delta)))
(global.get $state)
)
)
文本( data.txt
):
19827.987
JavaScript:
var suspender = new Suspender();
var init_state = () => 2.71;
var compute_delta = () => fetch('data.txt').then(res => res.text()).then(txt => parseFloat(txt));
var importObj = {js: {
init_state: init_state,
compute_delta: suspender.suspendOnReturnedPromise(compute_delta)
}};
fetch('demo.wasm').then(response =>
response.arrayBuffer()
).then(buffer =>
WebAssembly.instantiate(buffer, importObj)
).then(({module, instance}) => {
var get_state = instance.exports.get_state;
var update_state = suspender.returnPromiseOnSuspend(instance.exports.update_state);
...
});
在这个例子中,我们有一个 WebAssembly 模块,它是一个非常简单的状态机——每次更新状态时,它只是调用一个导入来计算要添加到状态的增量。
然而,在 JavaScript 方面,我们想要用于计算 delta 的函数需要异步运行; 也就是说,它返回一个数字的承诺而不是数字本身。
我们可以通过使用新的 JS API 来弥合这种同步差距。
在示例中,WebAssembly 模块的导入使用suspender.suspendOnReturnedPromise
包装,导出使用suspender.returnPromiseOnSuspend
包装,两者都使用相同的suspender
。
suspender
将两者连接在一起。
它使得,如果(未包装的)导入返回一个 Promise,(包装的)导出返回一个 Promise,中间的所有计算都被“暂停”,直到导入的 Promise 解决。
导出的包装本质上是添加一个async
标记,而导入的包装本质上是添加一个await
标记,但与 JavaScript 不同的是,我们不必显式线程化async
/ await
贯穿所有中间 WebAssembly 函数!
同时,初始化时对init_state
调用必然不挂起返回,而对export get_state
调用也始终不挂起返回,所以提案仍然支持现有的“同步”导入和导出WebAssembly 生态系统今天使用。
当然,有很多细节被略过,例如如果同步导出调用异步导入,那么如果导入尝试挂起,程序就会陷入困境。
下面提供了更详细的规范以及一些实现策略。
Suspender
处于以下状态之一:
caller
] - 控件位于Suspender
, caller
是调用Suspender
并期待externref
的函数方法suspender.returnPromiseOnSuspend(func)
断言func
是具有WebAssembly.Function
形式的函数类型的[ti*] -> [to]
,然后返回具有函数类型的WebAssembly.Function
[ti*] -> [externref]
在使用参数args
调用时执行以下操作:
suspender
的状态不是Inactive则陷阱suspender
的状态更改为Active [ caller
](其中caller
是当前调用者)result
成为调用func(args)
(或任何陷阱或抛出的异常)的结果suspender
的状态是Active [ caller'
] 一些caller'
(应该保证,虽然调用者可能已经改变)suspender
的状态更改为Inactiveresult
到caller'
方法suspender.suspendOnReturnedPromise(func)
func
是WebAssembly.Function
,则断言其函数类型为[t*] -> [externref]
并返回WebAssembly.Function
函数类型[t*] -> [externref]
;func
是Function
并返回Function
。在任何一种情况下, suspender.suspendOnReturnedPromise(func)
返回的函数在使用参数args
调用时都会执行以下操作:
result
成为调用func(args)
(或任何陷阱或抛出的异常)的结果result
不是返回的 Promise,则返回(或重新抛出) result
suspender
的状态不是Active [ caller
] 一些caller
陷阱frames
成为自caller
以来的堆栈帧frames
存在任何不可挂起函数的帧,则进行陷阱suspender
的状态更改为SuspendedonFulfilled
和onRejected
返回result.then(onFulfilled, onRejected)
的结果:suspender
的状态是Suspended (应该保证)suspender
的状态更改为Active [ caller'
],其中caller'
是onFulfilled
/ onRejected
的调用者onFulfilled
的情况下,将给定值转换为externref
并将其返回给frames
onRejected
的情况下,根据异常处理提案的 JS API 将给定值抛出高达frames
作为异常一个函数是可挂起的,如果它是
suspendOnReturnedPromise
,returnPromiseOnSuspend
,重要的是,JavaScript 编写的函数不可挂起,符合TC39成员的反馈,宿主函数(除了上面列出的少数)不可挂起,符合引擎维护者的反馈。
以下是该提案的实施策略。
它假设引擎支持堆栈切换,这当然是主要实现挑战所在。
有两种堆栈:主机(和 JavaScript)堆栈和 WebAssembly 堆栈。 每个 WebAssembly 堆栈都有一个名为suspender
的 suspender 字段。 每个线程都有一个主机栈。
每个Suspender
都有两个堆栈引用字段:一个称为caller
,另一个称为suspended
。
caller
字段引用调用者的(挂起)栈, suspended
字段为空suspended
字段引用当前与 suspender 关联的(暂停的)WebAssembly 堆栈, caller
字段为空。suspender.returnPromiseOnSuspend(func)(args)
是由
suspender.caller
和suspended.suspended
是否为空(否则捕获)stack
成为与suspender
关联的新分配的 WebAssembly 堆栈stack
并将之前的堆栈存储在suspender.caller
result
成为func(args)
(或任何陷阱或抛出的异常)的结果suspender.caller
并将其设置为 nullstack
result
suspender.suspendOnReturnedPromise(func)(args)
由
func(args)
,捕获任何陷阱或抛出的异常result
不是返回的 Promise,则返回(或重新抛出) result
suspender.caller
是否不为空(否则捕获)stack
成为当前堆栈stack
不是与suspender
关联的 WebAssembly 堆栈:stack
是否是一个 WebAssembly 堆栈(否则捕获)stack
更新stack.suspender.caller
suspender.caller
,将其设置为空,并将之前的堆栈存储在suspender.suspended
onFulfilled
和onRejected
返回result.then(onFulfilled, onRejected)
的结果suspender.suspended
,将其设置为 null,并将之前的堆栈存储在suspender.caller
onFulfilled
的情况下,将给定值转换为externref
并返回它onRejected
的情况下,重新抛出给定的值通过为可挂起函数创建宿主函数而生成的函数的实现更改为首先切换到当前线程的宿主堆栈(如果尚未在其上),最后切换回之前的堆栈。
是否可以公开接收异步函数/生成器(同步或异步)的 API,然后将其转换为可挂起的函数?
您能否通过一些伪代码或用例来澄清您的意思? 我想确保我给你一个准确的答案。
Suspender
的意图是成为 JS 的一部分还是一个单独的 API? 它是否专门用于 wasm ( WebAssembly.Suspender
)? 在我看来,这个提议应该在 TC39 中讨论。
它特别不打算影响 JS 程序。 更准确地说,试图挂起一个 JS 函数会导致一个陷阱。 为了确保这一点,我们遇到了一些麻烦。
不过,我可以向书宇提出来征求他的意见。
抱歉, @chicoxyzzy ,我发现我忘记包含 Stacks 子组中的一些上下文/更新。 较早的堆栈切换提案是为了您应该能够在挂起的堆栈中捕获 JavaScript/主机帧而编写的。 但是,我们收到了 TC39 人员的反馈,担心这会严重影响 JS 生态系统,并且我们收到主机实现者的反馈,担心并非所有主机框架都能够容忍暂停。 因此,堆栈子组此后一直确保设计仅捕获挂起堆栈中的 WebAssembly(相关)帧,并且该提案满足该属性。 我更新了 OP 以包含此重要说明。
很高兴看到这里的进步。 是否有任何示例说明如何在 Wasm 的 ESM 集成中使用它?
坏消息是,因为这一切都在 JS API 中,您不能简单地导入 ESM wasm 模块并获得对 promise 的这种堆栈切换支持。 好消息是你仍然可以使用带有这个 API 的 ESM 模块,只是用一些 JS ESM 模块作为粘合剂。
特别是,您设置了三个 ESM 模块: foo-exports.js
、 foo-wasm.wasm
和foo-imports.js
。 foo-imports.js
模块创建 suspender,使用它来包装foo-wasm.wasm
所需的所有“异步”产生承诺的导入,并导出 suspender 和那些导入。 foo-wasm.wasm
然后从foo-imports.js
导入所有“异步”导入以及直接从它们各自的模块中导入所有“同步”导入(当然,您也可以通过foo-imports.js
代理它们) foo-exports.js
从foo-imports.js
foo-exports.js
导入 suspender ,导入foo-wasm.wasm
导出,使用 suspender 包装“异步”导出,然后导出(未包装的)“同步”出口和包装的“异步”出口。 客户端然后从foo-exports.js
导入并且永远不会直接接触(或需要知道) foo-wasm.wasm
或foo-imports.js
。
这是一个不幸的障碍,但考虑到不修改核心 wasm 的限制,这是我们可以实现的最好的障碍。 不过,我们的目标是确保这种设计与扩展核心 wasm 的提案向前兼容,这样,当提案发布时,您可以将这三个模块换成一个扩展的 wasm 模块,并且没有人可以在语义上说出区别(模文件重命名)。
这是可以理解的,你认为它会满足你的需求吗(虽然很笨拙)?
我理解包装的必要性,至少在 WebAssembly.Module 类型 Wasm 导入尚不可能的时候(希望它们会在适当的时候)。
更具体地说,我想知道在 ESM 集成中是否有装饰这些模式的空间,以便可以更好地管理吊带胶水的两侧。 例如,如果有一些元数据以二进制格式链接导出和导入的函数,ESM 集成可以询问它并根据某些可预测的规则在内部匹配双重导入/导出包装吊钩函数作为集成层的一部分。
啊。 目前,还没有这样的计划。 我收到的反馈是,也不想改变 ESM 集成。 简而言之,希望最终所有这些都可以在核心 wasm 中实现,因此我们希望该提案留下尽可能小的足迹。
我收到的反馈是希望不要改变 ESM 集成
你能详细说明这些反馈来自哪里吗? 使用更高级别的集成语义来扩展 ESM 集成的空间很大,我觉得这个空间没有得到充分探索,因此我提出了它。 我过去没有听说过对改进这个领域的阻力。 将其视为一个加糖的领域对 JS 开发人员来说是一个好处,允许直接 Promise 导入/导出。
值得注意的是,这个提议确实阻碍了一个循环中的单个 JS 模块作为 Wasm 模块的导入者和被导入者的能力,由于在 ESM 集成中 JS 循环函数提升,Wasm 模块目前仍然可以用于函数导入,但不支持在导入函数周围使用 Suspender 表达式包装器进行此循环提升。
我从@lukewagner 那里得到了这个印象。 我同意有扩展 ESM 集成的空间,但我的理解是这需要对 wasm 文件进行更改/扩展——我们试图避免(作为小规模目标的一部分)——所以我们不想要这样的更改/扩展成为本提案的一部分。 当然,如果将此类更改/扩展添加到 ESM 提案中,那么这些更改/扩展将理想地补充该提案,这样人们就不需要 JS 包装器模块来获得该提案提供的功能。
我误读了@Jack-Works 的评论,在上面调整了我的评论。
感谢@RossTate的澄清,是的,我建议探索通过二进制文件本身的元数据匹配这些导入和导出暂停上下文的可能性,以通知主机集成,但无论如何都不要指望在 MVP 中。 我也只是借此机会指出 ESM 集成是一个可以更广泛地从糖中受益的空间,与基本 JS API 分开。
明确地说,我指出的挑战是我们添加到WebAssembly.instantiate()
(或带有新参数的WebAssembly.instantiate()
新版本)的任何选项也必须在通过 ESM 加载 wasm 时以某种方式显示-integration,而不是 ESM-integration 是不可变的。
啊,太酷了,所以我们在 ESM 方面比我意识到的有更多的灵活性,如果需要的话。 谢谢纠正我的误解。
听起来我们在谈论某种自定义部分来指定某些导出的 Wasm 函数应该如何作为基于 Promise 的 API 显示给 JS,或者反过来说,从 Wasm 导入的如何从基于 JS Promise 的 API 转换为某种类型堆栈切换。 我理解正确吗?
我喜欢这个主意。 我怀疑我们会发现自己需要一个类似的用于 Wasm GC/JS-ESM 集成的自定义部分(或同一部分的一部分)。 我不确定这个自定义部分在多大程度上可能是跨语言的,但在这两种情况下,它可能比接口类型的通用性稍差,并且还倾向于在组件
有没有人想写一些要点或自述文件来描述这个自定义部分的基本设计?
听起来这是一个可能的选择。 正如您提到的,GC 提案中已经讨论了类似的选项,例如在 WebAssembly/gc#203 中。 JS 集成暂定于明天在 GC 小组中讨论,因此在讨论期间牢记与此提案的可能联系可能会很好(或者它可能被证明是无关的,这取决于讨论的进展情况)。