在浏览器代码运行器中添加Go语言

Lobsters Hottest 工具

摘要

作者详细介绍了在基于浏览器的代码运行器(dailyprog)中添加Go语言所面临的挑战及解决方案,解释了为什么标准的GOOS=js方法无法在V8隔离环境中工作,以及如何通过使用GOOS=wasip1和最小的WASI主机shim来成功实现。

<p><a href="https://lobste.rs/s/qtui32/adding_go_browser_code_runner">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/07/10 22:13

# 将 Go 加入浏览器代码运行器 · Ata Kuyumcu 的博客 来源:https://blog.lvmbdv.dev/posts/adding-go-to-a-browser-code-runner/ dailyprog (https://dailyprog.club/)(每日编程谜题网站,此前文章 (https://blog.lvmbdv.dev/posts/generating-coding-puzzles-with-llms/))最初只支持 JavaScript。然后加入了 Python,接着是 C。每增加一种新语言都意味着一个不同类型的项目。Python 需要 Pyodide (https://pyodide.org/)。C 需要 PicoC (https://gitlab.com/zsaleeba/picoc) WASM 二进制文件以及一个自定义的 printf 输出桥接。原计划 Go 是第四种语言。每个“Go 到 WASM”教程都指向同一条路,于是我也走了那条路。 结果证明,这是一条错路。 ## 人人都在走的路:GOOS=js 将 Go 编译为目标浏览器的 WASM,需要设置 `GOOS=js GOARCH=wasm`。Go 为此提供了两个组件:一个是生成带有 JavaScript 系统调用桥接的 WASM 的编译器目标,另一个是 `wasm_exec.js` (https://go.dev/wiki/WebAssembly),这是一个支持文件,用于在 JS 端实现 Go 运行时的预期功能。你加载 `wasm_exec.js`,调用 `WebAssembly.instantiate()`,Go 运行时启动,然后你的程序运行。 这是大多数浏览器内 Go 游乐场的工作方式(比如 LiveCodes (https://livecodes.io/docs/languages/go);官方 playgound (https://go.dev/play/) 则完全回避了这个问题,直接在服务器上运行你的代码)。这种方式是可行的,背后有 Stack Overflow 上多年的解决方案支撑。 问题在于,dailyprog 是在 `isolated-vm` (https://github.com/laverdet/isolated-vm) 内运行用户提交的代码,而不是在主浏览器线程中。`isolated-vm` 是一个 V8 隔离区,没有 DOM、没有事件循环,并且 API 接口受限。而标准的启动流程使用了异步的 `WebAssembly.instantiate()`,它会返回一个 Promise。在 `isolated-vm` 内部,这个 Promise 永远不会被兑现,因为隔离区内没有任何东西可以驱动它。程序会一直挂起,直到调用超时。 我花了整整两天时间尝试各种变通方案。错误信息显示 `Maximum call stack size exceeded`,于是我去追踪栈溢出问题并增加 V8 的栈大小。但这毫无作用(`--stack-size` 甚至影响不到隔离区,而且栈溢出本身正是启动挂起的症状)。我尝试向隔离区注入 `setTimeout` 的 polyfill。还是不行。在同步环境中进行异步初始化,根本就是死路一条。 GOOS=js 还存在第二个问题。即使异步问题可以解决,`syscall/js` 桥接(Go 用来与 JavaScript 双向通信的机制)依赖于在 `globalThis` 上设置属性。在浏览器中,`globalThis` 就是 window。而在 `isolated-vm` 中,桥接在启动时捕获到的 `globalThis` 并不是你的代码所看到的那个 `globalThis`,因此回调被触发后没有任何东西能接收到它们。你无法从外部修补这个问题,因为桥接已经被编译进了 WASM 中。 GOOS=js 假设了一个完整的浏览器环境。一旦失去这个假设,它就会以无人记录的方式失败,因为几乎没有人会在 V8 隔离区中运行 Go WASM。 ## 更干净的路径:GOOS=wasip1 WASI (https://wasi.dev/) 是一个面向 WASM 的系统接口。它定义了一组导入函数,WASM 模块可以调用这些函数(文件 I/O、环境变量、时钟、随机数等),然后由宿主提供实现。这个合约比 JS 桥接要小得多。没有 Promise,没有事件循环,没有 `globalThis`。只有一块线性内存缓冲区以及少数几个对它进行读写操作的函数导入。 Go 从 1.21 版本开始支持 WASI 作为构建目标 (https://go.dev/blog/wasi):`GOOS=wasip1 GOARCH=wasm`。当你使用这个目标编译时,Go 的 `fmt.Println` 会通过 WASI 的 `fd_write` 导入来写入 stdout。你作为宿主的任务就是实现 `fd_write` 并捕获这些写入内容。 以下是 `fd_write` 的完整宿主垫片(shim): ``` function fd_write(fd, iovs, iovs_len, nwritten) { let written = 0; for (let i = 0; i < iovs_len; i++) { const ptr = mem.getUint32(iovs + i * 8, true); const len = mem.getUint32(iovs + i * 8 + 4, true); const text = readStr(ptr, len); stdout.push(text.endsWith("\n") ? text.slice(0, -1) : text); written += len; } mem.setUint32(nwritten, written, true); return 0; } ``` 就这样。该函数从线性内存中读取 `iovs` 条目(每个条目是一个 {offset, length} 对),解码 UTF-8 字节,去掉 `fmt.Println` 自动添加的尾部换行符,将结果推入一个 `stdout` 数组,并通过 `nwritten` 报告已写入的字节数(Go 会检查它)。其余的 WASI 导入函数都非常简单: - `args_get` 将源代码作为 argv[1] 传递。 - `clock_time_get` 以纳秒为单位返回 `performance.now()`。 - `random_get` 用 `Math.random()` 生成的字节填充缓冲区。 - `proc_exit` 抛出一个带有特定前缀的错误,以便我们可以区分正常退出和崩溃。 - 其他所有函数都返回错误:对于文件描述符调用返回 EBADF,其余返回 ENOSYS (52)。 整个宿主端代码大约只有五十行。Go 程序编译为 WASM,在隔离区中启动,通过 Yaegi (https://github.com/traefik/yaegi)(一个用 Go 编写的 Go 解释器)运行用户的代码,而 `fmt.Println` 的输出就会出现在 `stdout` 中。 运行时是一个单一的 WASM 二进制文件:编译为 WASI 的 Yaegi。构建方法如下: ``` go mod init yaegi-wasi go get github.com/traefik/[email protected] GOOS=wasip1 GOARCH=wasm go build -o yaegi.wasm yaegi_wasi.go ``` 包装代码大约 30 行 Go:用 `interp.New` 创建一个解释器,加载 `stdlib.Symbols`,从 `os.Args[1]` 读取源代码,然后调用 `Eval` 执行。没有特殊的编译技巧。生成的 WASM 文件大小为 38 MB。 ## 一次性陷阱 这里有一个问题。WASI 的 `_start` 函数是“一次性”的。你调用它一次,程序运行到结束,然后运行时就会销毁。如果你对同一个 `WebAssembly.Instance` 再次调用 `_start`,Go 会引发 panic: ``` fatal error: randinit twice ``` 或者: ``` fatal error: self deadlock ``` 再或者,最糟糕的情况是:没有任何错误信息。Yaegi 在 `fmt.Println` 触发之前就挂掉了,运行器报告 “No output for this case.” 这条消息里完全看不出是实例重用导致的问题。 解决方法:每次运行都使用全新的实例。在服务器端,每次运行都创建全新的 `isolated-vm` Isolate(冷启动大约需要 4 秒)。在浏览器中,你需要缓存编译好的 `WebAssembly.Module`(每次运行都编译一个 38 MB 的 WASM 二进制文件会非常痛苦),但每次运行都要创建一个新的 Instance: ``` // 错误:重用实例 let instance = null; export async function runGo(source, callback) { if (!instance) instance = new WebAssembly.Instance(mod, imports); instance.exports._start(); // 第二次调用会失败 } // 正确:缓存 Module,每次创建新 Instance let mod = null; export async function runGo(source, callback) { if (!mod) mod = await WebAssembly.compile(bytes); const instance = new WebAssembly.Instance(mod, imports); memory = instance.exports.memory; // 更新 shim 中的 memory 引用 instance.exports._start(); // 每次都是全新的运行时 } ``` 编译好的模块是开销大的部分,而实例则很便宜。这相当于从缓存的二进制文件派生出一个新进程。关键细节是更新 WASI shim 中的 `memory` 引用,使它们读取和写入的是新实例的线性内存,而不是前一个实例的。 ## 结果 加入 Go 是在完成了桥接统一之后进行的:所有四种语言共享同一套代码生成、运行、输出分割、结果评估的流水线。Go 特有的部分只有七个新文件(其中三个是测试文件,一个就是那个 38 MB 的二进制文件),再加上在共享文件中添加的一行代码。服务器端的 GoSandbox 类基本上是 CSandbox 的复制粘贴:通过桥接生成代码,通过运行时执行,分割输出,评估结果。大约 20 行代码。 浏览器端的运行器是一个 130 行的模块(`go-runtime.mjs`),负责获取 WASM 文件、实现相同的 WASI shim,并暴露 `runGo(source, onLine)` 接口。Web Worker 会惰性地导入它。 桥接的代码生成部分会生成一个 `main()` 函数,其中包含硬编码的 Go 字面量参数,每个测试用例对应一个 `fmt.Println(prefix, call)` 调用。结构与 C 桥接相同。类型推断将 JavaScript 值映射为 Go 类型(int、float64、string、bool 以及 slice)。`parseGoLine` 函数将结果字符串转换回 JS 值,以便进行 `deepEqual` 比较。 所有机械性的连接工作(`LangAdapter` 接口、`splitOutput`、`evaluateResults`)在统一桥接时就已经完成了。现在添加一种目标是 WASI 的语言,基本上就是填空的练习。唯一困难的部分是找到一个能编译为 WASM 的解释器,并证明它能在 `isolated-vm` 内部正常工作。 ## WASI 模式 WASI 正好适合这项任务。它并不优雅(它本质上是一个类似 POSIX 的系统调用接口),但其合约足够小,一个下午就能正确实现:`fd_write`、`args_get` 以及十几个 stub 函数。在桥接的另一端,没有任何第二个运行时需要自己的事件循环。 此后任何需要在 dailyprog 中添加、且需要 WASM 运行时的语言,都应该以 WASI 为目标。自托管的解释器(用自身语言编写并编译为 WASM 的解释器)只要该语言能编译为 `wasm32-wasip1` 就能工作。Yaegi 已经为 Go 证明了这种模式。用 C 编写的 Lua 解释器(通过 clang 以 WASI 为目标)也可以用同样的方式工作。用 Rust 编写的 Ruby 解释器也是如此,甚至 PHP 也有现成的第三方 WASI 构建版本。 GOOS=js 是第一个能在浏览器中正常工作的方案,并且 Go 团队持续维护它,这在你没有离开浏览器环境之前是很令人安心的。而 WASI 去掉了浏览器假设,给你的是一个读取输入并写入输出的程序。对于一个代码运行器来说,这就是全部的工作内容了。

相似文章

优化CPU密集型Go热路径的笔记

Hacker News Top

本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。

就用Go

Lobsters Hottest

一篇带有强烈观点的开发者文章倡导使用Go编程语言,强调其简洁的语法、强大的标准库、高效的并发模型以及单二进制部署,作为对过于复杂的现代技术栈的实用替代方案。