WebAssembly 游戏开发技术专题全层级8 / 31 已发布

WebAssembly System Interface(WASI):走出浏览器的关键一步

· 0 分钟阅读
WebAssembly System Interface(WASI):走出浏览器的关键一步 WASI's Capability Model, Preview 1→2 Migration & the POSIX Trade-off — WebAssembly 游戏开发技术专题

WebAssembly System Interface(WASI):走出浏览器的关键一步 WASI's Capability Model, Preview 1→2 Migration & the POSIX Trade-off

为什么 WASI 是走出浏览器的第一块基石

2018 年,我在一家小工作室做横版闯关游戏。桌面版跑得挺欢,老板一句“要不上个网页版”,我们把 C++ 代码丢进 Emscripten,半小时编译通过,浏览器里帧率六十,粒子特效一个不少。我当时真觉得自己今天能准点下班。

然后我点开存档界面,按了“保存”。

没反应。不是崩溃,不是报错弹窗,是更让人心里发凉的那种——那个“写入文件”的动作,在浏览器里根本不存在。浏览器给了你一块线性内存、一张画布、一个发网络请求的出口,剩下的什么都没有。你写进去的东西,刷新页面就蒸发了。

那一刻我才想明白一件事:Wasm 解决的是“代码怎么跑得快”,而“程序怎么跟外部世界打交道”是另一个问题,而且是个更底层的问题。你有一台马力很足、油耗很低的发动机,但车还没装方向盘、没装油箱盖、没装车门锁。

WASI 就是那个方向盘。全称 WebAssembly System Interface,直译“WebAssembly 系统接口”。名字朴素得像一份政府文件,但它回答的是一个非常硬的问题:一个 Wasm 模块,凭什么可以读文件、看时间、发网络请求、拿随机数?凭什么是它,而不是凭它自己说了算?

这个问题是整条 Wasm 走出浏览器的路的第一块基石。因为在它被回答之前,Wasm 的定位极其清晰也极其憋屈:一个高性能的计算内核,永远寄生在 JavaScript 宿主里。它算得再快,也得由 JS 喂数据、由 JS 收结果、由 JS 决定它能不能摸到别的东西。这个关系里,Wasm 是宠物,不是同事。

WASI 干的事,就是把这层关系从“寄生”改成“契约”。你告诉宿主你需要什么,宿主决定给不给你,给多少,给多久。听起来像是加了一层 bureaucracy(官僚流程)——我第一次读完规范的时候确实这么想,觉得这帮人怎么把简单事情搞复杂了。用了几年之后我改主意了:不是他们把简单事情搞复杂,是我一直把“复杂”当成了“正常”。我们习惯了程序一启动就拥有整台机器的错觉,直到有一天我们要在服务器上跑几百个互不信任的玩家脚本。

这篇想聊四件事:WASI 的能力安全模型到底怎么回事(这是它的灵魂,其他都是细节);Preview 1 这个“能跑但一眼看出是临时工做的”版本坑在哪;Preview 2 和 Component Model 到底改了什么、迁移要付出多少;以及 WASI 为什么故意不兼容 POSIX——这一条对游戏开发者来说,反而是最需要提前想清楚的。

第 1 层:能力安全模型——你能碰什么,取决于我递给你什么

先讲一个特别容易被忽略的差别,这个差别贯穿全文。

传统 Unix 判断“你能不能读这个文件”,靠的是你是谁。你是 uid 1000 的用户,那你就能读 uid 1000 名下的一切,包括那个你自己都忘了存在的、里面躺着私钥的隐藏目录。这套模型叫做“身份决定权限”。它的隐性假设是:同一个用户跑的代码,都是可信的。

这个假设在过去三十年基本成立。你装的软件是你自己选的,你跑的脚本是你自己写的。但到了“玩家上传一个 mod”“第三方开发者往你的游戏服务器里塞一段 NPC 行为逻辑”这个时代,假设直接崩了。你没法给每个 mod 分配一个操作系统用户,也没法指望 chroot(改根目录)和一堆系统调用拦截能拦得干净——绕过的姿势太多了。

WASI 换了一套思路:不看你是谁,只看我递给你什么。学术界管这个叫 capability-based security,“能力安全”。我给它的白话版本是——

不是发给你一张能刷开整栋楼的门禁卡,而是把你要用的那几个抽屉,整个搬到你的工位上。你现在可以随便翻这几个抽屉,但你连这栋楼有几层都不知道。

具体到实现上,WASI 里没有“路径”这个概念给你随便写。宿主在启动模块的时候,会显式地把若干个目录“递”进去,这个动作叫预开放(preopen)。在这几个目录里面,你爱怎么翻怎么翻;出了这几个目录,你连它存在与否都感知不到。注意,这不是“拒绝访问”,这是世界只有这么大。区别很大:拒绝访问会告诉你“有这个目录但你没权限”,那是信息泄露;而能力模型里,那个目录在你的宇宙里根本不存在。

这个设计对应到玩家的真实体验是什么?举一个特别具体的场景:你做了一个关卡编辑器,想让玩家用脚本控制机关。玩家把脚本分享出去,别人导入。如果这套脚本跑在一个“拥有服务器权限”的进程里,一个写得恶劣的脚本就能把你的排行榜数据、甚至服务器配置读走。如果它跑在 WASI 沙盒里,你能递给它的只有一个“关卡资源目录”,那它就算通篇都在尝试翻别的地方,也翻不到任何东西——它没有那个概念。

这里有个经典案例,我第一次看的时候还挺震撼的:把经典 C 游戏 Doom 的引擎编译成独立 Wasm 模块,然后丢进 Wasmtime(一个服务端 Wasm 运行时)里跑。整个过程里最关键的一步不是编译,是启动参数——你必须显式预开放一个目录,把 WAD 资源文件放进去,游戏才能读到关卡、贴图、音效。如果你什么都不开放,游戏会在启动阶段直接死掉,因为它连“资源在哪”这件事都问不出来。这就是能力模型的默认姿态:默认为零,你得主动给。

反过来看,这个 Doom 模块即便被替换成了恶意代码,它能碰的也只有你递进去的那个 WAD 目录——你的私钥、你的数据库连接串、你的系统配置,全都不在它的世界里。它不是一个“被限制的进程”,它是一个“世界只有 20 平米的进程”。

但代价也必须说清楚,不然就成了吹。这种模型最反直觉的地方是:程序里写死的绝对路径,全部会失效。Doom 引擎里很多资源路径是相对“当前工作目录”写的,如果你的宿主没把那个目录“表示成”当前工作目录,游戏就找不到资源。这个坑我在 Preview 1 上踩过不止一次:开发机上跑得好好的,一进容器就白屏。查半天发现,不是 WASI 有问题,是我在开发机上恰好把目录放对了位置,而从没显式地“递”过它。

还有一个我想拿来当对照的例子,因为它把这个设计思路推到了极端:WASM-4,一个“幻想游戏主机”。它的规则是,你用任何能编译到 Wasm 的语言写一张“卡带”,跑在它的虚拟主机上。整台主机对外暴露的导入表窄得可怜——能在 160x160 的画布上贴图、能发声、能读手柄、能打一行调试日志,加起来几十个。没有文件系统,没有网络,甚至连“时间”都不是真的时钟,而是每帧固定 1/60 秒的节奏感。

这不是简陋,这是设计选择。正因为导入表这么窄,一张卡带可以在任何宿主上跑,可以在网页里跑、在桌面跑、在一台树莓派上跑,宿主换个实现它也不受影响;也正因为窄,一张恶意的卡带能造成的最大破坏,就是画点奇怪的东西在屏幕上。它用“不给”换来了“到处都能跑”。这句话请记住,后面讲 WASI 与 POSIX 取舍的时候还会用到。

所以能力安全模型这一层的核心,不是“加了很多检查”,恰恰相反,是把检查从运行时前移到了启动时,从“每次调用都要判断”变成了“这次启动你的世界就这么大”。前者昂贵、容易漏、且需要一整套身份系统来支撑;后者便宜、可证明、而且不需要“用户”这个概念存在。这是 WASI 敢在服务端跑几百个互不信任模块的底气。

第 2 层:Preview 1——一个能跑、但一眼能看出是临时方案的起点

讲完灵魂,讲皮肉。WASI Preview 1 是个什么水平的东西?我给它的评价是:它是一份“先让 C 程序编译通过”的救命文档,不是一份“我们要做十年”的长期设计。

它的设计取向非常明确——照着 POSIX 的形状来。文件用一个整数描述符表示,错误用一个整数错误码表示,复杂参数在线性内存里拼结构体传指针。为什么这么干?因为这样 C 标准库(libc)能最快地移植过去,现存的那一大堆 C/C++ 代码能最快跑起来。牺牲优雅换速度,这是个合理的工程决策。

但游戏开发者用起来,会撞到四面墙,我一面一面说。

第一面墙:网络接口从来没进过标准。WASI 里关于套接字的那组接口,长期停留在“提案/实验”的状态,各家运行时的实现还不一致。这意味着什么?意味着“WASI 上的联网游戏,在 Preview 1 里没有标准答案”。你想做一个跑在服务端的房间服务器,桌面版五行代码开个监听端口,到了 WASI 版本,你得写一个宿主插件、用宿主语言实现网络、再把结果喂进模块。这就像在自家院子里搭了个违章建筑:能用,结实不结实看你手艺,但搬家的时候带不走。

第二面墙:轮询和异步的语义各家不一样。游戏主循环里最需要的是什么?是“等一会儿”或者“等到有事发生”。这个动作在 Preview 1 里名义上有,实际实现差异巨大——有的运行时超时精度差到几十毫秒,有的对同时等待多个事件的支持不完整。你没法拿它当可靠的帧调度基础,最后还是老老实实靠宿主每帧主动推一次。

第三面墙:所有复杂参数都得在线性内存里手工对表。你要传一个路径加若干选项,得在模块的内存里按约定摆好结构体的字段顺序,把偏移量递出去,宿主那边按同样的布局读回来。这中间的“约定”没有机器可校验的形式,全靠两边人的默契。换个语言、换个编译器版本、换个运行时,就得重新对一遍。我见过最离谱的 bug 是两边对某个字段的宽度理解差了两个字节,结果读出来的路径永远差一个字符,找了两天。

第四面墙:没有标准的方式表达“这个接口是哪个版本的”。结果就是“WASI Preview 1”成了一个事实上的固定形状,谁也不敢动,动了全世界的 C 代码都得重编。这本身就说明它是个过渡品。

面对这些墙,行业里出现了两条路。一条是老实等标准,另一条是——自己造一个更贴合业务的接口。Ambient 就是一个很诚实的样本。这是一个开源的多人游戏运行时,思路是:游戏逻辑编译成 Wasm,跑在服务器上,客户端和服务器共用同一份逻辑代码。它压根没打算用 WASI 的网络和实体系统接口,而是重新定义了一整套面向游戏的 ABI(二进制接口)——实体怎么创建、组件怎么挂、消息怎么发。为什么?因为 WASI 那一套是给“通用的系统程序”设计的,而游戏服务器要的东西形状完全不同。

这个选择的收益是:接口贴合业务,写起来顺手,性能可控。代价也很直白:你的逻辑被锁在这个运行时里了。换宿主?重写。生态?从零开始。这其实是每一代平台都会面临的经典两难——用标准,忍受它不够合身;用私有,享受合身但从此被绑住。Ambient 的答案是“我先活下来再说”,我认为对一个小团队来说,这个答案不丢人。

顺便说个反面教材,用来体会“没有 WASI 的世界有多痛”。浏览器里的 Unity WebGL 导出,用的是 Emscripten 自己搭的一套内存文件系统,整个“磁盘”就是一块内存。存档怎么写?写进这块内存,然后调用一个由引擎提供的同步回调,把内存里的内容刷进浏览器的 IndexedDB。这个过程中任何一步失败——用户清了浏览器数据、隐私模式、存储配额满了——存档就没了。这不是哪个团队偷懒,这是浏览器没有 WASI 之后的必然结果:你得自己发明一个文件系统,再自己发明一个持久化层。我自己就丢过一次测试玩家的存档,那次之后我给所有存档加了“三重冗余 + 手动导出”的入口,现在想想那套设计其实挺丑的,但当时真的被吓到了。

Preview 1 到今天(写这篇文章的时候)还活着,而且活得挺好,大量生产系统还在用。别指望它消失。你要做的不是等它被替换,是把自己的 I/O 边界切干净,让替换变成一件几天就能干完的事。

第 3 层:Preview 2 与 Component Model——从“约定俗成”到“写进合同”

Preview 2 最大的变化,不是加了几个新接口,而是换了底层规则:模块和宿主之间怎么“对话”这件事,被重新设计了一遍。

以前是“约定”:两边的人坐下来,商量好偏移量怎么算、字段怎么摆,然后各自实现,靠测试保证对得上。现在是“合同”:用一门叫 WIT 的接口描述语言,把“我需要什么、我提供什么”写成一份机器可读的文件,工具链拿这份文件自动生成两边的胶水代码。谁也不用跟谁商量了,因为合同本身就是唯一的真相。

再往上一层是 Component Model(组件模型)。它引入了两个非常有用的概念:接口(一组有名字的函数集合)和世界(一个组件对外部的要求加上它自己提供的东西,合起来)。你把这两个写清楚,一个组件就能被任何支持这套规范的宿主加载,用任何支持的语言实现。

这个东西对做游戏的人意味着什么?我给你一个具体到能想象的场景。

你要给关卡编辑器做插件系统,让第三方写“机关行为”。以前你得写一份 PDF 文档,写清楚每个函数叫什么、参数什么顺序、返回值什么含义,然后祈祷别人按你说的做。做错了,轻则逻辑不对,重则直接把编辑器干崩。做了三年,你会发现那 60 页文档里有一半内容是勘误和“补充说明”。

现在你写一份 WIT:这个插件需要随机数、需要读当前的关卡状态、需要发一个“触发事件”。第三方拿这份合同,用 Rust 写也行,用 C# 写也行,工具链自动生成骨架,参数类型对不上根本编译不过。那 60 页文档可以扔了,因为不合法根本进不来。

这就是我为什么觉得 Component Model 对独立游戏生态的意义,比“又多了一个提案”要大得多。插件生态最难的不是写接口,是维护接口、是让一百个不同水平的人都能正确接进来。合同化能省下的沟通成本,是实打实的。

再看一个服务端的具体例子:边缘运行时上的 HTTP 组件。Fermyon 的 Spin 那一套,是目前把 wasi:http 用起来最顺的一条路。对游戏后端来说,它对应的场景非常清晰——排行榜提交、成就解锁、匹配队列入队这类逻辑,特点是“短、快、状态轻、调用量大”。做成组件之后,可以跑在离玩家最近的边缘节点,冷启动是毫秒级的。它和“开一台常驻游戏服务器”的成本差异,不是百分之几十,是数量级。你一个独立团队,全球五个区域的排行榜,不用买五台服务器。

但我要泼一盆冷水,泼得很实:组件模型的性能不是免费的。跨组件边界的值传递需要真的转换。Preview 1 里传一个字符串怎么办?递个指针,完事,几乎是零成本。组件模型里,字符串、列表、记录这些复合类型,需要把数据从一边的内存搬到另一边,还要做布局转换。对于高频调用——比如一个每帧要发起几百次的物理查询——这是灾难级别的。我见过有人把整条渲染指令流都当成组件间调用,结果帧率从 60 掉到 9。不是 WASI 慢,是那层边界不该放在那里。

所以边界该往哪切?我的判断标准特别土:看这个调用每秒钟会来几次。每秒钟几万次的,坚决不要跨组件,全放在同一个组件内部,让它变成普通函数调用;每秒钟几十次的,可以跨;每秒钟几次的(比如一次 HTTP 请求、一次存档落盘),随便跨,开销完全淹没在 I/O 里。

迁移成本这件事,我还得说个现实:看你的语言在不在名单上。Rust 目前支持最好,因为工具链是同一批人做的;C/C++ 通过 wasi-libc 的新版本正在补,能跑但边缘情况还在修;C#、Go 这些托管语言,还在路上。你要现在动手迁到 Preview 2,第一步不是看规范,是去确认你的主力语言能不能生成合格的组件。这听起来很没格调,但这是我踩过最多次的坑——规范很美好,工具链不认你,事情就卡死在那儿。

最后提一句很多人忽略的细节:因为 Component Model 把“接口”变成了可以静态检查的东西,它顺带解决了一个老问题——模块的意图可以被审计了。一个组件声明了“我要网络和文件系统”,审查者一眼能看到;而一个 Preview 1 的模块,你只能看它导入了哪些底层函数,中间隔着一层 libc 的抽象,能看出个大概但看不清细节。这个差别在“我们要不要上架这个 mod”这种决策上,价值巨大。

第 4 层:与 POSIX 的取舍——有些东西不是没做,是故意不做

每次聊 WASI,总有人问:“它什么时候能支持 fork?什么时候能支持信号?什么时候能真的跑现有程序?”

我的回答一般是:有些东西不会有了,因为它们是被有意排除的,不是待办事项。

我们把这些“故意不做”的东西摊开看,一个一个对到游戏开发的实际需求上,你会发现这个取舍其实相当聪明。

fork 和多进程:POSIX 里生一个子进程是家常便饭。WASI 里基本没有。为什么?因为在沙盒里,“复制一整个进程”这件事实在太难安全地表达——复制出来的子进程该继承哪些能力?原本递进去的目录句柄,复制之后算谁的?这些问题的答案都指向“复杂且容易出错”。而游戏里真正需要 fork 的地方有多少?少到几乎可以忽略。你要的是“并行做事”,那用线程;你要的是“隔离执行一段代码”,那再开一个 Wasm 实例——后者比 fork 便宜、比 fork 安全、还比 fork 快。

信号:同理。用信号做进程间通知,是一套非常古老的机制。Wasm 的模型里,模块和宿主是清晰的两层,通知完全可以用正常的接口调用表达,没必要引入一套“随时可能打断你”的机制。游戏里你会在哪儿用到信号?我一时想不出来一个非它不可的场景。

全局命名空间:这个是最容易被低估的一条。POSIX 里一切都在一个大命名空间里——文件系统只有一个根,环境变量只有一张表,谁都能读。WASI 的能力模型最讨厌的就是“全局”。所以环境变量这块,我的建议特别明确:能不给就不给。你一旦给了,等于给模块开了一个“读取宿主全部配置”的后门,因为环境变量天生是全局可读的。游戏服务器上的密钥、数据库地址,太多人喜欢往环境变量里塞。能力模型下,这类东西应该换成“显式递进去的一个只读值”,而不是一个谁都能翻的全局表。

下面这张表,是我建议所有做 Wasm 游戏服务端的团队都填一遍的——把“游戏真实需要什么”和“WASI 怎么给”对齐清楚,很多争议会当场消失。

游戏的真实需求POSIX 的做法WASI 的做法要注意什么
读关卡、贴图、配置资源按路径打开预开放单个资源目录,在该目录内自由读别把整个项目根目录递进去
写存档打开、写入、关闭预开放一个存档目录,写入后要显式同步“写成功”不等于“落盘了”,这条下面细说
每日重置、限时活动读系统时间墙上时钟与单调时钟分开给用系统时间做判定 = 送玩家一个改时间就能刷的漏洞
掉落随机、暴击判定读系统随机源专门的随机接口,宿主决定真假战斗判定用伪随机就够了,抽卡必须真随机
多线程物理线程库不属于 WASI,属于 Wasm 线程规范浏览器里还要先满足跨源隔离的两个响应头条件
联机对战套接字Preview 1 无标准,Preview 2 的 HTTP 已可用,长连接仍在演进先想清楚你要的是 HTTP 还是长连接,这两条路差别很大
执行玩家上传的脚本沙箱、chroot、受限用户,样样都可能被绕再开一个实例,什么都不递这才是 Wasm 在服务端最值钱的地方

表里“写成功不等于落盘”这一条,我必须展开讲,因为它是我真实翻过的车,而且是那种特别丢人的车。

早年我把一段服务器逻辑塞进 Wasm 跑,本地测试全绿,存档读写一切正常。部署到容器里之后,玩家反馈:退出再进,存档没了。我看日志,每一次写入都返回成功,一次错都没报。查了两天,最后发现真相是——宿主把那个目录映射到了一块内存盘上,进程一退出,内存盘整个消失。在我的视角里,我“写成功了”;在现实的视角里,我什么也没写。

这个教训的本质不是“WASI 有 bug”,而是能力模型下你必须显式地知道那个目录到底通向哪儿。POSIX 时代你写一个路径就完事,因为整个世界是共享的、是持久的、是别人维护好的;能力模型把这份“别人维护好的世界”拆掉了,只留给你一个句柄,句柄后面是什么,你得问清楚。说白了:以前你是房客,房子是房东的责任;现在你是房车车主,车停哪儿、油还剩多少,全是你的责任。

回到 WASM-4 那个例子上——它把“不给”做到极致,反而换来了最好的可移植性,一张卡带的执行结果在任何宿主上完全一致。WASI 的取舍是同一个道理,只是它需要在“能干活”和“够安全”之间找个位置,所以它不是什么都不给,是给得极其克制。克制的代价是你得多想几步,收益是你的代码可以在几百个互不信任的模块旁边安心睡觉。

参数化设计工具:两张能直接照着填的表

下面这两张表是我自己项目里在用的,直接抄就能用。前提说清楚:这是经验值,不是标准答案,你得拿自己的基准测试去校准。

表 A:游戏 Wasm 模块的能力配置清单

能力项默认策略建议区间踩坑提示
文件系统读取关,按需开预开放目录数 ≤ 3每多一个口子,攻击面线性增长;别把项目根目录递进去
文件系统写入关,只在需要存档时开只开放一个存档目录写完必须显式同步;确认那个目录是持久的,不是内存盘
网络出站关只给白名单内的目标给“任意地址”等于给了内网扫描能力
网络入站Preview 1 无标准走宿主自定义接口想清楚要 HTTP 短连接还是长连接,方案完全不同
墙上时钟谨慎给只在 UI 展示时给任何用系统时间做的逻辑判定,都是给玩家的作弊器
单调时钟给全程给帧计时、冷却计时一律走它,系统时间被改也不受影响
随机数给按用途分两档:战斗判定用伪随机,付费抽取用真随机别用一个随机源打天下
环境变量能不给就不给理想值 0全局可读,天生和能力模型打架
子进程不给0要隔离就再开一个实例,别绕远路
线程按需看物理和加载是否需要浏览器里还要先满足跨源隔离的两个响应头

表 A 里我最想强调“预开放目录数 ≤ 3”这一条。见过太多项目一偷懒,直接把整个工作目录递进去,理由是“省事”。省的事是今天的,风险是三年后的,而三年后你已经换了两拨人,没人记得当初为什么这么配。

表 B:跨边界调用预算——决定你该把线切在哪

调用类型单次开销量级每秒可容忍次数结论
纯数值参数调用纳秒级几万到几十万内循环里也能用
传字符串或结构体微秒级几百到几千攒批再调,别一条一条来
组件模型跨组件值转换微秒到几十微秒几十到几百绝不放进每帧执行的内循环
文件或网络 I/O毫秒级每秒个位数全部异步化,绝不阻塞主循环

表 B 的数字是量级,不是精确值——不同运行时、不同 AOT/JIT 配置能差出三倍。但“差三倍”不影响决策,因为表里的档位之间差的是两个数量级。你要做的判断是:这个调用落在哪一档,而不是它具体多少纳秒。

拿这张表去照你的游戏循环,你会发现很多“性能问题”其实是切错了边界。比如每帧调用几十次跨组件的“查询敌人列表”,单看一次没问题,加起来就是 60 帧掉到 40 帧。改法很土:把这批查询挪进同一个组件,让它变成一次普通函数调用,边界上只传一次结果。

双路径:初级速览与进阶深入

初级用户速览:记住三句话就够

第一句:WASI 是 Wasm 的操作系统接口。Wasm 本身只会算数,不会开文件、不会看表、不会发网络请求。WASI 就是那套“怎么跟外部世界打交道”的约定。它让一个 Wasm 模块能像一个独立程序一样活着,而不只是浏览器里的一个加速内核。

第二句:它默认什么都不给。你的模块能用什么,完全取决于宿主递给了你什么。没有“我试试看”,只有“你有没有被递过来”。这条是 WASI 和传统系统最根本的区别,前面讲了一大堆,落到实践就这一句。

第三句:浏览器里没有 WASI。很多人第一次听说这个概念会以为“那以后网页游戏也能直接读硬盘了”。不能。浏览器里的 Wasm 走的是另一套(Emscripten 的内存文件系统、JS 提供的各种接口)。WASI 服务的是“浏览器之外”的世界:桌面运行时、服务器、边缘节点、嵌入式。

你要是刚接手一个要上服务端的 Wasm 项目,拿这三句去问你的架构师,大概率能问出点东西来。

进阶深入:四个值得现在就动手的点

一、把 I/O 收口,别等标准。这是我认为最高优先级的事,而且它跟“用 Preview 1 还是 2”无关。你现在的代码里,如果“读文件”这个动作散落在三十个地方,那你迟早要付出三十次迁移的代价。把它收敛成一个薄薄的门面——所有 I/O 都从这一个地方出去——将来换实现就是换一个文件。反过来,就算标准再稳定,散沙一样的调用点也能把你拖死。这一点我跳过坑,实话说,跳得挺惨的。

二、区分“冷路径”和“热路径”,用不同标准对待。存档落盘、拉配置、上报成就,这些是冷路径,一秒钟几次,用最保守、最好维护的方案就行,别为它优化。帧内逻辑、物理查询、渲染指令提交,这些是热路径,一秒钟几万次,你要盯着它是不是被迫跨了组件边界。这两条路径用同一套标准去评价,是最常见的判断错误。

三、理解 AOT 编译和实例快照对“服务器冷启动”的意义。把 Wasm 模块提前编译成机器码,再把“已经初始化完成”的实例状态存成快照,下次来请求直接恢复到那个状态。对一个需要加载几百张配置表、构建一批索引的游戏服务器来说,这一招能把启动从秒级压到毫秒级。这是 Wasm 在服务端真正能赢过容器的地方之一——容器的启动是“重新跑一遍进程”,Wasm 的启动可以是“恢复一个状态”。

四、想清楚你要不要用“组件化”做插件生态。如果你只是想跑一段自己的逻辑,Preview 1 完全够用,别折腾。但如果你要做的是“让别人给你的游戏写东西”——mod、地图脚本、第三方 UI——那 Component Model 那份“合同”的价值会被放大十倍。因为你面对的不确定因素不是性能,是一百个水平参差的人。

争议与常见坑:五条我真心建议你别踩的

坑一:“WASI 就是 POSIX 换了个名字。”这是我听到最多的误解,也是最误导人的一个。POSIX 靠“你是谁”决定权限,WASI 靠“我递给你什么”。这个差别看着小,实践上天差地别——它意味着你没法把现有程序零成本搬过去,那些写死路径、依赖全局状态、靠 fork 干活的代码,全都要重新设计。谁跟你说“改个编译选项就行”,那是他没真跑过。

坑二:“有了 WASI,网页游戏就能开文件了。”不能。浏览器里的 Wasm 没有 WASI 的标准实现,这条路是不通的。市面上有些“在浏览器里模拟 WASI”的库,那是把宿主能力做成一层 JS 模拟,本质还是 JS 给你什么你才能用什么,跟真正的能力模型不是一回事。别拿它当架构决策的依据。

坑三:“Preview 1 已经够用了,不用迁。”短期看是对的,我自己现在跑的东西大部分还是 Preview 1。但这句话漏了一个条件——它只对“你只跑自己的代码”成立。一旦你要接第三方的模块、要维护多语言 SDK、要做插件市场,那层“靠人肉对表”的约定就会变成你最大的维护成本。判断标准很简单:你的边界对面,是不是你控制不了的人。

坑四:“WASI 让 Wasm 变慢了。”不准确。开销不在“有没有 WASI”,在“跨边界的调用频率和参数复杂度”。一个参数是整数、每秒调十次的接口,加不加 WASI 你都感知不到;一个传大字符串、每秒调一万次的接口,用不用 WASI 都会崩。把账记到 WASI 头上,会让你去优化错误的地方。

坑五:路径假设。这个我重复了三次,因为它值得重复三次。开发机上“当前工作目录”天然就有、天然对,容器里、边缘节点上、沙盒里,它可能压根不存在,或者指向一个完全不同的地方。你的测试环境越“顺手”,这个坑埋得越深。我的做法是:在 CI 里专门跑一遍“什么都不预开放”的启动测试,让它失败,看失败在哪一步——那一步就是你真实的、隐藏的依赖。

顺带说一个我认为还没定论、值得大家吵一吵的问题:WASI 的“窄接口”路线,会不会让游戏行业最终放弃它、全都去自造 ABI?Ambient 已经这么干了,WASM-4 也这么干了,很多游戏引擎也这么干。我的看法是:不会全放弃,但会分叉。通用能力(文件、时钟、随机)会收敛到 WASI 上,因为没人愿意自己实现一遍还实现得更差;游戏专属能力(实体、组件、帧同步)永远会留在引擎私有层,因为标准化的收益远小于贴合业务的收益。与其纠结“要不要全押 WASI”,不如想清楚你这条线该切在哪儿——这是设计问题,不是选型问题。

FAQ

Q1:我在浏览器里做游戏,WASI 跟我有关系吗?

间接有,而且比你想的要多。第一,你用的工具链(Emscripten 那一套)和 WASI 的 C 标准库实现共享大量代码,理解 WASI 能让你看懂很多编译报错到底在说什么。第二,如果你打算做“网页版 + 桌面版 + 服务端”三端——现在很多独立游戏都是这个组合——把 I/O 收敛成 WASI 风格的能力接口,能让三端的移植成本降一个量级。第三,你迟早会碰到“为什么浏览器里读个文件这么难受”这个问题,知道是“因为浏览器没有 WASI”会让你少走很多弯路。

Q2:从 Preview 1 迁到 Preview 2,是不是要重写?

不是重写,是“重新打包 + 换边界”。核心逻辑——那些纯计算、不碰 I/O 的部分——一行都不用改。要改的是最外层那几十个调用点,以及打包方式和工具链。工作量几乎完全取决于一件事:你的 I/O 有多散。散在三十个地方,那就是三十次改造;收敛在一个门面后面,那就是换一个文件的活。所以我一直在说,收口这件事今天就该做,它不依赖任何标准进度。

Q3:WASI 能做多线程游戏吗?

这个问题里混了两个层次,得拆开。多线程是 Wasm 规范层面的事(线程提案、共享内存、原子操作),不是 WASI 的职责。WASI 管的是“你从外部世界能拿到什么”,线程管的是“模块内部怎么并行”。两件事经常被混为一谈。真要落地还得注意:浏览器里的共享内存需要页面满足跨源隔离的两个响应头条件,否则浏览器会直接把相关能力关掉。

Q4:游戏服务器跑在 WASI 里,性能真的能行吗?

看你跑什么,这句话不是回避。纯逻辑、事件驱动、状态轻的服务——匹配、房间管理、成就、排行榜、反作弊规则判定——非常合适,而且能享受到隔离和冷启动的好处。需要长时间持有大量内存状态、频繁跨边界读写大数据块的场景——比如一个常驻的大世界模拟——先做基准测试,别听任何人的结论,包括我的。测的时候记得测“真实负载”,别测空循环,空循环的 Wasm 和 JS 差距会给你完全错误的信心。

Q5:现在入场,要不要等 Component Model 稳定了再动手?

我的答案有点反直觉:等,是最差的一种选择。因为 Preview 1 到 Preview 2 的迁移成本,主要不在 ABI 有多难,在“你的 I/O 边界切得干不干净”。边界干净,迁移是几天的事;边界烂成一团,等三年也一样烂。所以现在真正该动手的事,不是追规范进度,是把 I/O 收口、把热路径和冷路径分开、把“假设存在的世界”改成“显式递进来的东西”。这三件事,做完了你会发现——不管最后是哪套标准赢,你都站在有利的位置上。

最后留个问题给你,也是我每次做架构评审都会问自己的:如果明天你的宿主只递给你一个目录,你的代码还能跑起来吗?如果答案是“不知道”或者“跑不起来”,那说明你要补的功课,跟 WASI 没关系——是依赖关系从来没被认真梳理过。而这件事,跟规范稳不稳定,一点关系都没有。

文章标签
WebAssemblyWasmEmscriptenWASIwasm-bindgenComponent ModelSharedArrayBufferAtomicsWasm 多线程Wasm SIMDwasm-packRust Wasm
更多专题全部专题
觉得有价值?点赞或收藏支持内容持续产出。
← 返回专题:WebAssembly 游戏开发技术专题