我现在已经花了大约一年的时间使用 WebAssembly。就像我第一次接触 TypeScript 时的笔记一样,这里有一些关于 WebAssembly 的“高级初学者”知识,特别强调浏览器和 C++。
如果你是那种喜欢戳例子的人,我已经制作了一个名为“weave”的 WebAssembly 文件查看器,它可以让你交互式地探索
.wasm
文件的内容。你可以在这里玩一些演示文件。特别要注意,您可以单击“代码”部分,然后从那里进入函数体,甚至可以从那里标记其他未标记的参数/局部变量以帮助阅读代码。
吸引我在 Figma 工作的一件事是有机会学习和使用 WebAssembly。事实证明,Figma 是一个非常好的环境。 Figma 是“网络原生”的组合——许多应用程序是用普通的 React/TypeScript 堆栈编写的——同时还使用了数量惊人的低级 C++ 代码。在您的浏览器中编辑的 Figma 文档是使用由 C++ 场景图管理的 GPU 着色器渲染的,如果您考虑过缩放的平滑程度,您可能只会注意到这一点!这篇文章稍微深入了一点。
一段介绍。 WebAssembly(“Wasm”)是一种指令格式 + 虚拟机,现在是浏览器原生的。它旨在允许在 Web 上安全地运行来自 JavaScript 以外的其他语言的代码,但它在其他地方的应用范围越来越广。在抽象意义上,它与Native Client 、JVM 或 CLR 或多或少是相同的想法,尽管当然在许多方面也有所不同。 Wasm 在浏览器之外还有一些有趣的应用,但我将把它们放在本文的范围之外。
它是如何工作的?有序列化格式和字节码等。但另一种看待方式是它的运行方式与 JS 的执行方式非常相似:从操作系统沙盒化,通常使用优化编译器预处理您发布的代码。从这个角度来看,与 JS 相比,它的反序列化速度更快,更容易优化。与 JS 的另一个大区别是没有环境标准库或浏览器 API。
规格。 Wasm 规范是一个真正令人愉悦、简洁和具体的规范。它温暖了我的 PL 爱好者的心。在编写像 weave 这样的工具时,我发现这种格式非常适合使用。
简单的架构。 Wasm 机器(我认为?)比同类系统简单得多。它是一个堆栈机器,没有寄存器;指令通常推送和弹出 32/64 位整数并浮动到该堆栈上。代码是一个函数数组,可以通过数组索引相互调用。托管环境也可以在该数组中注册函数,以调用 Wasm 代码。内存是由 32 位地址索引的平面字节数组。内存、调用堆栈和代码都是相互独立的—— “哈佛架构” 。
除此之外,没有明确的对象、垃圾收集、指针、字符串、数组、模块等概念;您的 Wasm 函数可以相互调用、进行数学运算、读写内存,仅此而已。
文本格式。该规范定义了一种与字节码 1:1 的文本格式,使用 s 表达式呈现。 Godbolt(又名“编译器资源管理器”)还允许您查看 Clang 生成的 Wasm 输出;这是一个简单的“添加”功能。不幸的是,输出不是文本格式,它只是我认为由 Clang 组成的东西。但是,如果您已经熟悉 Godbolt,那么您可能对此不会有太大的问题。 (上面链接的我的 Wasm 查看器 Weave 也以非标准方式呈现指令,因为它专注于原始文件内容的文本呈现。)
性能。 WebAssembly 神奇的性能是仙尘吗? (简短回答:否)是对性能问题的一个很好、详细的介绍。回答同一问题的更高级别方法是,在性能工作中,通常架构更改比优化循环更重要。话虽如此,Wasm 为您提供了相当低级的控制,因此相对于 JS,它非常适合运行您关心内存布局效率等问题的代码,并且它是其他语言的更容易编译目标。
指针。 Wasm 内存目前限制为 4gb,这意味着指针只有 4 个字节。同时,64 位平台上的 v8 使用 64 位指针,这意味着它们必须竭尽全力不让 8 字节指针吃掉所有内存。 (我发现这篇博文“句柄是更好的指针”确实改变了我在这方面的看法,值得你花时间。相关地,在我的 n2 实验中,我意识到通过一次限制为“仅”40 亿个文件,我会通过将所有图形句柄切换为 32 位,将其大小减半。)
一个有趣的旁注:Wasm 被限制为 4gb,但给定的文件可以声明一个下限,这意味着某些代码必须代表“当前内存限制”的数字。这个数字实际上需要一个 64 位整数来表示,因为一个无符号的 32 位整数只上升到 2**32-1,比最大可能值小一! Chrome 犯了这个错误并且在这方面存在一些错误,这意味着在 Figma 目前使用 4gb 限制的实验中,我们实际上限制为 4gb 减去一页以避免这些错误。
安全。 Wasm 是沙盒的,但您可以在其上运行 C++。这怎么能行?该规范有一整节关于健全性的内容是在 Wasm 语义中建立内存安全性,但这实际上并没有回答您可能遇到的安全问题。
这是现在对我来说很明显的事情,但我认为当我开始时并不明显。 C++ 有自己的栈和堆概念,在 C++ 中,您可以获取栈上某物的地址。这通过存在于 Wasm 可寻址内存中的 C++ 堆栈变量转换为 Wasm,就像 C++ 堆栈存在于内存中一样。该内存完全在 C++ 代码的控制之下。同时,包括调用堆栈在内的 Wasm 运行时结构是独立的,对 C++ 来说实际上是不可见的。总之,缓冲区溢出之类的 C++ 内存错误仍然会影响 C++ 内存,但该内存不同于任何 Wasm 结构(例如调用堆栈),因此损坏的 Wasm 程序所能做的就是弄乱自己的内存,而不是主机的内存。
转义 JavaScript。 Wasm 是否意味着您现在可以编写自己喜欢的语言而不是 JavaScript,或者在浏览器中运行您喜欢的程序?有点,但不是真的。确实可以将东西交叉编译到 Wasm,但无论如何已经可以将许多东西交叉编译到 JS。换句话说,Wasm 让事情变得更高效,但它没有暴露任何新的语义后果或行为,除了事情变得更快的普通后果。
无论您以 JS 还是 Wasm 为目标,浏览器中的 not-JS 不经常运行的原因大致相同,我喜欢称之为Probst 悖论的现象:软件和编程语言开始时不是为了在 Web 上运行而编写的with 将对环境有自己的假设,使它们在网络上运行不佳。对于许多语言,假设大型语言运行时或库或垃圾收集器是免费可用的,而对于许多程序,假设是存在诸如“文件”或“屏幕”或“标准输出”之类的东西。当然,几乎任何东西都可以通过足够的锤击来工作。
(当人们将糟糕的 web 应用程序移植到手机时,你会看到类似的动态。你可以在手机上使用 web 技术制作好的应用程序——例如,Libby 真的很棒!但如果你想重用你投入的工作通过在手机上运行一些旧的 web 应用程序,通常的结果不是很好,因为 web 应用程序不是用手机编写的。)
令我感兴趣的是 C(与 Go 或 Python 等不同)最终如何成为在这方面针对 Wasm 的更好语言之一,因为 C 是为在没有太多运行时的地方运行而设计的(就像 Rust 中的no_std
一样)。但请注意,即使要在 Wasm 下运行 C,您通常也需要最终在 Wasm 包中交付 malloc 的实现。
托管环境。在浏览器中运行时,Wasm 内存只是一个ArrayBuffer 。在 Wasm 中,代码可以随意在该内存上随意涂鸦。要调用环境(例如浏览器),它可以调用暴露给它的函数,但 Wasm 知道的唯一类型是数字。
这意味着要传递一个字符串或结构,传递一个地址,然后从 JS 端编写let x = memory[address];
从内存中读取字节。 (这甚至不是伪代码,请参阅Memory.buffer !)有很多方法可以将它们绑定在一起,但它们都涉及成本和频繁复制。例如,要将 Wasm 字符串转换为 JS String
对象(调用任何涉及字符串的浏览器 API 必须执行此操作),您必须通过TextDecoder
或其他等效方法传递内存数组字节的切片。这不是免费的。
总而言之,理论上你可以通过任何语言让 JS 调用 Wasm 调用 JS 和线程调用堆栈,但两者之间的界限使它比你可能想要的更昂贵。并且 Wasm 不会让你神奇地混合两种非 JS 语言,就像你已经能够在 Wasm 之外混合它们一样。
这里我要强调的是,浏览器中的 Wasm 是 Wasm 与 JS 在同一个线程中同步运行,而不是作为单独的 worker 运行。当星星对齐以便您的开发工具工作时,这意味着例如堆栈跟踪可以通过同一堆栈中的 JS 和 Wasm 帧向后跟踪。 (当然也可以在 worker 中运行 Wasm。)
能崩溃吗?一般来说,Wasm 代码可以自由地对自己的内存做任何事情,因此语言级别的问题——甚至包括“取消引用空指针”——都是合法的。写入空指针只是写入偏移量为零的内存数组,与任何其他写入一样合法。
但是 Wasm 中的错误代码仍然会在运行时崩溃!在规范中,他们称之为陷阱。对于陷阱的一个简单示例,想象一下访问memory[n]
的代码比您的内存大小大一些。您可以看到这里指定的一些陷阱。
然而,Wasm 在加载时有一个“验证”通过,它会验证许多属性,例如“所有直接函数调用都引用有效的函数索引”,因此许多此类潜在的无效行为在加载时被屏蔽掉。这种验证是 Wasm 可以在运行时高效执行同时仍然安全的部分原因。再举一个例子,Wasm 中的跳转以一种有趣的方式构造,使得它们总是引用块边界。
在 Figma,我们以一种微妙的方式遇到了与 C++ 虚拟调用相关的陷阱,但关于 C++ 和 Wasm,我还有很多话要说,所以我计划在另一篇文章中讨论所有这些。
原文: http://neugierig.org/software/blog/2022/06/wasm-notes.html