- 开发工具
- 系统底层
【免费下载链接】wazero
wazero: the zero dependency WebAssembly runtime for Go developers
WebAssembly 采用"宿主(Host)+ 客机(Guest)"的虚拟机架构:宿主是嵌入 WebAssembly 运行时的进程,客机则是被编译为 WebAssembly 二进制格式(Wasm)的程序。本文基于 wazero 官方文档中关于编程语言支持的总览与各语言专项笔记(TinyGo、Rust、Zig),系统讲解从源码到.wasm的编译流程、WebAssembly 的约束边界、系统调用(WASI)现状、并发模型,以及如何在 wazero 运行时中调用编译产物。读完本文,你将掌握三种主流语言的 Wasm 编译命令、内存分配(host allocation)模式与二进制体积/性能优化手段,并能根据 WebAssembly Core 规范与 WASI 的支持情况规划自己的嵌入方案。
从源码到 Wasm:宿主与客机模型
WebAssembly 的核心抽象是"虚拟机的字节码",而非某个具体操作系统的可执行文件。因此,使用任何语言开发 WebAssembly 程序的第一步,都是先把源码编译成 Wasm 字节码,第二步才是由宿主进程(如 wazero 所在的 Go 程序)加载并执行它。
以 Go 源码为例,可以使用 TinyGo 编译器完成这一步:
.-----------. .----------------------. .-----------. / main.go /---->| tinygo -target=wasi +---->/ main.wasm / '-----+-----' '----------------------' '-----------'wazero 是一个嵌入在 Go 应用中的运行时,而非浏览器,因此它的语言笔记全部偏向后端(backend)使用场景,而非浏览器场景。截至本文,wazero 已按语言字母序维护了以下三份编译笔记:
| 语言 | 文档 | 最小编译命令 |
|---|---|---|
| TinyGo | TinyGo 指南 | tinygo build -o X.wasm -target=wasi X.go |
| Rust | Rust 指南 | rustc -o X.wasm --target wasm32-wasi X.rs |
| Zig | Zig 指南 | zig build-exe X.zig -target wasm32-wasi |
需要说明的是:这些笔记由 wazero 社区维护,并非各语言官方文档,也不代表语言编译器维护团队的观点。如果你发现错误,可以参与维护;本文中的所有命令、特性与限制均以当前仓库与对应工具链的现状为准。
约束:WebAssembly 能做什么,不能做什么
WebAssembly Core 规范定义了一个基于栈的虚拟机。默认可用的特性本质上都是计算性的——模块之间唯一的通信方式是函数、线性内存与全局变量。
由此带来几个直接后果:
- 没有标准库与系统调用接口:操作系统本应提供的功能(如 fork 进程)无法实现,控制台输出等常见 I/O 能力在不同编译器中支持程度不一,详见下文 系统调用。
- 生态仍处于早期:WebAssembly 作为编译目标仍相对小众,维护与开发资源有限,某些特性即使技术上可行也可能尚未实现。
- 编译期诊断能力弱:与更成熟的目标平台相比,开发 WebAssembly 程序时更多问题只能在运行时暴露,可能表现为运行时报错甚至 panic;错误信息可能具有误导性;语言维护者也可能不熟悉相关问题,或依赖较少的核心维护者。
这些约束直接影响你的库设计与依赖选择——你不仅需要考虑自己源码的能力边界,还要考虑所用第三方库是否能在 Wasm 目标下正常工作。在极端情况下,约束与支持顾虑可能促使开发者转向较新的语言(如 Zig)。
缓解约束:用目标运行时做单元测试
无论使用哪种编程语言,wazero 给出的最佳实践都是对你的代码做单元测试,并且用你最终打算使用的 WebAssembly 运行时(比如 wazero 本身)来跑这些测试。
测试应覆盖代码的关键路径(包括错误分支)。这样做可以保护你的时间:相比临时上报的问题,你会拥有更高的信心和更高效的沟通手段。从仓库的实现来看,这一建议完全可行——imports/wasi_snapshot_preview1 目录下提供了args_test.go、clock_test.go、fs_test.go、poll_test.go、proc_test.go等大量针对 WASI 宿主函数的测试,而 examples/allocation/tinygo、examples/allocation/rust、examples/allocation/zig 则是可直接运行的宿主侧集成示例,均带有对应的_test.go文件。
系统调用:WASI 现状与各语言差异
WebAssembly 是基于栈的虚拟机规范,运行层级低于操作系统。凡操作系统会提供的功能,都需要系统接口来补齐。编程语言的标准库通常包含需要 I/O 的特性(如控制台输出),可移植性通常依赖 POSIX 一致的实现,例如fd_read。
**WebAssembly System Interface(WASI)**定义了基于 POSIX 的宿主函数,wazero 对其支持状况与函数清单详见 specs 文档。但必须强调几个事实:
- WASI 并非标准:它是 WebAssembly 社区子组的工作成果,尚未发布任何工作草案;最后一个稳定点是 2020 年底打 tag 的
wasi_snapshot_preview1,且 tag 之后还有函数被增删(例如proc_raise被移除、sock_accept被加入)。 - 语言编译器并不总是支持 WASI:例如 AssemblyScript 曾经支持 WASI,现已不再支持;即使基于 wasi-libc 编译到 WASI 的工具链也存在缺口——TinyGo 指南明确指出 TinyGo 尚不支持
fd_readdir(os.ReadDir会运行时报错)。 - 存在混合方案:例如 Emscripten 用 WASI 做控制台输出,但使用自己的虚拟文件系统函数。
- WASI 正在被替换:WASI 团队正在开发一个不兼容的、模块化的替代版本。
此外,即使系统接口受支持,部分用户仍偏好freestanding 编译目标(完全独立、不依赖宿主接口),以控制二进制体积与性能。总结而言:WebAssembly 的系统接口既不标准也不成熟,开发者必须理解并测试自己所依赖的系统接口——测试不仅能确认当前能力,还能在生态演进时持续验证它们仍然可用。
WASI 宿主函数的支持矩阵
根据 specs 文档 中的完整清单(以wasi_snapshot_preview1为准),wazero 已实现的函数覆盖了参数、环境、时钟、文件系统、路径、轮询、进程、随机数与套接字等类别。其中被实际使用的主要函数及对应编译器包括:
- 参数与环境:
args_get、args_sizes_get、environ_get、environ_sizes_get(已知被 TinyGo 使用) - 时钟:
clock_res_get、clock_time_get(被 TinyGo 使用) - 文件描述符:
fd_close、fd_fdstat_get、fd_prestat_get、fd_prestat_dir_name、fd_read、fd_seek、fd_write等(Rust/TinyGo/Zig 均有使用) - 目录与路径:
fd_readdir(Rust/Zig)、path_open、path_create_directory、path_remove_directory、path_rename、path_unlink_file等(Rust/TinyGo/Zig 均有使用) - 进程与调度:
proc_exit(Rust/TinyGo/Zig)、sched_yield(Rust) - 随机数:
random_get(Rust/TinyGo/Zig) - 套接字:
sock_accept、sock_recv、sock_send、sock_shutdown(Rust/Zig)
注意:清单中标记为 💀 的函数(如fd_fdstat_set_rights、proc_raise)属于"后来从 WASI 移除"的函数。wazero 并不会仅为了填满清单而实现所有 WASI 函数,若你需要尚未实现的特性,建议携带具体用例(例如用哪门语言编译、目标是哪个 Wasm)向维护者反馈。
并发:WebAssembly 尚无真正的并行
WebAssembly尚不支持真正的并行:它缺少多线程、原子操作与内存屏障支持(threads 提案仍在推进中)。举例来说,编译到 WASI 的程序会生成一个对应源码中main的_start函数;当运行时调用_start时,执行会一直停留在同一线程上,直到该函数完成。
具体到 wazero:一次 Wasm 函数调用会停留在发起调用的 goroutine 上,直到调用完成。虽然宿主函数理论上可以"做任何事"(包括启动进程),但只要 Wasm 二进制遵循 WebAssembly Core 规范,除非使用规范尚未定义的非标准指令或约定,否则无法并行做任何事。
把并行代码编译成串行 Wasm
在 threads 提案落地之前,语言编译器无法生成能在函数内部控制调度或安全并行修改内存的 Wasm——一个函数内部无法并行执行任何事情。这直接影响了语言原语向 Wasm 的翻译:
- 垃圾回收:在运行时宿主的调用线程上触发,而不是在后台进行。
- 语言级线程或协程:编译失败,或退化为顺序处理。
- 锁与屏障:编译失败,或以不安全方式实现。
- 异步函数(含 I/O):只能顺序执行。
语言编译器通常共享 LLVM、Binaryen 等基础设施。在翻译过程中一个有用的工具是 Binaryen 的Asyncify,它可以让语言以"异步"方式支持同步操作。TinyGo 正是靠它实现 goroutine 调度的(详见下文)。
通过编排实现"伪并发"
为了绕开 WebAssembly Core 抽象层并发能力的缺失,业界通常的做法是编排一组 worker 池,并保证池中每个模块只被顺序使用。例如 waPC 提供了 WASM 模块池,使得宿主回调可以在无法共享内存的情况下并行调用。这类编排模式与 wazero 的"调用停留在发起 goroutine 上"的模型天然契合——你可以在 Go 侧用多个 goroutine 各自驱动一个互不共享的模块实例来获得并行。
TinyGo:wasi目标实战
TinyGo 是 Go 源码的替代编译器,支持两个 Wasm 目标:wasm(浏览器/JavaScript 场景)与wasi(浏览器之外)。wazero 笔记只讨论wasi目标。完整笔记见 TinyGo 指南。
最小可运行示例——导出add函数:
package main //export add func add(x, y uint32) uint32 { return x + y } // main is required for the `wasi` target, even if it isn't used. func main() {}tinygo build -o main.wasm -target=wasi main.go编译出的 Wasm 会导出add函数,宿主(无论是否用 Go 编写)都能调用它。
标准库缺口与系统调用缺口
TinyGo 在wasi目标下并未完整实现 Go 标准库,首个让用户踩坑的是encoding/json:能编译,但运行时会 panic(受限于反射支持)。典型的运行期报错还包括目录读取:
package main import "os" func main() { if _, err := os.ReadDir("."); err != nil { println(err) } }上面这段代码能编译,但运行时会打印readdir unimplemented : errno 54。底层错误通常(但不总是)是syscall.ENOSYS——这是"系统调用尚未实现"的标准打桩方式。从 TinyGo 源码结构看,syscall.ReadDirent正是以返回syscall.ENOSYS的方式被 stub 掉的。
内存与宿主分配
TinyGo 编译 Go 到 Wasm 时,会把线性内存初始大小配置为2 页(128KB),并标记堆基址,堆基址之后的内存全部用于 Go 堆。Go 内的分配由分配器管理,可调用memory.grow直到宿主返回 -1。
当宿主函数需要直接分配内存(例如先写入一段 JSON,再调用导出函数解析它)时,TinyGo 导出的函数可以这样写:
//export configure func configure(ptr uintptr, size uint32) { json := ptrToString(ptr, size) }注意:WebAssembly 使用 32 位内存寻址,因此uintptr是 32 位。通用的调用模式分三种,需要分清内存所有权:
- 宿主分配字符串并调用 Guest 导出函数:宿主调用内置导出
malloc拿到写入内存的偏移量,把字符串写入该偏移(ptr),然后调用 Guest 函数并传入ptr与长度。此分配归宿主所有,完成后必须调用内置导出free。 - Guest 向导入的宿主函数传递字符串:Guest 用
stringToPtr取得宿主需要的偏移量,宿主直接从 Wasm 内存读取。字符串仍归 Guest 管理(受垃圾回收支配),宿主不应调用free。 - Guest 从导出函数返回字符串:Guest 用
ptrToLeakedString取得偏移量并连同长度一起返回。这是所有权转移,字符串不会被 Guest 回收;宿主读取后必须调用free。
宿主侧调用的内置malloc/free在 WebAssembly 文本格式中形如:
(func (export "malloc") (param $size i32) (result (;$ptr;) i32)) (func (export "free") (param $ptr i32))这些ptrToString等辅助函数代码量较大,可以直接从 examples/allocation/tinygo 中复制,或者引入 tinymem 依赖。仓库中的 greet.go 宿主示例 完整演示了:调用malloc分配 →mod.Memory().Write写入名字 → 调用导出的greet→ 从greeting的返回值(uint64中拆出高 32 位指针与低 32 位长度)读出问候语 →defer free.Call释放的全过程;对应的 testdata/greet.go 则展示了//go:wasmimport env log导入宿主函数、//go:wasmexport greet导出函数等写法。
WASI 内部结构
从 TinyGo 的wasi目标配置看,TinyGo 在底层复用了wasm32-unknown-wasi这个 LLVM 目标作为系统调用层(libc),最终由 wasi-libc 实现。与普通代码类似,TinyGo 依据 GOOS/GOARCH 后缀与构建标签决定使用哪套抽象——例如os.Args直接在runtime_wasm_wasi.go中用 Wasm 宿主函数实现,syscall.Chdir复用与其他架构相同的syscall_libc.go,而syscall.ReadDirent则在syscall_libc_wasi.go中被 stub。
TinyGo 的并发:goroutine 与 Asyncify
TinyGo 无论目标如何都只使用单核/单线程,这恰好匹配 Wasm 当前缺乏多线程支持的现状。其 goroutine 调度器目前基于 Binaryen 的Asyncify。结论是:TinyGo 默认支持 goroutine,行为等价于GOMAXPROCS=1。由于 goroutine 不是线程,下面的程序即使按相反依赖顺序定义 goroutine,也会输出预期结果:
package main import "fmt" func main() { msg := make(chan int) finished := make(chan int) go func() { <-msg fmt.Println("consumer") finished <- 1 }() go func() { fmt.Println("producer") msg <- 1 }() <-finished }但也有缺陷:例如同一个函数若被导出(//export notMain),并在 main 未运行的情况下被调用,创建 goroutine 的那行目前会在运行时 panic。鉴于此类问题,有些人选择用-scheduler=none让它在编译期直接失败——反正 Wasm 代码往往需要定制,去掉 goroutine 支持的影响可能有限。
TinyGo 优化选项
追求更小二进制体积:
-scheduler=none:减小体积,但 goroutine 会在编译期报错。一个简单的cat程序可从默认约 260KB 降到约 60KB(配合下面的选项)。--no-debug:剥离 DWARF,但保留 WebAssembly name section。
追求运行性能:
-gc=leaking:避开 GC,对短生命周期程序可提升性能。-opt=2:启用额外优化,常以增大二进制体积为代价。
TinyGo 常见问题
- 为什么必须定义 main?使用
wasi目标至少要写一个空func main() {},否则模块实例化会失败,除非宿主侧导出了(func (import "env" "main.main") (param i32) (result i32))。 - 怎么用 json?TinyGo 尚未实现
encoding/json所需的反射 API,多数用户改用非反射解析器(如 gjson)。 - 为什么没用到 WASI 也会导入 WASI 函数?至少实现
panic就需要写控制台,fd_write正被用于此。一个"裸"的独立 Wasm 目标目前还不存在。 - 为什么我的 Wasm 这么大?TinyGo 至少要内置垃圾回收与
panic,这部分 Wasm 通常不大(约 4KB);真正的大头常是看似简单但需要大量支撑函数的 API,例如fmt.Println可能就需要约 100KB 的 Wasm。
Rust:wasm32-unknown-unknown与wasm32-wasi实战
自 Rust 1.30 起,Rust 可通过三个目标生成.wasm:wasm32-unknown-emscripten(主要面向浏览器)、wasm32-unknown-unknown(独立使用,浏览器内外皆可)、wasm32-wasi(浏览器之外)。wazero 笔记聚焦经 wazero 测试的wasm32-unknown-unknown与wasm32-wasi,并出于精确与简洁以rustc而非cargo为例。完整笔记见 Rust 指南。
最小示例——导出add函数:
#[no_mangle] pub extern "C" fn add(x: i32, y: i32) -> i32 { x + y }rustc -o lib.wasm --target wasm32-unknown-unknown --crate-type cdylib lib.rs#[no_mangle]与extern "C"用于标记要导出给 WebAssembly 宿主的函数;--crate-type cdylib表示以库(而非带main的可执行程序)方式编译。
Rust 内存与宿主分配
Rust 编译到 Wasm 时,线性内存初始大小为17 页(约 1.1MB),并标记堆基址;其后的内存全部用作 Rust 堆。Rust 内的分配由global_allocator管理,可调用alloc_pages直到宿主memory.grow返回 -1。
当宿主需要先分配内存再调用导出函数时(例如写入一段 JSON 再调用解析函数),宿主侧流程与 TinyGo 一致:先调用分配函数拿到内存偏移,写入数据,调用导出函数(传入ptr与长度),最后无条件用同一ptr调用 free 函数,防止内存泄漏。WebAssembly 使用 32 位内存寻址,所以uintptr是 32 位。
需要自定义导出malloc与free:
(func (export "malloc") (param $size i32) (result (;$ptr;) i32)) (func (export "free") (param $ptr i32) (param $size i32))Rust 实现示例:
use std::mem::MaybeUninit; use std::slice; unsafe fn ptr_to_string(ptr: u32, len: u32) -> String { let slice = slice::from_raw_parts_mut(ptr as *mut u8, len as usize); let utf8 = std::str::from_utf8_unchecked_mut(slice); return String::from(utf8); } #[no_mangle] pub extern "C" fn alloc(size: u32) -> *mut u8 { // Allocate the amount of bytes needed. let vec: Vec<MaybeUninit<u8>> = Vec::with_capacity(size as usize); // into_raw leaks the memory to the caller. Box::into_raw(vec.into_boxed_slice()) as *mut u8 } #[no_mangle] pub unsafe extern "C" fn free(ptr: u32, size: u32) { // Retake the pointer which allows its memory to be freed. let _ = Vec::from_raw_parts(ptr as *mut u8, 0, size as usize); }仓库中的 examples/allocation/rust 提供了完整对照:宿主侧 greet.go 调用导出的allocate/deallocate并读写mod.Memory();客机侧 testdata/greet.rs 演示了#[link(wasm_import_module = "env")]导入宿主log函数、#[cfg_attr(all(target_arch = "wasm32"), export_name = "greeting")]导出函数,以及用wee_alloc作为#[global_allocator]压缩体积。这类技术性代码应尽量与业务逻辑分离。
Rust 系统调用与优化
需要操作系统功能时必须使用wasm32-wasi目标,它会导入 WASI 宿主函数。例如:
fn main() { println!("Hello World!"); }用rustc -o hello.wasm --target wasm32-wasi hello.rs编译后,main会变成导出的 WASI_start函数。wazero 还提供了无任何 WebAssembly 特定代码、纯 Rust 实现cat的 WASI 示例项目。
优化选项方面,追求小体积可修改源码(如使用 wee_alloc 这个小巧的、面向 WebAssembly 调优的分配器)或设置rustc标志:-C debuginfo=0(剥离 DWARF,保留 name section)、-C opt-level=3(包含全部体积优化)。使用 cargo 时--release即对应这两项。使用wasm32-wasi目标的用户可考虑 cargo-wasi 工具,它能显著减小 Wasm 体积。追求性能则可设-C opt-level=2,但常以增大体积为代价。
Zig:wasm32-freestanding与wasm32-wasi实战
自 Zig 0.4.0 起,Zig 可通过wasm32-emscripten(主要面向浏览器)、wasm32-freestanding(独立使用,浏览器内外皆可)、wasm32-wasi(浏览器之外)三个目标生成 Wasm。wazero 笔记(面向 Zig 0.10.1)聚焦wasm32-freestanding与wasm32-wasi。完整笔记见 Zig 指南。
最小示例——导出add函数:
export fn add(a: i32, b: i32) i32 { return a + b; }zig build-lib -dynamic -target wasm32-freestanding main.zigzig build-lib -dynamic表示以库(无main函数)方式编译,产物会导出add供宿主调用。
Zig 的内存模型与宿主分配
Zig 语言本身不为程序员管理内存,也没有默认分配器——需要分配的函数都接受一个Allocator参数。宿主需要先分配内存时,流程同样为:宿主调用分配函数 → 写入数据(如 JSON)→ 调用导出函数(传入ptr与size)→ 完成后无条件调用 free。
pub export fn configure(ptr: [*]const u8, size: u32) void { _configure(message[0..size]) catch |err| @panic(switch (err) { error.OutOfMemory => "out of memory", }); }由于 Zig 允许用户方便地接入自定义分配器,导出malloc/free对很容易实现。例如基于page_allocator:
const allocator = std.heap.page_allocator; pub export fn malloc(length: usize) ?[*]u8 { const buff = allocator.alloc(u8, length) catch return null; return buff.ptr; } pub export fn free(buf: [*]u8, length: usize) void { allocator.free(buf[0..length]); }examples/allocation/zig 中的示例还展示了几个值得注意的技巧:用@ptrToInt把 Zig 指针转成数值类型、用[*]u8作为参数接收指针并切片重建字符串、以及用extern指令导入宿主函数。
Zig 系统调用与优化
需要操作系统功能时须使用wasm32-wasi目标。Zig 标准库对 WASI 的支持仍在积极开发中,一般情况下编译到wasm32-wasi时应优先使用标准库(如std.io)。同样,wazero 提供了纯 Zig 实现cat的 WASI 示例。
体积优化方面,Zig 提供了多个控制二进制体积、执行速度与安全检测的构建模式:
-ODebug:构建快、开启安全检测、运行性能较慢、体积较大-OReleaseSafe:运行性能中等、开启安全检测、编译较慢、体积较大-OReleaseSmall:运行性能中等、关闭安全检测、编译较慢、体积较小
性能优化则用-OReleaseFast:启用额外优化,可能以增大体积为代价。此外更换分配器(如挑选更小的分配器实现)也能显著影响体积。
常见问题
为什么我的.wasm二进制这么大?
三种语言的默认配置都可被覆盖:TinyGo 用-scheduler=none、--no-debug;Rust 用-C debuginfo=0、-C opt-level=3或 cargo 的--release,WASI 目标还可借助 cargo-wasi;Zig 用-OReleaseSmall等构建模式。在牺牲特性或性能换取更小体积之后,进一步调整源码(如换用更小的分配器)还能继续压缩。以 TinyGo 为例,GC 与panic的最小实现通常只有约 4KB,真正占体积的往往是fmt.Println这类"看似简单实则依赖大量支撑函数"的 API。
宿主分配字符串时应该由谁调用 free?
取决于所有权归属:宿主分配的(经malloc)由宿主调用free;Guest 内部字符串(经stringToPtr)仍归 Guest 管理,宿主不应释放;Guest 导出的泄漏字符串(所有权转移)必须由宿主调用free。Rust 示例中用std::mem::forget完成所有权转移,TinyGo 示例中用C.malloc+stringToLeakedPtr实现同样的语义——仓库中两个 greet 示例的宿主代码都通过defer保证释放。
总结
把一门语言编译到 WebAssembly 并嵌入 wazero 运行,是一条清晰但需要审慎对待的路径:先用tinygo、rustc或zig按目标(wasi、wasm32-unknown-unknown、wasm32-freestanding等)产出.wasm,再用 Go 侧 wazero.NewRuntime 加载模块、以ExportedFunction调用函数、通过mod.Memory()读写线性内存完成数据交换。务必牢记三点:系统接口(WASI)不标准且不成熟,需要针对你依赖的接口做单元测试;WebAssembly 尚无真正的并行,调用会停留在发起 goroutine 上,需要借助模块池编排来获得并行;内存所有权(谁分配谁释放)必须显式约定,才能避免泄漏与悬垂指针。想进一步深入,可从 TinyGo 指南、Rust 指南、Zig 指南 以及 examples/allocation 三个可直接运行的示例项目开始。
- 开发工具
- 系统底层
【免费下载链接】wazero
wazero: the zero dependency WebAssembly runtime for Go developers
相关推荐
如何在wazero中运行Rust、TinyGo和Zig编译的WASM:完整跨语言指南
如何在wazero中运行Rust、TinyGo和Zig编译的WASM:完整跨语言指南 WebAssembly(WASM)正在改变我们构建跨平台应用的方式,而wa
开发工具系统底层wazero 与 TinyGo 实战指南:从 Go 源码到可嵌入的 wasi Wasm 模块
wazero 与 TinyGo 实战指南:从 Go 源码到可嵌入的 wasi Wasm 模块 导读 本文围绕 wazero 仓库中维护的 site/conten
开发工具系统底层Roc 编译器嵌入指南:在 Zig 程序中内嵌编译并执行 .roc 模块
Roc 编译器嵌入指南:在 Zig 程序中内嵌编译并执行 .roc 模块 本文以 src/compile/README.md https://link.gitc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考