在VS Code网页版中运行WebAssemblies

2023年6月5日,作者:Dirk Bäumer

VS Code for the Web (https://vscode.dev) 已经上线一段时间了,我们的目标一直是支持在浏览器中完成完整的编辑/编译/调试周期。对于像 JavaScript 和 TypeScript 这样的语言来说,这相对容易,因为浏览器自带 JavaScript 执行引擎。但对于其他语言来说,这更加困难,因为我们必须能够执行(并因此调试)代码。例如,要在浏览器中运行 Python 源代码,需要一个能够运行 Python 解释器的执行引擎。这些语言运行时通常是用 C/C++ 编写的。

WebAssembly 是一种用于虚拟机的二进制指令格式。现代浏览器中已经内置了WebAssembly虚拟机,并且有工具链可以将C/C++编译为WebAssembly代码。为了了解目前WebAssembly的潜力,我们决定将一个用C/C++编写的Python解释器编译为WebAssembly,并在VS Code for the Web中运行它。幸运的是,Python团队已经开始着手将CPython编译为WASM,我们很高兴地利用了他们的成果。探索的结果可以在下面的短视频中看到:

在VS Code网页版中执行Python文件

它看起来与在VS Code桌面版中执行Python代码并没有太大不同。那么,为什么这很酷呢?

  • Python源代码(app.pyhello.py)托管在GitHub仓库中,并直接从GitHub读取。Python解释器可以完全访问工作区中的文件,但不能访问任何其他文件。
  • 示例代码是多文件的。app.py 依赖于 hello.py
  • 输出在VS Code的终端中显示得很好。
  • 你可以运行一个Python REPL并完全与之交互。
  • 当然,它运行在网络上。

此外,编译为WebAssembly(WASM)代码的Python解释器无需修改即可在VS Code for the Web中运行。这些代码与CPython团队创建的代码完全相同。

它是如何工作的?

WebAssembly虚拟机不附带SDK(例如,Java.NET)。因此,开箱即用的WebAssembly代码无法打印到控制台或读取文件内容。WebAssembly规范定义的是WebAssembly代码如何调用运行虚拟机的主机中的函数。在VS Code for Web的情况下,主机是浏览器。因此,虚拟机可以调用在浏览器中执行的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系统接口。它定义了几种类操作系统的功能,包括文件和文件系统、套接字、时钟和随机数。使用WASI SDK编译C/C++代码只会生成WebAssembly代码,但不会生成任何JavaScript函数。打印C语言printf语句内容所需的JavaScript函数必须由主机提供。例如,Wasmtime是一个运行时,它提供了一个WASI主机实现,将WASI连接到操作系统调用。

对于VS Code,我们决定支持WASI。尽管我们的主要重点是在浏览器中执行WASM代码,但我们实际上并不是在纯浏览器环境中运行它。我们必须在VS Code的扩展主机工作线程中运行WebAssemblies,因为这是扩展VS Code的标准方式。扩展主机工作线程除了提供浏览器的worker API外,还提供了整个VS Code扩展API。因此,我们不是将C/C++程序中的printf调用连接到浏览器的控制台,而是希望将其连接到VS Code的终端 API。在WASI中这样做对我们来说比在emscripten中更容易。

我们当前实现的VS Code的WASI主机基于WASI快照预览1,本博客文章中描述的所有实现细节均指该版本。

如何运行我自己的WebAssembly代码?

在我们在VS Code for the Web中运行Python之后,我们很快意识到我们采取的方法允许我们执行任何可以编译为WASI的代码。因此,本节演示了如何使用WASI SDK将一个小型C程序编译为WASI,并在VS Code的扩展主机中执行它。该示例假设读者熟悉VS Code的扩展API,并且知道如何为VS Code for the Web编写扩展。

我们运行的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中,我们在将WebAssemblies集成到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 执行引擎。节点模块 @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网页版中运行的扩展。

运行Hello World

我们使用C/C++代码作为WebAssembly的源,并且因为WASI是一个标准,所以还有其他支持WASI的工具链。例如:Rust.NETSwift

VS Code 的 WASI 实现

WASI 和 VS Code API 共享诸如文件系统或 stdio(例如,终端)等概念。这使我们能够在 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的实现还不可用。因此,我们选择了一种不同的实现,它使用SharedArrayBufferAtomics将同步的WASI API映射到VS Code的异步API上。

该方法的工作原理如下:

  • WASM 工作线程创建一个 SharedArrayBuffer,其中包含有关应在 VS Code 端调用的代码的必要信息。
  • 它将共享内存发布到VS Code的扩展主机工作线程,然后使用Atomics.wait等待扩展主机工作线程完成其工作。
  • 扩展主机工作线程接收消息,调用适当的VS Code API,将结果写回SharedArrayBuffer,然后使用Atomics.storeAtomics.notify通知WASM工作线程唤醒。
  • 然后,WASM 工作线程从 SharedArrayBuffer 中读取任何结果数据,并将其返回到 WASI 回调中。

这种方法唯一的困难是SharedArrayBufferAtomics要求站点必须是跨源隔离的,由于CORS非常具有传染性,这本身可能是一项艰巨的任务。这就是为什么目前仅在Insiders版本insiders.vscode.dev上默认启用,并且必须在vscode.dev上使用查询参数?vscode-coi=on来启用。

下图详细展示了WASM工作器与扩展主机工作器之间的交互,针对我们编译为WebAssembly的上述C程序。橙色框中的代码是WebAssembly代码,绿色框中的所有代码在JavaScript中运行。黄色框代表SharedArrayBuffer

WASM工作器与扩展主机之间的交互

一个网页壳

既然我们已经能够将C/C++和Rust代码编译为WebAssembly并在VS Code中执行,我们探索是否也可以在VS Code for the Web中运行一个shell。

我们研究了将Unix shell之一编译为WebAssembly。然而,一些shell依赖于操作系统功能(如生成进程等),这些功能目前在WASI中尚不可用。这促使我们采取了一种略有不同的方法:我们在TypeScript中实现了一个基本的shell,并尝试仅将Unix核心工具如lscatdate等编译为WebAssembly。由于Rust对WASM和WASI有很好的支持,我们尝试了uutils/coreutils,这是一个用Rust重新实现的跨平台GNU coreutils。瞧,我们有了第一个最小的web shell。

一个网页外壳

如果你不能执行自定义的WebAssemblies或命令,那么shell的功能将非常有限。为了扩展web shell,其他扩展可以为文件系统贡献额外的挂载点,以及当它们在web shell中输入时调用的命令。通过命令的间接性,将具体的WebAssembly执行与终端中输入的内容解耦。从一开始就在Python扩展中使用这种支持,允许你通过在提示符中输入python app.py直接从shell中执行Python代码,或者列出默认的python 3.11库,该库通常挂载在/usr/local/lib/python3.11下。

Python集成到网页壳

接下来是什么?

WASM 执行引擎扩展和 Web Shell 扩展目前均为实验性预览版,不应用于使用 WebAssemblies 实现生产就绪的扩展。它们已公开发布,以便获取对该技术的早期反馈。如果您有任何问题或反馈,请在相应的 vscode-wasm GitHub 仓库中提交问题。该仓库还包含 Python 示例WASM 执行引擎 以及 Web Shell 的源代码。

我们所知道的是,我们将进一步探讨以下主题:

  • WASI 团队正在开发规范的预览版2和预览版3,我们也计划支持这些新版本。新版本将改变WASI主机的实现方式。然而,我们有信心能够保持我们在WASM执行引擎扩展中暴露的API大部分稳定。
  • 还有一个WASIX项目,它通过添加类似操作系统的功能(如进程或futex)来扩展WASI。我们将继续关注这项工作。
  • 许多用于VS Code的语言服务器是用不同于JavaScript或TypeScript的语言实现的。我们计划探索将这些语言服务器编译为wasm32-wasi并在Web版的VS Code中运行的可能性。
  • 改进Web上的Python调试。我们已经开始着手这项工作,敬请期待。
  • 添加支持,以便扩展B可以运行由扩展A贡献的WebAssembly代码。例如,这将允许任意扩展通过重用贡献了Python WebAssembly的扩展来执行Python代码。
  • 确保为wasm32-wasi编译的其他语言运行时能够在VS Code的WebAssembly执行引擎上运行。VMware Labs提供了Ruby和PHP的wasm32-wasi二进制文件,并且两者都可以在VS Code中运行。

谢谢,

Dirk 和 VS Code 团队

编程快乐!