现已发布!阅读关于 11 月份的新特性和修复。

在 Web 版 VS Code 中运行 WebAssembly

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

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

WebAssembly 是一种用于虚拟机的二进制指令格式。如今,现代浏览器都自带 WebAssembly 虚拟机,并且有工具链可以将 C/C++ 编译为 WebAssembly 代码。为了了解当今 WebAssembly 的可能性,我们决定采用 C/C++ 编写的 Python 解释器,将其编译为 WebAssembly,并在 Web 版 VS Code 中运行它。幸运的是,Python 团队已经开始致力于将 CPython 编译为 WASM,我们很乐意借用他们的努力。探索的结果可以在下面的短视频中看到

Execute a Python file in VS Code for the Web

它看起来与在 VS Code 桌面中执行 Python 代码并没有什么不同。那么,这有什么酷的地方呢?

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

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

它是如何工作的?

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

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

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

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

在我们在 Web 版 VS Code 中运行 Python 后,我们很快意识到我们采用的方法允许我们执行任何可以编译为 WASI 的代码。因此,本节演示如何使用 WASI SDK 将小型 C 程序编译为 WASI,并在 VS Code 的扩展宿主中执行它。该示例假设读者熟悉 VS Code 的扩展 API,并且知道如何为 Web 版 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 扩展提供了 WebAssembly 执行引擎,该引擎将 WASI API 连接到 VS Code API。节点模块 @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);
    }
  });
}

以下视频显示了在 Web 版 VS Code 中运行的扩展。

Run 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

Interaction between the WASM worker and the extension host

一个 Web Shell

既然我们能够将 C/C++ 和 Rust 代码编译为 WebAssembly 并在 VS Code 中执行,我们探索了是否也可以在 Web 版 VS Code 中运行 Shell。

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

A web shell

如果您无法执行自定义 WebAssembly 或命令,则 Shell 非常有限。为了扩展 Web Shell,其他扩展可以为文件系统贡献额外的挂载点,以及在 Web Shell 中键入时调用的命令。通过命令进行间接调用将具体的 WebAssembly 执行与终端中键入的内容解耦。从一开始就在 Python 扩展中使用此支持,您可以直接从 Shell 中执行 Python 代码,方法是在提示符中输入 python app.py 或列出通常挂载在 /usr/local/lib/python3.11 下的默认 Python 3.11 库。

Python integration into web shell

接下来是什么?

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 并在 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 团队

快乐编码!