首页
/ wazero WebAssembly 运行时设计决策精讲:零依赖、配置模型与 WASI 语义选型(以 moby 仓库 vendored v1.11.0 为对象)

wazero WebAssembly 运行时设计决策精讲:零依赖、配置模型与 WASI 语义选型(以 moby 仓库 vendored v1.11.0 为对象)

2026-09-07 09:11:36作者:冯梦姬Eddie

本文的讲解对象是 vendored 于当前仓库中的 wazero —— 一个纯 Go 实现的 WebAssembly(Wasm)运行时。wazero 以"零依赖、零 CGO"为最大差异化卖点,同时为在 Go 宿主程序中嵌入任意语言编译出的 Wasm 模块提供了 Runtime/Module 两级配置模型与完整 WASI 支持。本仓库根目录 go.modgithub.com/tetratelabs/wazero v1.11.0 // indirect 引入该模块,它被 vendored 代码中的容器运行时插件生态实际消费,例如 vendor/github.com/containerd/nri/pkg/adaptation/wasm-enabled.go 通过 wazero.NewRuntimeConfig().WithCloseOnContextDone(true) 创建带超时取消能力的 Wasm 沙箱,vendor/github.com/knqyf263/go-plugin/wasm/host.go 则引入 wazero/api 作为宿主调用的类型基础。

本仓库中 wazero 的 RATIONALE.md 记录了该项目大量"为什么这样设计"的决策,覆盖依赖策略、代码结构、公开 API、配置体系、退出语义、文件系统抽象、fd_readdir 细节、时钟/睡眠系统调用、poll_oneoff、编译器引擎与并发测试模式。本文将逐章精讲这些决策背后的取舍与依据,并结合仓库内源码路径给出可追溯的实现锚点。读完后,你将能理解一个"生产级、无 CGO、可嵌入"的 Wasm 运行时在工程上做了哪些关键权衡,以及为什么其 API 呈现出今天的形状。

零依赖与零 CGO:wazero 的核心定位

wazero 刻意保持零 go.mod 依赖(x/sys 除外),以区别于其他通常因 CGO 而背负沉重负担的运行时。避免 CGO 意味着用户不再需要共享库、libc 等前置条件,同时保住 Go 的交叉编译能力。这一点与仓库根目录 README.mdvendor/github.com/tetratelabs/wazero/README.md 中对 wazero "zero dependencies、不需要 CGO、可在受限环境静态嵌入"的定位描述一致。

零依赖带来责任的转移:Go 原生平台支持不错(单调时钟无需平台特定代码,编译器所需的 mmap 也无需太多工作),但 Go 对不同操作系统的支持程度并不一致。因此,严格的依赖策略换来的是"更显式的平台支持(尤其在编译器运行时)",以及"更大、更难维护的代码库"。换句话说,这是用内部复杂度换取用户侧部署简单性的权衡。

为什么 darwin 上会用 CGO 实现系统调用?

尽管 wazero 在设计上依赖与 CGO 双免,部分代码仍可"可选"地使用 CGO 并保留禁用后的回退路径。唯一的默认启用 CGO 的 GOOSdarwin。原因是:无论 CGO_ENABLED 为何值,Go 在 darwin 的运行时层都会走"CGO 机制"——Apple 本身不支持完全静态链接的二进制。这对 wazero 反而是利好,因为 Go 标准库尚未暴露的一些系统调用(典型如纳秒精度时间戳操作所需的 futimens)可以借此补齐。

为什么引入 x/sys

x/sys/unix 目前是 wazero 唯一的外部 go.mod 依赖(可从 vendor/github.com/tetratelabs/wazero/go.mod 的实际依赖声明获得印证)。该模块由 Go 官方团队维护,覆盖 syscall 包顾及不到的 OS。经过慎重权衡后采纳它,是因为它在不增加维护负担、也不给 wazero 用户造成部署问题的前提下,显著改善了 wazero 在较老或较少见 OS 上的使用体验。

值得注意的是:在 wazero 的整个生命周期中,一个"零依赖"哲学与"实际上需要一个被维护良好的系统调用层"之间的矛盾是反复出现的张力。Rationale 的明确态度是——宁可接受唯一一个高质量的官方依赖,也不自己重造轮子。

项目结构与包边界:为什么几乎所有代码都是 internal

wazero 大量使用 Go internal 包,目的是在"对终端用户的 API 兼容性"与"编译器之间安全共享内部实现"之间取得平衡。面向用户的包只有三类:

  • 根包 wazero(含各类 Config 结构)与 runtime.goconfig.gofsconfig.go 等顶层文件;
  • 共享类型的 api 包(vendor/github.com/tetratelabs/wazero/api);
  • 内建 WASI 库(imports/wasi_snapshot_preview1)。

其余全部放在 internal 下,包括 wasmenginesyssysfswasip1wasmruntime 等。

Internal 包的取舍

把大多数代码置于 internal 意味着第三方无法外部实现编译器、解码器等分面,也阻止了把代码拆分为独立仓库——结果是更大的 monorepo 和更多的集中评审负担。文档明确否定了替代方案:允许外部实现意味着必须导出 CodeSection 这类符号,极易滋生 bug;而且 Wasm/WASI 规范演进极快,任何外部实现都面临高漂移风险。以"实现一个编译器"为例,它要求同时精通 Wasm、Go 与汇编,还需要深刻理解内部结构设计和多层级测试;即使具备这些能力,支持外部代码会为项目引入大量不确定性变量,反而挤占中央代码库本就沉重的维护时间。

因此 internal 策略的代价是:代码库更大、必须自行实现全部标准特性、扩展更多以"fork 不可行"为前提来思考。缓解手段则是友好的开源许可证、高严谨性与协作精神。

如何避免循环依赖:api 作为共享层

wazero 用一套明确模式在 internal 代码间共享常量与接口,从而根除循环依赖:

  • 共享接口与常量放在根下的单一包 api
  • 用户 API 与结构体依赖 api,放入根包 wazero(例如 InstantiateModule 依赖类型 api.Module);
  • 实现代码也可以依赖 api,放在 /internal 下对应包(例如包 wasm 依赖 api.Module)。

代价是必须在两个包中重复定义部分符号。若用户需要访问某个内部类型,则用"覆盖类型(cover type)"收窄,例如 wasm.Store 被包装进 wazero.runtime

type runtime struct {
    s *wasm.Store
}

由于变更只能经由配置发生,这种重复并不糟糕——导出函数被限制到少数几个。

避免安全 bug:公开 API 不得写内部可变符号

为防范代码注入等安全缺陷,公开 API 中没有任何东西被允许直接写入 internal 包的可变符号。具体到共享的 api 包:它不能包含任何可变的公开符号,例如切片或带导出字段的结构体。于是,像内存变更这类共享功能必须通过接口实现:

  • api.Memory 通过暴露 WriteFloat64Le 之类的方法来保护访问,而不是导出一个 []byte 缓冲区;
  • 代表 CodeSection[]byte 没有对应的导出符号。

这除安全外还预防了其他 bug,并让 Wasm 解码等校验逻辑得以集中。

API 设计:几个看似"不一致"的刻意选择

为什么 context.Context 时有时无?

只有部分 API 带首参 context.Context,初看奇怪。wazero 最初为一切可能被追踪的东西都加了 context,后来发现它只对生命周期(lifecycle)与宿主函数(host functions)真正有用。对指令级的作用域(如内存更新),context 参数粒度过细且实践中不可见——绝大多数用户使用编译器引擎时,其内存、全局量或表访问根本不会触碰 Go 的 context。

为什么 api.ValueType 统一映射到 uint64?

WebAssembly 允许函数由 guest(Wasm 侧)或 host(宿主侧)定义,签名用 Wasm 类型表达;i32 是可能按有符号解释的 32 位类型。Wasm 1.0 最多允许一个结果,但参数与结果个数均可为零或多个。在 wazero 中宿主是 Go,导出的函数可经 api.Function 调用——用户以 []uint64 切片提供参数并读取结果;无结果时返回空切片,签名则经 FunctionDescription 获取,它返回与每个参数/结果对应的 api.ValueType

之所以统一映射为 uint64,是因为它是 WebAssembly 1.0 中最大的类型(i64)的承载位宽;切片又可以表达"空签名"(如 start 函数)。以下调用语法对 (param i32 i32) (result i32)(param i64 i64) (result i64) 两种签名同样有效:

x, y := uint64(1), uint64(2)
results, err := mod.ExportedFunction("add").Call(ctx, x, y)
if err != nil {
    log.Panicln(err)
}
fmt.Printf("%d + %d = %d\n", x, y, results[0])

WebAssembly 并不定义宿主侧参数/结果的编码策略,因此这套映射规则是 wazero 自行定义的。它把映射写进 api.ValueType 并提供 api.EncodeF64 等辅助函数,让用户复用惯用的 Go 类型转换并规避强转边界。文档也对比了另一种方案——字节缓冲 + 值类型的二进制编码——结论是:对最常见的 i32/i64 而言它更繁琐,而且同样无法回避"1.0 之后新增的值类型没有统一编码"这一根本矛盾。总结一句话:因为规范里没有签名映射标准,wazero 选择了"偏简单、以整数为中心"的方案,其余靠文档与工具函数兜底。

Post 1.0 值类型:externref 与 v128

Wasm 1.0 之后新增的值类型给上述模型带来压力:有的没有 guest 表示,有的超过 64 位。但它们很少出现在导出的(extern)函数签名中。

  • externref 没有 guest 表示,wazero 将其映射为 uint64——这正是支持的平台上编码一个指针所需的最大位宽。
  • 唯一的超过 64 位的类型是 SIMD 的 v128。截至文档写作时间,宿主函数向量化并不被使用,即便使用也因宿主函数开销而劣于 guest 侧向量化;因此 v128 几乎不会出现在导出函数签名里。它需要两个 uint64 编码只是内部细节,不值得为此改动导出的 api.Function 接口——那会破坏所有既有用户。

接口而非结构体:防止错误初始化

公开包中所有导出类型,无论配置还是运行时,都是接口。好处是内部灵活,且避免用户绕过 NewXxx 构造函数自行实例化而出错——从根源上减少支持负担:

rt := &RuntimeConfig{} // 错误:字段为 nil
rt := RuntimeConfig{}  // 错误:本应是指针
rt := wazero.NewRuntimeConfig() // 正确

代价是维护者需要多做工作:接口与实现结构体解耦导致签名写两遍;接口必须在使用时文档化并声明"不支持第三方实现";且截至 Go 1.21,godoc 对接口的支持仍不理想。

配置体系:无错误、不可变、扁平、链式

wazero 用 XxxConfig 类型为 Runtime 与 Module 等作用域做配置:RuntimeConfig 配置 RuntimeModuleConfig 配置实例化。所有配置类型都以默认值起步,可被用户定制(如选择特性集、覆盖模块名)。

为什么每个配置项不返回 error?

没有配置类型会创建需要关闭的资源,使用过程也不返回错误。这减少了资源泄漏并让链式调用更顺手,同时把"解析配置"与"校验配置"解耦(比如可以先从 yaml 解析再统一校验)。于是错误只有一处需要处理:

cfg = cfg.WithFS(fs).WithName(name)
mod, err = rt.InstantiateModuleWithConfig(ctx, code, cfg)
if err != nil {
  return err
}

而不是在每个 WithXxx 处都写一遍错误分支。即使内部某处打开了资源却失败,也无需用户追踪"哪些资源要在部分失败时关闭"——那由唯一能读取配置字段的内部代码统一处理。

为什么配置不可变?

Runtime 这类作用域在一个进程内看似不会重复,但确实可能——而且可能发生在不同 goroutine。有的用户每个模块新建一个 runtime,有的用户复用同一份基础模块配置、仅对每次实例化做小改动(如改名)。不可变让配置可安全地在任意 goroutine 中使用。既然不可变,变更就必须通过返回值生效,与切片的 append 同理:

cfg = cfg.WithName(name)   // 显式再赋值

mod, err = rt.InstantiateModuleWithConfig(ctx, code, cfg.WithName(name)) // 隐式传参
if err != nil {
  return err
}

为什么不用 Option 模式?

Option 模式在 Go 中很常见,但 wazero 选择了链式 WithXxx。文档逐一对比了两种形态(链式接口方法 vs ModuleConfigOption func(c *moduleConfig) 函数式选项),理由有三:

  1. 接口为其方法提供了天然命名空间,比"带前缀的包级函数"更直接;
  2. 把解析出的配置转成函数回调,比转成函数切片更直接;
  3. 无论哪种方式都需要"派生配置",而 option 模式实现派生更别扭。

此外无 option 让测试与调试更简单。文档也坦承:正因为 option 在 Go 中过于流行,才更需要把这一决策写下来。

为什么配置不深层结构化?

wazero 的配置覆盖 Wasm 使用的两个主要作用域:

  • RuntimeConfig:最宽泛的作用域,同时作用于编译与实例化,例如控制 WebAssembly 规范版本;
  • ModuleConfig:影响编译后实例化的模块及其允许的资源,例如定义 STDOUT 如何(或是否)被捕获,并允许再细分出 FSConfig(见 vendor/github.com/tetratelabs/wazero/fsconfig.go)。

它们默认保持扁平,仅在被证明有必要时才懒加载子配置。扁平结构更易使用、更易发现;相比 option 模式,往接口里加配置也不会污染包命名空间。文档特别强调:"给配置增加一条字段时的不适感是特性而非 bug"——这正是防止配置蔓延的手段:只有在确认会被用到时才新增配置;实验性配置先通过 context 字段试验,验证想法后再落地为正式类型。事实证明这套哲学在走向 1.0 的近一年半里有效:只产生了一个子配置 FSConfig

为什么 ModuleConfig.WithStartFunctions 默认是 _start

wazero 曾提供类似 StartWASICommand 的函数来校验前置条件并启动 WASI 的 _start 命令,但它造成困惑:许多语言都编译了 WASI 依赖,且行为不一致。核心冲突是"鸡生蛋问题"——导出的函数需要语言运行时(如 GC)提供的特性,而 _start 必须执行完,导出行为才能工作。例如:与 Go 1.21 的 GOOS=wasip1(不支持函数导出)不同,TinyGo 的 "wasi" target 支持导出函数,只有经 "wasi" target 走 FFI 风格才可行;若不先执行 _start,wapc-go 这类 ABI 会因初始化缺失(如实现 panic)而崩溃。Envoy 等嵌入方也出于同样原因调用 _start

于是 wazero 将 _start 默认加入 ModuleConfig.WithStartFunctions。需要多个初始化函数时(如 wapc-go),用户可以在 _start 之后追加其他初始化;想完全掌控 _start 的用户(如部分单元测试)可以清空 start functions。

这一 2022 年的决策到 2023 年依然成立,即便 "wasix" 出现也一样——因为 wasix 向后兼容 wasip1。但 WASI "Preview 2" 并非按与 wasip1 兼容的方式实现,其 start 函数很可能不同并定义于 wasi-cli "world"。文档给出的承诺是:wazero 未来会尝试支持 wasip2,但不会以破坏既有编译器的方式去做——如果 wasip2 用了另一个函数名,wazero 不会移除 _start;最可能的情形是把新的启动函数追加到 start functions 列表中,与 _start 并存。

Runtime == Engine + Store,为什么不支持多 Store

wazero 用一个用户类型合并了规范中的 Store 概念与未定义的 Engine 概念。不支持多 store 是因为多一层会复杂化生命周期与加锁;而且在实践中"一个 engine 有多个 store、每个 store 有多个 module"并不常见——更常见的是"1 engine + 1 store + 多 module",或"1 engine + 多个 store,每 store 1 个非宿主 module"。最坏情况下,用户可以用多个 runtime 来等效。若将来真有需求,可用重载实现,例如 Runtime.InstantiateInStoreRuntime.Store(name) Store

退出(Exit)语义

为什么只在非零退出码时返回 sys.ExitError

直觉上,即便退出码为零(成功),也应当返回退出错误,因为模块已不再可用(后续的函数导出会报错)。但 wazero 不这么做:sys.ExitError 只在出错(非零)时返回。

原因是性能与易用性权衡——对既用 WASI(带 _start)又允许自定义导出的 guest 而言:Rust、TinyGo 及常规 wasi-libc 在 _start 期间并不会退出模块;若它们退出,函数导出就会失效。而 Go 1.21 的 GOOS=wasip1 会在 _start 期间退出,但它本就不支持除 _start 之外的导出,且 _start 本就不应被多次调用。既然并非总是返回 sys.ExitError,wazero 增加了 Module.IsClosed 供防御性检查,帮助集成方避免调用注定失败的函数。

为什么宿主函数退出后要用 sys.ExitError panic?

目前,停止代码执行的唯一可移植手段就是 panic——WebAssembly 的 trap 指令(如除零)就是这样实现的,确保其后不再有代码执行。当代码执行到 WASI 的 proc_exit 指令时也必须停止处理:无论退出码为何,退出后任何被调用的代码都会处于不一致状态(这也是为何 emscripten 有时会在 exit 后插入 unreachable 指令的原因)。因此 panic 携带 sys.ExitError 是一种有意的机制,而非缺陷。

WASI 语义:wazero 如何解释"未正式定义"的规范

文档开宗明义:WASI Snapshot Preview 1 定义得不够正式,部分 API 语义存在歧义,不同运行时解释不同,进而影响 WASI 应用的可移植性。以下小节记录了 wazero 的具体取舍。

为什么在用户自定义 fs.FS 上不承诺 wasi-testsuite 兼容?

os.File 基础上实现大多没问题,但对 os.DirFS 的用户自定义包装,wazero 不承诺 wasi-testsuite 兼容;真实系统上的唯一选项是使用其 sysfs.FS。原因包括:即便有 os.File 抽象,Windows 仍有大量行为差异(远超文件锁问题,例如 ACCESS_DENIED 不能正确映射为 EPERM);FileInfo.Sys() 返回的信息不足以构建 WASI 需要的 inode——重建 inode 需要底层文件的完整路径而不仅是目录名,os.File 无法提供;一旦尝试,只读包装等功能便会纠缠不清;最后还有 Go 版本相关的行为差异(例如 Go 1.20 打开文件的方式与更早版本不同)。

为什么 WASI 规则不被强制?

snapshot-01 对"command module"有多条规则,但 wazero 只强制 memory 导出规则:若存在 _start 函数,则强制其签名正确并能成功,但导出本身不被强制;导出不被要求局限于 _start 调用之内;__indirect_function_table 导出同样不被强制。原因是实现方没有遵守规则——例如 TinyGo 不导出 __indirect_function_table,若在此崩溃 wazero 就无法运行 TinyGo 模块;wapc-go 加载的模块也不总是定义 _start。既然 snapshot-01 不是正式版本、更不是 W3C 推荐,为这些事破坏用户毫无意义。

为什么 I/O 配置不与 WASI 耦合?

WASI 本质上是"定义一个宿主函数来访问系统接口(如写 STDOUT)"这一实践的正式化。它在 snapshot-01 停滞,且截至文档时间正在被整体重写。这种不稳定性要求 wazero 具备在 WASI 各规范间过渡的能力,因此系统配置必须集中且解耦——否则若同一 fd 编号经两个不同函数调用 fd_write,就会写往不同位置。

所以 wazero 把系统配置定义在 ModuleConfig 中而非某个 WASI 类型里。这让终端用户能以最小影响从一个规范切换到另一个,也让集中资源可以被一致地关闭(如经 Module.Close)。从后续更多 ABI 得以在 wazero 中使用的实践来看,这一决策是对的。

ModuleConfig 设计背景:以 exec.Cmd 为类比

WebAssembly 1.0(20191205)只规定了模块间隔离的一部分(如 wasm.Memory 有尺寸约束、实例彼此隔离、默认不导出——事实上一个 Wasm 模块默认没有 memory)。而更多方面是未定义的。文档用 exec.Cmd 类比:WASI 命令本质上就是带 _start 的模块,类似带 main 的进程;在 exec.Cmd 中捕获写到控制台的 "hello world" 只需设置 Stdout 字段(比如指向 buffer),而在 Wasm 1.0 中只能通过宿主函数(如 HostModuleFunctionBuilder)再内部拷贝内存来实现。

WASI 用宿主函数实现系统接口:WASI command Modulewasi_snapshot_preview1 导入 fd_write,并以 fd=1(STDOUT)调用。snapshot-01 没有声明配置的途径(尽管其函数定义隐含了配置,如 fd 1 是否应存在、写往何处),且规范处于重写中——因此 wazero 把配置放在顶层 ModuleConfig。为保证默认隔离,ModuleConfigWithXxx 都把默认值覆盖为 no-op 或空;同一份 ModuleConfig 无论 WASI 函数被导入多少次都只用一个实例;nil 默认值在并发时安全且"从不用就不花钱"。最后,ModuleConfigModule 一对一,模块可代为关闭配置,避免再给用户一个令人困惑的关闭 API。STDIN、Environ 等项的命名、默认值与校验刻意贴近 exec.Cmdsyscall.SetEnv 等 Go 库习惯,但也不盲目照抄——例如 exec.Cmd 默认从真实 fd("/dev/null")读 STDIN,直接继承会带来耗尽 fd 的隐患,故 wazero 改为读 io.EOF。结论是:ModuleConfig 重在"模拟",而非必然执行真实系统调用。

文件系统抽象:为什么再造一个 sys.FS

动机

sys.FS 的诞生源于 Go 标准库 fs.FS/fs.File 的局限:面向 wasip1 的编译器可能访问"写新文件"的功能,而这在 Go 标准库抽象下长期不可得。从 2021 年 3 月的 issue #21 开始(早于 wazero 命名),到 golang/go#45757,再到 2022 年 3 月汇总的 #390 与后续 #1013/#1532,用户关于文件拦截(masking access)与"创建虚拟或真实新文件"的诉求持续多年。

技术根因是:Wasm 模块实例是一台虚拟机,仅支持 os.File 会破坏沙箱用例;而 os.File 不是接口,无法实现拦截。因此必须暴露一个接口层(而非只暴露真实文件),才能让用户拦截 I/O 操作。

为什么 sys.File 没有 Fd() 方法?

看似可以暴露底层 fd(便于接入 poll 等多路复用系统调用),但文档给出了多组理由。其一是用户等待文件抽象已超过两年,边缘特性不值得继续拖延交付。其二是实现难度:Go 自己都没有抽象 fs.FdFile,因为 fd 是常见特性的实现细节——os.DirFS 有底层 fd,而 embed.FS 没有;os.FileFd() uintptr 返回值并非抽象且不安全(部分 os.File 内部实际使用未导出的 poll.FD,plan9 又用不同类型)。若给 sys.File 暴露 Fd(),wazero 不仅要声明 Go 描述的全部边界情况(包括 finalizer 影响),还要在虚拟文件语境下复述它们,并与虚拟化的 sys.FileTable、GC 影响协同推理——最终可能走向一条放弃文件系统抽象、退化为 syscall 弱键映射的歧途,而初衷仅仅是"允许文件写入"!

文档由此总结出 wazero 试图"做得比 Go 语言团队更多"时必须审慎评估的三条准则:

  • 至少要能对 os.File 支撑的文件实现;
  • 对虚拟文件系统与常规使用不得造成困惑或认知负担;
  • 代价可控:自定义代码完全由核心团队(远小于 Go 官方维护者群体)负责。

基于这些,"大概率永远不会在 sys.File 上暴露 Fd()"。

为什么 sys.FilePoll()sys.FS 没有?

File.Poll 面向用户反复要求的"单文件、单次"轮询用例——包括 Go 1.21 GOOS=wasip1 的抽象测试,以及 python、container2wasm REPL、监听 socket 等真实场景。单个文件没有 head-of-line blocking 风险,即便模拟也无妨。而多文件轮询的典型场景是双向网络服务,目前 wasip1 标准库还用不到。逐文件循环 File.Poll 会引入 head-of-line blocking(尤其 timeout=-1 无限阻塞时),所以确实有理由考虑多轮询 API——但这不需要导出 File.Fd()。若未来多轮询变得关键,sys.FS 可以暴露一个类似如下的 Poll

ready, errno := fs.Poll([]sys.PollFile{{f1, sys.POLLIN}, {f2, sys.POLLOUT}}, timeoutMillis)

真实文件系统可仿照 Go 内部 unix.Poll 传 fd,或在不受支持的 OS 上返回 sys.ENOSYS;虚拟文件实现则可围绕超时做策略以规避无限超时的 worst case。文档的立场是:为一切建接口并非最佳实践(Go 也没在 os.File 里暴露 poll.FD);这类多轮询可先限于仓库内建文件系统,从而在不抽象化、不泛测的前提下保留 CLI 多路复用能力。

为什么 wazero 不实现"工作目录"?

早期 API 曾含 WithWorkDirFS,用于独立于根文件系统控制 "./config.yml" 这类相对路径的解析目标,但失败了并被移除。原因:面向 wasm 的编译器对工作目录行为各异——wasi-libc(TinyGo 所用)把工作目录变更记录在编译出的 wasm 里(初始为 "/",直到代码调用 chdir);Zig 则假定第一个预打开(pre-opened)的 fd 就是工作目录。wazero 能标准化的唯一分层点只在宿主函数上,而 WASI 不用宿主函数追踪工作目录,因此其存储与初始值无法被统一。可行的规避是:在编译进 main 的代码里调用 chdir,或通过参数/ENV(如 PWD)传入初始值;无法控制编译产物的用户,则应在配置中只使用绝对路径。

为什么忽略 io.Reader 在 n>1 时返回的 error?

依据 io.Reader 约定:收到 error 时先处理已读到的字节。在 fd_read 这个 syscall 抽象层,调用方就是处理器,无法既内联处理字节又返回 error(EIO)。若想把字节连同 error 一并交回调用方,就必须至少暂时忽略 error。选择只剩两个:把 error 挂到 fd 上等下次调用,还是直接忽略。若为 fd 记录"上次错误",要回答它是否瞬时/永久、是否适用于一切 fd 操作等问题,何况可能根本没有下次读取。文档的判断是走最简路径:返回已读字节、忽略错误,假定后续操作若有必要自会报错——这既降低复杂度,也照顾了"已读字节足以满足处理器"的场景。

文件描述符分配策略

当前分配策略与 unix 相似:打开文件时选取最小的未用编号。WASI 标准说明程序不能指望 fd 编号按"最小优先"分配,而应假定是随机的——但"随机"在计算机里是很不精确的概念,这一策略在技术上已满足实现要求。理论上可给选择过程加更多"随机性",但绝不应作为防止应用猜测下一个 fd 号的安全手段,因而没有强动机去复杂化它。

为什么 FSConfig.WithDirMount 不模仿 os.DirFS

直觉上,任何长得像 Go 标准库的特性都应行为一致,以求对 Go 开发者"最少惊讶"。但文件系统方面 wazero 主动打破这一点,因为这与 WASI(被实现最广的文件系统 ABI)的预期冲突。根本差异是:os.DirFS 是虚拟文件系统抽象,WASI 是系统调用之上的抽象。例如 fs.Open 的签名不允许传 flags,而 path_open 可以传 flags——测试甚至要求 flags 被以特定方式遵守。冲突必须选边:文档判断,把代码编译到 wasm 的开发者数量将多于自定义文件系统插件的开发者,而前者更受益于与 WASI 兼容,因此冲突时优先 WASI 行为。

Readdir:为何更像 Go 的 os.File 而非 POSIX readdir

wazero 曾尝试迁移到类似 POSIX DIR 结构的设计(暴露 telldirseekdirreaddir),最终选择更像 os.File.Readdir,因为它在性能与 wasip1 适配性上更好。

  • wasip1/wasixfd_readdir 类似 Linux 的 getdents 而非 POSIX readdir,而 getdents 更接近 Go 的 os.File.Readdir。内部类型 sys.DirentCache 目前只服务 wasip1/wasix,待 HostModuleBuilder 支持实例化状态后可迁往 wasi_snapshot_preview1 包。
  • wasip2:wasi-filesystem preview2 的 directory-entry-stream 定义在 component model 中而非 ABI 层,在 wasmtime 中是一个"消费型迭代器"——用任何单步读取(如 Readdir(1))都易支持,只是不能批量读或跳过。preview1 adapter 的实现印证了这一点,他们用了与我们 sysfs.DirentCache 相似的 dirent 缓存。preview2 没有 seek 概念,无缓存时 cookie 被当作数字解释并重复读取条目。截至文档时间 wasip2 尚未完成,设计讨论被推迟到其稳定且参考实现 wasmtime 落地之后。
  • wasip3directory-entry-stream 预计从同步走向同步/流式并发生巨变,与同步的 POSIX readdir 差异显著;但 wasip3 在 wasip2 之后(2024 年或更晚),同样留待稳定后再议。

如何用 fs.File 实现 Pread

ReadAtpread 的 Go 等价物——不改变也不受底层文件偏移影响。可惜并非所有 fs.File 都实现 io.ReaderAt(例如 Go 1.19 的 embed.openFile 就不实现)。初始实现用 Seek,为避免回归,在不支持 io.ReaderAt 时回退到 io.Seeker:先取初始偏移、seek 到目标读偏移、读后再复位。若最后的复位失败,文件偏移将处于未定义状态,且这不具备线程安全性。虽然"每次读取都 seek"看似昂贵,但 embed.openFile 这类常见情形只访问单个 int64 字段,代价很低。

预打开(pre-opened)文件与 fd_prestat_dir_name

WASI 通过 fd_prestat_getfd_prestat_dir_name 让程序获知初始化时已打开 fd 对应的目录路径。例如 wasi-libc 的 __wasilibc_register_preopened_fd 会扫描 STDERR(1) 之后的 fd 并调用 fd_prestat_dir_name;Zig 的 preopensAlloc 类似。这些预打开函数只在初始化后不再使用。wazero 支持"stdio 预打开 + 各挂载点",例如 .:/;guest 路径是目录及其名称,例如 fd 3(STDERR+1)经 fd_prestat_dir_name 返回 "/"。多个预打开时按"最长前缀优先匹配",使 "/tmp" 无论顺序都能优先于 "/" 命中。

fd_prestat_dir_name 有三个参数(fd、结果写入偏移 pathpath_len),其中第三个语义含糊。wazero 的 FdPrestatDirNamepath_len精确长度写入结果;Wasmer 则把 path_len 当最大长度。wazero 遵循 wasmtime 语义——当 path_len 等于 path 实际长度时二者一致,因此实际差异通常无关紧要。

fd_readdir:为什么 wasi_snapshot_preview1 要求点条目而 POSIX 不要求

这一节是文档中罕见的"标准博弈史",值得完整还原:

  • 2019 年 10 月,WASI 项目就已知晓要求 "." 与 ".." 点条目既未在 preview1 中成文、也非 POSIX 要求、更难以合成(例如基于 Windows FindNextFileW 的运行时无法返回它们)。
  • 一年后打出的 snapshot-01 标签并未采纳"点条目可选"的修改。此后 phases/snapshot/docs.md 多年被显著改动,常与 wasmtime/wasi-libc 亦步亦趋。结果是 ABI 与行为长期不稳定,snapshot-01 无法作为可移植性的有效基准;测试套件的呼声早在 2019 年 4 月就已出现,合规却始终无从谈起。
  • 2022 年 11 月 wasi-testsuite 项目启动并固化预期,很快反过来驱动运行时与规范文档的改动:WASI 开始把 wasmtime 的测试导入为所有运行时的强制行为。某些改动波及 wasi-libc(例如 readdir 开始隐式触发 inode fan-out,造成性能回退)。2023 年 1 月合并的一个测试要求点条目——测试在未对任何运行时运行的情况下被合并,即便临时运行也只针对 Linux,因此三年前就预警过的可移植性问题直到测试 Windows 的 wazero 才发现。
  • 同月 wazero 请求撤回该改动(Go 的 os.ReadDir 不返回点条目,而合成它们又因测试同时要求 inode 而复杂化;且点条目不仅被 Go 丢弃,也被其他主流语言丢弃),被 preview1 的 WASI 负责人拒绝,但被纳入完全不同的 preview2 ABI 讨论。
  • 2023 年 2 月,WASI 主席宣称该规则"由子组整体决定",依据是会议纪要——但纪要里 WASI 负责人错误地声称 POSIX 合规要求返回点条目(POSIX 明确称其为可选),并要求无人反对;co-chair 表示"因存在既有 P1 程序,不应做此类改动"。此后无其他记录在案的意见。

文档结论一针见血:preview1 被追溯性地改为要求点条目、preview2 被改为要求其缺席,该规则是从 wasmtime 测试反向工程而来,并建立在两个错误前提上:POSIX 合规要求点条目(POSIX 明文其为可选);WASI 因既有 P1 程序而不能改动(此前此后 preview1 都在改)。截至 2023 年 6 月,wasi-testsuite 仍只在 Linux 运行,Windows 上的合规交由各运行时自行决定验证。而 preview2 adapter 用假 cookie 0 与 1 指代点条目,给 "." 真实 inode、给 ".." 零 inode。

为什么 "." 与 ".." 条目麻烦?

目录条目内含 stat 信息(含用于比较文件等价性的 inode)。对 "." 可合成特殊条目暴露与 fd stat 相同的信息,但不同时做 ".." 会引发困惑,而 ".." 难得多:回填父目录 inode 昂贵且微妙——预打开(挂载)目录的逻辑父目录可能不同(例如同时挂载 "/" 与 "/tmp",实现 "/tmp" 下的 ".." 需要另一个预打开的状态);还有"根路径不返回 .."之类更易理解的边界。此外还有 Dirent.Off(Linux 手册称 cookie)问题:当宿主 OS 不返回点条目时,要支持 seekdir 仍需要每个条目的 Off 值;可朴素地用顺序值 0 与 1 合成,但 POSIX 要求把偏移当不透明值,真实文件系统可能用 0 表示真实条目从而冲突——于是只要合成任一条目的 Off,就须为所有条目合成(实践中最简单是递增编号,如 preview2 adapter 所为)。

而最关键的判断是:这一切开销会摊到 wazero 全体用户身上。截至 2023 年初,涉事的主流编译器是 TinyGo、Rust、Zig——它们编译的代码全部忽略点条目,因此伪造这些条目不但增加代码复杂度,还带来毫无必要的开销。除非终端用户或规范强制要求,否则没有理由做——而 snapshot-01 对此只字未提,POSIX 亦表述为"可选"。不幸的是 WASI 项目于 2023 年初在规范与 wasi-testsuite 中同时强制了点条目,仅为此原因,wazero 即便对多数用户毫无必要,也增加了合成点条目的开销。

为什么不为 ".." 预填充 inode?

只给 "." 填充 inode(wasi-testsuite 要求,且因缓存通常已有),不给 ".." 填充:wasi-testsuite 并不要求 ".." 的 inode(可能因为 wasip2 adapter 不填充它);wasi-libc 明确不会在零 ino 上对 ".." 做 lstat fan-out;而 Go 这类不使用 wasi-libc 的语言即使不需要 inode 也会白白付出 stat 系统调用代价(Go 干脆丢弃两个点条目)。结论:预取 ".." 的 inode 没有显著收益,反而有代价。

为什么不要求 inode 非零?

Dirent.Ino 不要求非零,因为强制反而可能阻碍后来经 Stat_t.Ino 解析出真实值。Ino 被定义为类似 POSIX 的 d_ino(不为零特判),它可能为零的原因包括:文件不是常规文件或目录;底层文件系统不支持 inode(如 embed.FS);目录不含 inode 但后续 stat 可以有(如 Windows);后端基于 wasi-filesystem(wasip2),其 directory_entry.inode 本就是可选的。零 inode 的副作用同样被详述:os.SameFile 等文件等价性工具失效;wasi-libc 的 wasip1 模式会调用 lstat 尝试取非零值(除非条目名为 "..");新编译器若用 Dirent.Ino 冒充 d_fileno 可能意外跳过零 inode 条目——尽管 Linux getdents 不要求 d_fileno 非零,BSD 的 getdirentries 则实现各异(OpenBSD 会返回零 d_fileno 的 dirent,Darwin 会跳过)。实际曾因此出过问题:Go 的 syscall.ParseDirent 对所有 GOOS=unix 共享,抽象时把 direntInod_fileno/d_ino 取数并在任一为零时丢弃,最终不得不为 GOOS=wasip1 特判,否则虚拟文件会被无条件跳过。

综上,与其为一个罕见场景复杂化实现并强制非零 inode,不如允许为零,并把研究结论完整文档化,供新兴编译器复用与引用。

时钟:sys.Walltimesys.Nanotime

sys 包定义了两个函数类型 WalltimeNanotime,分别对应墙上时钟与单调时钟导出,命名沿用 Go 惯例(time_now 即同时调用二者):

func time_now() (sec int64, nsec int32, mono int64) {
	sec, nsec = walltime()
	return sec, nsec, nanotime()
}

分开的函数让实现方可选择"一次实现两个时钟(如 Go 那样)"或拆开实现。每个都可配置 ClockResolution(虽通常不准确,见下)。暴露它只为满足 WASI。

为什么默认使用假时间?

WebAssembly 内含着基于 capability 的安全设计范式。默认假时间可降低计时攻击风险,代价是用户须显式配置才能接入真实时钟。示例攻击研究可参考 "Fantastic Timers and Where to Find Them" 一类成果。

为什么假时间会在读取时递增?

假 nanotime 与 walltime 在每次读取时递增 1ms。尤其在 nanotime 场景,这能防止忙自旋(spinning)。

为什么不用 time.Clock

wazero 无法用 time.Clock 作为时钟实现插件,原因有三:其一,它只能用构建标志(faketime)替换,并把墙钟与单调钟在同一调用里混在一起(monotonic 是事后补入的);其二,Go 的时钟不是接口,而 WebAssembly 时钟导入没有同样的历史包袱——事实上 Go 自己的 wasm 导入就把 walltime 与 nanotime 分开读取;其三,追求确定性或安全性的 WebAssembly 用户需要能替换宿主进程之外的替代时钟实现。

ClockResolution

时钟分辨率依赖硬件与 OS,本应经系统调用获取精确值,但 Go 不提供获取分辨率的函数,无 CGO 时没有便捷途径取真实值。当前实现返回固定值:realtime 取 1us、monotonic 取 1ns(假设 realtime 时钟通常比 monotonic 精度低)。未来可通过 OS+arch 特定汇编直接发系统调用改进——例如 Linux amd64 上 clock_getres 的 syscall 号为 229,且它不像取时那样高频,可以只用非 VDSO 的回退逻辑;Windows 侧则常参考 mingw-w64 找出对应 POSIX 方法的 Windows API。写汇编的代价是需要覆盖大量 OS×arch 组合。

sys.Nanosleepsys.Osyieldsys.Stat_t

sys.Nanosleep

主流语言都有 sleep 机制,典型实现是经 WASI poll_oneoff 的相对时钟订阅——例如下面的 Zig 代码最终会调用 wasi_snapshot_preview1.poll_oneoff

const std = @import("std");
pub fn main() !void {
    std.time.sleep(std.time.ns_per_s * 5);
}

TinyGo(-target=wasi)与 Rust(--target wasm32-wasi)亦然。wazero 暴露 sys.Nanosleep 允许覆盖这一常见路径的实现(即便 Go 自身不用),因为它提供了一个对常见程序功能的简易高效闭包;同时文档以 sys.Nanotime 提醒用户:部分编译器不优化 sleep。

sys.Osyield

sys.Osyield 允许用户无需重新编译 wazero 即可控制 WASI sched_yield 的行为,主要为了与其他"允许用户实现"的特性(如 sys.Nanosleep)保持一致。与它们不同,wazero 不提供开箱即用实现——因为访问它可能带来性能问题。例如一个经 CGO 的实现每次调用可能造成约 1us 延迟:

//go:noescape
//go:linkname osyield runtime.osyield
func osyield()

在实践中,直到线程相关函数落地前,定制它的请求不大可能出现;文档列出 wasi-threads、stack-switching 等早期信号作为前瞻依据。

sys.Stat_tsys.EpochNanos

sys.Stat_t 类似 syscall.Stat_t,但定义时不受构建约束——例如可在未定义 syscall.Stat_tGOOS=windows 上使用。首个用例是从 fs.FileInfo 返回 inode 而不依赖平台细节:用户可从 info.Sys() 返回 *sys.Stat_t,为虚拟文件定义非零 inode,或将真实 inode 映射为虚拟 inode。若干字段约定与 syscall.Stat_t 不同或需澄清:数字字段统一 64 位(至少一个平台如此定义);零值等价于 nil 或缺失;Dev/Ino 定义为无符号(不透明,多数 syscall.Stat_t 亦然);Modefs.FileMode(非 POSIX 定义,无法覆盖全部值,但可复用 Go 惯例经验);NLink 无符号(链接数不可能为负,未知时建议默认 1);Size 有符号(稀疏文件可能返回负值);Atim/Mtim/Ctim 有符号(1970 年前为负,纳秒分辨率)。

时间戳字段用 sys.EpochNanos(int64 的类型别名)而非 time.Time,首要原因是转换代价:wasip2(及兼容的 wasix)在内存中以 64 位整数编码时间戳,若用 time.Time 就得把 syscall.Timespec 转成 time.Time.UnixNano() 转回 64 位。尽管未来 component model 的 wasi-filesystem 可能共享 wasi-clocks 的结构化时间戳类型,canonical ABI 大概率仍是两部分组合,届时可用 syscall.NsecToTimespec 之类工具在不分配结构体的情况下写入内存。文档还幽默地指出:32 位 epoch 秒有 "Year 2038" 问题,而 epoch 纳秒有 "Year 2262" 问题——对库来说更不值得担心。

poll_oneoff:WASI 的 I/O 多路复用

poll_oneoff 用于在多个句柄上等待 I/O 事件,概念上类似 POSIX poll(2)。名字不叫 poll,是因为规范强调"对同一大句柄集重复使用该函数并不高效"。wazero 只在若干适用于常规文件与标准输入的场景支持它,目前不支持 socket 句柄等其他 fd 类型。

时钟订阅

相对时钟订阅(如 sleep)在多数情况下经 sys.Nanosleep() 实现,唯一例外是轮询来自 os.Stdin 的交互输入(见下)。

FdRead/FdWrite 订阅

对除 Stdin 外的 fd 订阅读或写时,实现通常立即返回成功——除非 fd 未知。fd 不会被进一步检查是否有新数据;任何超时都被取消,调用得以返回——除非存在 Stdin 订阅,那会单独处理。

对 Stdin 的 FdRead/FdWrite 订阅

订阅 Stdin 读(写无意义会报错)需要额外小心,因为 wazero 允许为 Stdin 配置自定义 reader。若检测到自定义 reader,行为与常规 fd 相同:假定数据存在并向结果缓冲区写回成功。但若检测到 reader 来自 os.Stdin,则走特殊代码路径,调用 sysfs.poll()——它是 POSIX 上 poll(2) 的包装,在 Windows 上被模拟。

POSIX 上的 Poll

POSIX poll(2) 可等待 fd 上的数据并在数据可用或超时前阻塞。sysfs.poll() 只保留给标准输入,因为:① 处理交互输入确实必需——否则 Go 无法在不实际读取(消费)数据的情况下窥探标准输入;② 若 Stdin 连的是管道,多数情形直接返回成功即可;③ sysfs.poll() 是阻塞调用(底层 syscall 阻塞,与 goroutine 无关),应限制使用。因此仅当订阅目标是 os.Stdin 且句柄对应交互会话时,才以 Stdin 句柄与超时调用 sysfs.poll()——这也意味着此情形下超时不可中断,除非 Stdin 本身有数据到达。

Windows 上的 Select 模拟

Windows 上无法把 sysfs.poll() 委托给单一系统调用(socket、管道、常规文件没有统一 syscall),wazero 便针对当前关注场景模拟:常规文件一律报告就绪(多数操作系统本就如此);管道则对每个判定为"以读方式打开的管道"句柄调用 PeekNamedPipe(暂忽略只写管道);socket 使用 WinSock 的 WSAPoll 实现,但不依赖其超时参数——而是设 0 超时使其表现为 peek。这样可在函数开头一次性检查常规文件,再用可取消的 time.Tick 周期性轮询管道与 socket,与 Go 运行时其余部分融洽协作。

阻塞的影响

因为这是阻塞系统调用,会阻塞 goroutine 的载体线程,使 context 取消无法直接支持。文档勾勒了一个未实现的缓解思路:向集合中加入信号 fd(如管道读端或 Linux eventfd),context 取消时向 fd 写入即可让 Select 立即返回——但这需要做点收尾工作把"特殊 fd"从终端用户面前藏起来。

全局常量初始化的有符号整数编码

整数全局常量初始化器按有符号编码,因为其解释在声明时不可知——例如并不存在"有符号整数值类型"。以下声明中的常量在无符号与有符号 LEB128 下位模式一致:

(global (export "start_epoch") i64 (i64.const 1620216263544))

但有些数字不是:16256 无符号编码为 807f,有符号编码却是 80ff00。规范虽然称抽象整数为无符号值,二进制编码却明确为有符号。为保持一致,wazero 在全局常量初始化器这一特例上采用有符号编码。

实现限制:2^27 背后的内存账

WebAssembly 1.0 规范允许运行时对模块与执行施加限制,wazero 的限制是务实的,核心数字统一为 2^27(134,217,728):

  • 模块内函数实例数:规范未限定(funcaddr 可任意多)。wazero 限制为 2^27,因为仅函数指针就要占 1GB。
  • store 内函数类型数:规范不限。wazero 给每个函数类型分配唯一 ID 并用 uint32 表示,因此上限 2^27——仅引用函数类型就需 512MB。
  • 函数内栈值数:规范未明确。wazero 把所有值(含 f32/f64)内部表示为 64 位整数,2^27 个值即 1GiB;作为参照,goroutine 在 32 位架构上的栈上限约 250MB,而 Wasm 目前是 32 位环境。所有函数在模块实例化阶段被静态分析,可能触达该上限的函数会返回错误。
  • 模块内全局量数:理论上可达 2^32。wazero 限制每模块 2^27——内部把全局量存于指针类型切片(64 位平台每项 8 字节),2^27 项即 1GiB 切片。
  • 模块内表数:规范允许 2^32 张表,wazero 限制 2^27:仅指针表就占 1GB,且编译器实现以 32 位有符号偏移访问表切片,2^27 × 8 字节 = 2^30 字节偏移恰在可表示范围内。触达此限制的模块会在编译阶段返回错误。

共同理由:相信所有真实用例都远低于此,且在这些异常规模下没有测试手段。

编译器引擎实现:为何与异步抢占安全共存

Go 运行时的 goroutine 抢占分协作式与异步式。协作式发生在函数调用等点,对运行时生成的函数无碍(它们不直接调用 Go 实现的函数);异步抢占则可能在函数任意点打断执行并操纵 CPU 寄存器,理论上危险。但 wazero 生成的机器码不需要考虑异步抢占:所有汇编代码都经 Go 汇编函数实现的 trampoline 进入,而从 Go 1.20 起这些汇编函数对异步抢占被视为"不安全"——从 Go 运行时视角看,运行时生成机器码的执行被当作 trampoline 函数的一部分,因此同样正确地被视为不可异步抢占。

为什么 context 取消在 Go 代码里而非原生代码处理

自 v1.0.0-pre.9 起,wazero 支持用 Go context 在超时或显式取消时中断执行。内部实现为特殊操作码 builtinFunctionCheckExitCode,它触发 Go 函数(ModuleInstance.FailIfClosed)在代码的策略性位置原子检查一个哨兵值。直接检查哨兵值(不离开原生世界)确实省若干周期,但因为原生代码从不抢占(见上节),这可能导致其他 goroutine 永远得不到执行机会——也就永远没机会设置哨兵值,取消将无法生效。因此取消必须回流到 Go 侧处理。

Golang 工程模式:hammer 测试与无锁跨 goroutine 观察

Hammer 测试

使用锁或原子等并发原语并发代码都应配 "hammer 测试":在受限于数量的 goroutine 中跑大循环,用其一半作为 GOMAXPROCS,命名统一含 "hammer" 便于查找。注解版要点如下:

  1. P 是 goroutine 数,默认 8(testing.Short 下 4);一半作为核数,4 小于现代笔记本 CPU 数,允许多个 hammer 测试并行。
  2. N 是每 goroutine 的工作(循环)规模,默认在 ~0.1s 内完成(存疑时用 1000,Short 模式用 100);CI 节点慢,慢测试会拖累反馈。
  3. defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(P/2)) 让 goroutine 换核,检验共享数据的可见性。
  4. 用初始化为 Add(P)sync.WaitGroup 把 goroutine 挡住,使其同时开跑。
  5. finished := make(chan int) 跟踪进度,每个 goroutine defer finished <- 1;测试用 require.XXX,故在 finished <- 1 前的 defer 中 recover()t.Fail,以便一次看到每个失败而非只看到第一个。
  6. 全部 P 个 goroutine 就绪后,用 WaitGroup.Add(-P) 原子放行。
  7. 阻塞等待每个 goroutine 的完成信号。
  8. 全部完成后若 t.Failed() 则 return,否则做后续状态检查。

无锁、跨 goroutine 的更新观察

wazero 使用原子操作实现跨 goroutine 读取的"非官方实践"(go.dev/ref/mem 未明确定义此场景):例如 Close 可借对零值的 compare-and-swap(CAS)保证只发生一次;使用该模式时一律对同一数字字段同时用原子读与原子写。其依据除测试外还有两点推断:sync.WaitGroup 按定义必须支持从其他 goroutine 调用 Add(内部用原子);以及 Go 官方成员 "atomics guarantee sequential consistency among the atomic variables" 的论断。

在本仓库中的实际定位

回到本仓库语境:wazero 以间接依赖形式存在于根 go.modv1.11.0 // indirect),完整 vendored 于 vendor/github.com/tetratelabs/wazero/。它的下游消费者至少有两个:

理解 RATIONALE 中的每一处取舍,有助于在阅读这些消费者代码时判断其行为边界——例如模块关闭与 IsClosed 语义、默认 _start 启动函数、文件系统挂载与 fd 分配策略等,均源自本文所述的设计决策。若需进一步核对声明与实现的对应关系,可继续深入 vendor/github.com/tetratelabs/wazero/config.govendor/github.com/tetratelabs/wazero/runtime.govendor/github.com/tetratelabs/wazero/fsconfig.go 及其 internal 目录下的 wasmenginesyssysfswasip1 等实现包。

登录后查看全文
热门项目推荐
相关项目推荐