在网页版 VS Code 中运行 WebAssembly
2023年6月5日,由 Dirk Bäumer 撰写
网页版 VS Code (https://vscode.dev) 已经推出一段时间了,我们的目标一直是在浏览器中支持完整的编辑/编译/调试周期。对于像 JavaScript 和 TypeScript 这样的语言来说,这相对容易,因为浏览器自带了 JavaScript 执行引擎。但对于其他语言来说,这就比较困难了,因为我们必须能够执行(并因此调试)代码。例如,要在浏览器中运行 Python 源代码,就需要一个能够运行 Python 解释器的执行引擎。这些语言运行时通常是用 C/C++ 编写的。
WebAssembly 是一种用于虚拟机的二进制指令格式。如今,现代浏览器都内置了 WebAssembly 虚拟机,并且有工具链可以将 C/C++ 编译成 WebAssembly 代码。为了探究如今 WebAssembly 的可能性,我们决定将一个用 C/C++ 编写的 Python 解释器编译成 WebAssembly,并在网页版 VS Code 中运行它。幸运的是,Python 团队已经开始着手将 CPython 编译成 WASM 的工作,我们很乐意地借鉴了他们的成果。这次探索的结果可以在下面的短视频中看到。
这看起来与在桌面版 VS Code 中执行 Python 代码并没有什么不同。那么,为什么说这很酷呢?
- Python 源代码(
app.py
和hello.py
)托管在一个 GitHub 仓库中,并直接从 GitHub 读取。Python 解释器可以完全访问工作区中的文件,但无法访问任何其他文件。 - 这个示例代码是多文件的。
app.py
依赖于hello.py
。 - 输出结果很好地显示在 VS Code 的终端中。
- 你可以运行一个 Python REPL 并与之完全交互。
- 当然,它是在网页上运行的。
此外,编译成 WebAssembly (WASM) 代码的 Python 解释器无需任何修改即可在网页版 VS Code 中运行。这些二进制文件与 CPython 团队创建的完全相同。
它是如何工作的?
WebAssembly 虚拟机本身不附带 SDK(例如像 Java 或 .NET 那样)。所以,开箱即用的 WebAssembly 代码无法向控制台打印信息或读取文件内容。WebAssembly 规范定义的是 WebAssembly 代码如何调用运行该虚拟机的主机中的函数。对于网页版 VS Code 来说,主机就是浏览器。因此,虚拟机可以调用在浏览器中执行的 JavaScript 函数。
Python 团队提供了两种版本的解释器 WebAssembly 二进制文件:一种是使用 emscripten 编译的,另一种是使用 WASI SDK 编译的。尽管它们都生成 WebAssembly 代码,但在它们作为主机实现提供的 JavaScript 函数方面有不同的特点:
- emscripten - 特别专注于 Web 平台和 Node.js。除了生成 WASM 代码外,它还生成 JavaScript 代码,作为在浏览器或 Node.js 环境中执行 WASM 代码的主机。例如,这些 JavaScript 代码提供了一个函数,可以将 C 语言
printf
语句的内容打印到浏览器的控制台。 - WASI SDK - 将 C/C++ 代码编译为 WASM,并假设有一个符合 WASI 规范 的主机实现。WASI 代表 WebAssembly 系统接口 (WebAssembly System Interface)。它定义了若干类似操作系统的功能,包括文件和文件系统、套接字、时钟和随机数。使用 WASI SDK 编译 C/C++ 代码只会生成 WebAssembly 代码,而不会生成任何 JavaScript 函数。打印 C 语言
printf
语句内容所必需的 JavaScript 函数必须由主机提供。Wasmtime 就是一个例子,它是一个提供 WASI 主机实现的运行时,将 WASI 连接到操作系统调用。
对于 VS Code,我们决定支持 WASI。尽管我们的主要目标是在浏览器中执行 WASM 代码,但我们实际上并不是在一个纯浏览器环境中运行它。我们必须在 VS Code 的扩展宿主工作线程 (extension host worker) 中运行 WebAssembly,因为这是扩展 VS Code 的标准方式。扩展宿主工作线程除了提供浏览器的 worker API 外,还提供了完整的 VS Code 扩展 API。因此,我们实际上想将 C/C++ 程序中的 printf
调用连接到 VS Code 的终端 (Terminal) API,而不是浏览器的控制台。在 WASI 中实现这一点比在 emscripten 中更容易。
我们当前对 VS Code 的 WASI 主机实现基于 WASI 快照 preview1,本博文中描述的所有实现细节都参照该版本。
我如何运行自己的 WebAssembly 代码?
在网页版 VS Code 中成功运行 Python 后,我们很快意识到我们所采用的方法可以执行任何能够编译成 WASI 的代码。因此,本节将演示如何使用 WASI SDK 将一个小型的 C 程序编译成 WASI,并在 VS Code 的扩展宿主中执行它。本示例假设读者熟悉 VS Code 的扩展 API,并知道如何为网页版 VS Code 编写扩展。
我们运行的 C 程序是一个简单的“Hello World”程序,如下所示:
#include <stdio.h>
int main(void)
{
printf("Hello, World\n");
return 0;
}
假设您已安装最新的 WASI SDK 并且它已添加到您的 PATH
环境变量中,可以使用以下命令编译该 C 程序:
clang hello.c -o ./hello.wasm
这会在 hello.c
文件旁边生成一个 hello.wasm
文件。
新功能通过扩展添加到 VS Code 中,我们在将 WebAssembly 集成到 VS Code 时也遵循同样的模式。我们需要定义一个扩展来加载和运行 WASM 代码。该扩展的 package.json
清单文件的关键部分如下:
{
"name": "...",
...,
"extensionDependencies": [
"ms-vscode.wasm-wasi-core"
],
"contributes": {
"commands": [
{
"command": "wasm-c-example.run",
"category": "WASM Example",
"title": "Run C Hello World"
}
]
},
"devDependencies": {
"@types/vscode": "1.77.0",
},
"dependencies": {
"@vscode/wasm-wasi": "0.11.0-next.0"
}
}
ms-vscode.wasm-wasi-core 扩展提供了将 WASI API 连接到 VS Code API 的 WebAssembly 执行引擎。Node 模块 @vscode/wasm-wasi
提供了一个在 VS Code 中加载和运行 WebAssembly 代码的接口。
以下是加载和运行 WebAssembly 代码的实际 TypeScript 代码:
import { Wasm } from '@vscode/wasm-wasi';
import { commands, ExtensionContext, Uri, window, workspace } from 'vscode';
export async function activate(context: ExtensionContext) {
// Load the WASM API
const wasm: Wasm = await Wasm.load();
// Register a command that runs the C example
commands.registerCommand('wasm-wasi-c-example.run', async () => {
// Create a pseudoterminal to provide stdio to the WASM process.
const pty = wasm.createPseudoterminal();
const terminal = window.createTerminal({
name: 'Run C Example',
pty,
isTransient: true
});
terminal.show(true);
try {
// Load the WASM module. It is stored alongside the extension's JS code.
// So we can use VS Code's file system API to load it. Makes it
// independent of whether the code runs in the desktop or the web.
const bits = await workspace.fs.readFile(
Uri.joinPath(context.extensionUri, 'hello.wasm')
);
const module = await WebAssembly.compile(bits);
// Create a WASM process.
const process = await wasm.createProcess('hello', module, { stdio: pty.stdio });
// Run the process and wait for its result.
const result = await process.run();
if (result !== 0) {
await window.showErrorMessage(`Process hello ended with error: ${result}`);
}
} catch (error) {
// Show an error message if something goes wrong.
await window.showErrorMessage(error.message);
}
});
}
下面的视频展示了该扩展在网页版 VS Code 中运行的情况。
我们使用 C/C++ 代码作为 WebAssembly 的源,并且由于 WASI 是一个标准,还有其他工具链也支持 WASI。例如:Rust、.NET 或 Swift。
VS Code 的 WASI 实现
WASI 和 VS Code API 共享一些概念,比如文件系统或标准输入输出(例如,终端)。这使我们能够在 VS Code API 之上实现 WASI 规范。然而,不同的执行行为是一个挑战:WebAssembly 代码的执行是同步的(例如,一旦 WebAssembly 执行开始,JavaScript 工作线程就会被阻塞直到执行完成),而 VS Code 和浏览器的大部分 API 都是异步的。例如,在 WASI 中读取文件是同步的,而相应的 VS Code API 却是异步的。这一特性给在 VS Code 扩展宿主工作线程中执行 WebAssembly 代码带来了两个问题:
- 我们需要防止扩展宿主在执行 WebAssembly 代码时被阻塞,因为这会阻塞其他扩展的执行。
- 需要一种机制来在异步的 VS Code 和浏览器 API 之上实现同步的 WASI API。
第一种情况很容易解决:我们在一个单独的工作线程中运行 WebAssembly 代码。第二种情况更难解决,因为将同步代码映射到异步代码需要暂停同步执行的线程,并在异步计算的结果可用时恢复它。WebAssembly 的 JavaScript-Promise 集成提案在 WASM 层面上解决了这个问题,并且在 V8 中有该提案的实验性实现。然而,当我们开始这项工作时,V8 的实现尚未可用。因此我们选择了另一种实现方式,它使用 SharedArrayBuffer 和 Atomics 来将同步的 WASI API 映射到 VS Code 的异步 API。
该方法的工作原理如下:
- WASM 工作线程创建一个
SharedArrayBuffer
,其中包含有关应在 VS Code 端调用的代码的必要信息。 - 它将共享内存发布到 VS Code 的扩展宿主工作线程,然后使用 Atomics.wait 等待扩展宿主工作线程完成其工作。
- 扩展宿主工作线程接收消息,调用相应的 VS Code API,将结果写回
SharedArrayBuffer
,然后使用 Atomics.store 和 Atomics.notify 通知 WASM 工作线程唤醒。 - 然后,WASM 工作线程从
SharedArrayBuffer
中读取任何结果数据,并将其返回给 WASI 回调。
这种方法唯一的困难在于 SharedArrayBuffer
和 Atomics
要求网站是跨域隔离的 (cross-origin isolated),这本身可能就是一项艰巨的任务,因为 CORS 具有很强的传播性。这就是为什么它目前只在 Insiders 版本 insiders.vscode.dev 上默认启用,而在 vscode.dev 上必须使用查询参数 ?vscode-coi=on
来启用。
下图更详细地展示了 WASM 工作线程和扩展宿主工作线程之间,就我们上面编译到 WebAssembly 的 C 程序而言的交互过程。橙色框中的代码是 WebAssembly 代码,所有绿色框中的代码都在 JavaScript 中运行。黄色框代表 SharedArrayBuffer
。
一个网页 shell
既然我们能够将 C/C++ 和 Rust 代码编译成 WebAssembly 并在 VS Code 中执行,我们便探索了是否也可以在网页版 VS Code 中运行一个 shell。
我们研究了将一个 Unix shell 编译成 WebAssembly 的可能性。然而,一些 shell 依赖于操作系统特性(如生成进程等),而这些特性目前在 WASI 中尚不可用。这促使我们采取了一种略有不同的方法:我们用 TypeScript 实现了一个基本的 shell,并尝试只将像 ls
、cat
、date
等 Unix 核心工具编译成 WebAssembly。由于 Rust 对 WASM 和 WASI 有非常好的支持,我们尝试了 uutils/coreutils,这是一个用 Rust 编写的 GNU coreutils 的跨平台重实现。瞧,我们有了一个最初的最小化网页 shell。
如果你不能执行自定义的 WebAssembly 或命令,那么 shell 的功能就非常有限。为了扩展网页 shell,其他扩展可以为文件系统贡献额外的挂载点,以及在网页 shell 中输入时被调用的命令。通过命令的间接调用,将具体的 WebAssembly 执行与在终端中输入的内容解耦。从一开始就在 Python 扩展中使用这种支持,允许你通过在提示符中输入 python app.py
直接从 shell 内部执行 Python 代码,或者列出通常挂载在 /usr/local/lib/python3.11
下的默认 python 3.11 库。
接下来会发生什么?
WASM 执行引擎扩展和 Web Shell 扩展目前都处于实验性预览阶段,不应用于实现使用 WebAssembly 的生产就绪扩展。它们的公开发布是为了获得对该技术的早期反馈。如果您有任何问题或反馈,请在相应的 vscode-wasm GitHub 仓库中提出问题。该仓库还包含 Python 示例以及 WASM 执行引擎和 Web Shell 的源代码。
我们确定将进一步探索以下主题:
- WASI 团队正在开发规范的 preview2 和 preview3,我们也计划支持。新版本将改变 WASI 主机的实现方式。但是,我们有信心可以保持我们在 WASM 执行引擎扩展中暴露的 API 基本稳定。
- 还有 WASIX 项目,它通过额外的类似操作系统的功能(如进程或 futex)来扩展 WASI。我们将继续关注这项工作。
- 许多 VS Code 的语言服务器是用 JavaScript 或 TypeScript 以外的语言实现的。我们计划探索将这些语言服务器编译为
wasm32-wasi
并在网页版 VS Code 中运行的可能性。 - 改进网页版 Python 的调试功能。我们已经开始着手这项工作,敬请期待。
- 增加支持,以便扩展 B 可以运行由扩展 A 提供的 WebAssembly 代码。例如,这将允许任意扩展通过重用提供了 Python WebAssembly 的扩展来执行 Python 代码。
- 确保其他为
wasm32-wasi
编译的语言运行时能够在 VS Code 的 WebAssembly 执行引擎之上运行。VMware Labs 提供了 Ruby 和 PHP 的wasm32-wasi
二进制文件,并且两者都可以在 VS Code 中运行。
谢谢,
Dirk 和 VS Code 团队
编码愉快!