WebAssembly 异常处理提案:游戏中错误管理的新范式 Exception Handling as an Error Budget
异常不是 try/catch 的语法糖——它是你在 16ms 里给「出错」留的那笔预算
为什么「出错」这件事,值得单独拿出来设计
去年我把一个做了三个月的小游戏传上 itch,免费,无人问津。第三天来了第一条留言,四个字:「黑屏,啥都没有。」我远程让他开控制台,一行红字——显存分配失败。他那块显卡的驱动老了两年。而我代码里所有错误处理加起来的行数,是零。游戏在「加载贴图」这一步撞死,浏览器里什么都不会发生:没有崩溃弹窗,没有「是否发送报告」,只有一个白得发亮的画布。他关了页面,再也没回来。
这就是我想先把话说明白的地方:在 Web 上,「出错」不是一个技术细节,它是一次用户流失事件。原生游戏崩了,玩家骂两句、发个 dump,你至少还知道有这么个人存在过。浏览器里崩了,你连他来没来过都不知道。你的后台数据里,他只是一个「加载到 60% 就走了」的百分比。
WebAssembly 的异常处理提案(Exception Handling,社区口语简称 EH)听上去特别底层,好像只跟编译器和规范组有关。但它真正要解决的问题,就是上面这个:让「出错」从一次死亡,变成游戏里可以设计的一段流程。能捕获、能降级、能弹出一句「这关的资源包损坏了,我们回主菜单」,而不是白屏。
不过我得先把话说难听一点,免得你读完之后到处写 try:游戏行业禁了二十多年异常,那些理由在 Wasm 里一条都没消失。真正变的是代价结构——以前你用异常,代价是「整份代码变慢变胖」;现在代价收窄到了「你真的把它抛出来的那一下」。代价结构一变,设计空间就变了,这才是这篇要聊的东西。
第一层:先把「错误」分成三层,混着谈就一定谈错
我参加过很多次关于异常的会,绝大多数吵到最后都没结论,因为大家吵的不是技术,是分类。有人嘴里的「错误」是玩家点了个买不起的道具,有人嘴里的「错误」是数组越界踩了内存。这两件事在工程上的处理方式是完全相反的,混在一起讨论,永远收不了场。
所以我习惯先画一张三层图,谁有意见先在这张图上改:
| 层级 | 典型例子 | 在 Wasm 里长什么样 | 能被捕获吗 | 该用什么机制 | 设计目标频率 |
|---|---|---|---|---|---|
| 预期内失败 | 钱不够、寻路无解、网络包乱序、技能冷却中 | 普通返回值 | 不适用 | 返回值/结果类型 | 随时,不该限制 |
| 罕见可恢复 | 贴图解码失败、存档槽被占用、GPU 上下文丢失 | 原生异常 | 能 | 异常 + 上层统一兜底 | 低于 1 次/秒 |
| 加载期失败 | 关卡包 404、模块校验不过、模型文件损坏 | 异常跨到 JS 侧 | 能,通常在 JS 侧接 | 转成可读错误 + UI 兜底 | 每次加载数次 |
| 致命 | 数组越界、栈溢出、内存耗尽 | trap(陷入)或直接中止 | 不能 | 重建实例 + 上报 | 应该是 0 |
这张表里有一行是绝大多数人栽跟头的地方:trap 不是异常,catch 抓不到它。Wasm 的 trap 是引擎层面的中止——越界访存、执行到不可达指令、间接调用类型对不上、栈溢出。trap 会把整个 Wasm 调用栈一把掀掉,直接掀到 JS 那一层。你在 Wasm 里套多少层 try 都没用,因为它压根不走你这套机制。
这件事听起来像限制,我反而觉得是好事:它从物理层面逼你把「我还能不能继续跑」这个问题想清楚。越界之后你的游戏状态已经不可信了,继续跑下去只会把坏数据写进玩家存档。原生游戏里我们说「宁可崩,不要带病运行」,Wasm 的 trap 就是这个哲学的硬实现。
那游戏圈为什么长期禁异常?三条理由,都说人话:
一是帧预算。16.7 毫秒里每一毫秒都是抢来的。C++ 的经典异常实现是「表驱动展开」,happy path 上确实零成本,但这个「零成本」是有条件的:编译器得能证明没有异常穿过某个函数,才敢放心内联、才敢把变量塞进寄存器。你一旦加了 try,优化空间当场缩水。做游戏的人对「优化空间缩水」这件事的容忍度,比对「抛异常慢」还低。
二是确定性。回放、联机同步、帧同步,都要求同样的输入产生同样的结果。异常是一条跟调用栈深度强相关的控制流,而栈深度在热重载、异步加载、脚本回调下很难保持一致。你在单机上测得好好的,到了联机里对局就分叉,查三天查不出来。
三是文化。EA 的 EASTL 能在关异常的配置下跑,Google 的 C++ 规范直接禁异常,很多主机项目的编译选项里就写着关掉。这不是技术判断,是组织判断:单机崩了玩家重启,服务器带着坏状态跑会把整个存档写坏。
但 Web 把第三条反过来了一半。浏览器里没有崩溃上报,你连「崩了」都不知道,那「优雅降级」的价值就被抬起来了。不是技术变了,是环境变了——在一台你看不见玩家、也收不到尸的机器上,「活着」比「干净地死掉」值钱。这跟本系列里聊帧预算的那篇、聊插件沙盒的那篇,其实是同一个问题的三个面。
第二层:旧方案的账单——Emscripten 是怎么「假装」有异常的
Wasm 最早的版本里没有任何异常指令。尴尬的是 C++ 有一整套必须跨函数传播错误的语义:构造函数失败、运算符重载里报错、标准库那些你可能都不知道会抛的接口。你没法用返回值优雅地表达它——你总不能去改标准库里那些函数的签名。
于是 Emscripten 想了个聪明的办法:让 JS 帮忙掀栈。具体有三条路,每条路的账单不一样:
| 方案 | 直觉描述 | 代码体积 | 热路径调用开销 | 析构能跑吗 | 跨模块 | 适合谁 |
|---|---|---|---|---|---|---|
| 关闭捕获 | 出事直接中止,catch 一行都不生效 | 最小 | 无 | 不适用 | 不适用 | 确定不会抛的项目 |
| JS 模拟 | 抛一个 JS 异常把 Wasm 栈掀掉,每一帧自己检查「刚才是不是该跳走」 | 明显变大,抛点密的项目两三成起 | 每次穿桩多一轮 JS 往返 | 能,但慢,还得另建影子栈 | 不能 | 必须兼容老浏览器的存量项目 |
| 长跳转 | 一张全局的跳转目标链表 | 中等 | 较低 | 只能部分(中间帧被跳过) | 不能 | Lua 这类 C 风格的错误模型 |
| 原生提案 | 引擎自己沿栈找接应点 | 个位数百分比 | 不进 try 就接近零 | 能 | 能,靠导入同一个标签 | 新项目 |
重点说说中间那一档,因为它最能体现「旧方案贵在哪」。Emscripten 会给每一个「可能抛」的函数生成一个 JS 侧的中转桩(社区口语里叫 invoke 包装器)。Wasm 调用它,桩里用 JS 的 try 把这次调用包住。真抛的时候,靠一个 JS 异常把整个 Wasm 栈掀掉,控制权回到桩里;桩把「刚刚抛了」这件事记在全局,然后 Wasm 侧每一个调用点后面自己检查这个标记,决定要不要跳到本帧的清理代码。
于是你会看到一个很诡异的景象:C++ 的析构函数是能跑的,但它靠的不是引擎帮你展开栈,而是「每一层都自己问一句:我刚才是不是该跳走了」。这就带来四个后遗症:
中转桩本身要写代码,抛点越密,桩越多,体积就上去了;每次穿桩调用多一轮 JS 与 Wasm 之间的往返,在热路径上是灾难;编译器不敢内联这些函数,因为它必须留一个「能停下来」的位置;栈信息在 JS 那边,C++ 侧要自己维护一条影子栈去跑析构,慢,而且经常跟你真实的逻辑栈对不上——调试的时候你看到的调用栈和实际发生的事不是一回事,那种感觉非常上头。
Unity WebGL 把这件事做成了一个下拉框,叫 Enable Exceptions。我一直觉得那个下拉框是整个 Web 游戏行业对「异常值多少钱」最诚实的一次公开报价:
| 档位 | 支持范围 | 包体 | 运行性能 | 现实里谁在用 |
|---|---|---|---|---|
| None | 完全关闭,出错即死 | 最小 | 最快,最接近原生 | 包体敏感的商业 Web 版,赌「我们不抛」 |
| Explicitly Thrown Exceptions Only | 只支持脚本层显式 throw,运行时的空引用/越界之类不支持 | 较小 | 接近 None | 较新版本多数新项目的默认档 |
| Full Without Stacktrace | 全套支持,但没有托管栈信息 | 明显增大 | 明显变慢 | 确实需要真兜底的项目 |
| Full With Stacktrace | 全套 + 托管调用栈 | 最大,通常得按 MB 算 | 最慢 | 基本只该出现在开发期 |
这个设计的意图很清楚:Unity 没法替所有项目做决定,所以把成本显性化成四档,让制作人自己在「我要不要错误恢复」和「我要不要小 20 兆包体」之间选。收益是透明,代价是残忍——它在逼你承认,错误恢复是有价格的,而且价格不便宜。
社区里最常听见的一句抱怨是「为什么我的 WebGL 包 120 兆」,你去问,八成是 Full With Stacktrace 没关。第二档最坑的地方在于它的名字很会骗人:你以为「显式抛的异常能用」就等于「异常能用了」,其实不是。堆栈捅穿、资源句柄为空、类型转换失败这些你以为归它管的地方,它统统不管——而很多第三方插件恰恰是拿这些当异常用的。
所以旧方案最要命的地方不是「慢」,而是它是全局性收费的:你为「可能抛异常」这件事付钱,而不是为「真的抛了」付钱。哪怕你这一局一次都没抛,你也背着那笔体积和调用的账单。原生提案要改的正是这个。
第三层:原生提案干了什么——把钱从「可能」挪到「真的」
原生提案最核心的新东西叫标签(tag)。你可以把它理解成「异常的身份证」:它在模块里声明,带一串参数类型,运行时它就是一个身份。你能接住某个异常的前提是:你认识这张身份证——要么是你自己声明的,要么是从别处导入的。这条规则看起来平淡无奇,但它后面会变成插件生态里的一颗地雷,我们第四层再说。
动作层面,提案给的东西不多,四件:进入一个带捕获表的块(try)、指定某类标签在这里被接住(catch)、把异常往上一层推(delegate)、抛出(throw)。后面还补了一个「把已经抓在手里的异常引用重新扔出去」的动作,用来做中间层的日志埋点。没有花活,就是一个能让错误跨帧传播的最小闭环。
真正有意思的是引擎那一侧怎么找到「该去哪接」。掀 Wasm 栈对 JS 引擎来说很容易,因为那些帧本来就是它管的;但 Wasm 的栈对 Wasm 自己是不可见的(这也是为什么调试体验一直靠源码映射和调试信息那篇里讲的东西来补)。所以引擎必须在运行时维护一份「哪些帧里挂着捕获表」的信息,抛的时候沿着这份信息往上找第一个匹配的标签。业内管这个叫影子栈。
理解了这一点,「零成本」这三个字兑现到什么程度就清楚了:
好消息是 happy path 上真的不花钱。你不进 try 区域,就没有额外指令,也不用穿任何桩。相对旧方案这是质变——旧方案是每次调用都要过一道 JS,新方案是「不抛就不用管」。
坏消息是「进入 try 区域」本身有成本。早期实现里,引擎为了能精确地停在接应点,不太愿意把调用内联进 try 区,也不愿意在 try 区做某些激进的寄存器优化。所以你偶尔会观察到一个反直觉的现象:我在某个循环外面加了一层 try,结果附近的循环变慢了。这不是玄学,是引擎在保守。
提案后来的改版正是冲着这个来的:把捕获表提到代码块的头部一次性声明,而不是让它散在代码后面;同时引入一个可以「抓在手里带走」的异常引用类型,以及配套的重抛动作。好处是捕获表和代码块解耦了,引擎可以在不牺牲内联的前提下知道接应点在哪;坏处是生态被切成了两半——浏览器先上的是老的那一版,新的一版还在各家引擎追赶。写这篇文章的时候,Chrome 走得最快,Firefox 中规中矩,Safari 一如既往慢半拍;真要做产品,你得按最慢的那个定方案,而不是按最快的那个写代码。这也算是本系列里那篇「性能神话破解」想说的老道理:提案过了不等于你能用,能用不等于所有用户都能用。
那抛出去那一下到底多贵?我不给你精确数字,因为那些数字换个版本就过期,我给的是一份量级感:浅栈(几层深)的原生抛出大致在微秒量级;旧的 JS 模拟方案要穿过一次 JS 异常再逐帧检查标记,量级上差着一个数量级左右。栈越深越贵,而且是指数感的那种贵——每一帧都要查捕获表,还要跑清理代码。
所以判断标准从来不是「它快不快」,而是「你一秒抛几次」。一秒抛一次,随便抛,那点开销你连测都测不出来;一帧抛一次,你就得开始算账了;在一个遍历五千个实体的循环里抛,那你是在拿帧率开玩笑。
再看看隔壁 Rust 是怎么处理这事的,对照很有意思。Rust 把「异常」这个词拆成了两半:结果类型是正常路径,panic 是「程序有 bug,我不玩了」。而 Rust 编到 Wasm 上,长期是 panic 即中止(abort)——没有栈展开。这意味着 panic 一响,整个 Wasm 实例的状态就废了,你既接不住它,也继续不下去。玩家看到的是:画面卡在最后一帧,控制台一行红字,他以为游戏卡了,其实早就死了。
这个体验问题反过来逼出了生态里的设计变化。Bevy 在 0.15 版本里加了「可失败的 system」:一个系统可以返回成功或错误,由应用层决定是记日志、跳过这个系统,还是整体退出。翻译成我们策划的语言就是——把「这个系统会不会失败」从语言层面的大事,降级成这一帧里的一件小事。代价是 Rust 社区吵了十年的老问题:错误会传染,每个调用点都得处理,代码里到处是问号。收益是:在浏览器里,你能给玩家一句人话,而不是让他盯着静止的画面猜发生了什么。
顺手提一个反例做对比:Godot 走的是第三条路——全引擎错误码,失败检查宏满天飞,几乎不用异常。这条路在 Wasm 上最省心(没有异常就没有账单),代价是错误处理代码写得像体力活,而且你一旦引了用异常的第三方库,还是得开异常或者打补丁。全异常、全返回码、分层混用,这三条路里我押第三条,下面讲怎么分。
第四层:接到你的游戏上——错误预算与三条边界
机制讲完了,落地其实就三件事:定预算、划边界、管沙盒。
第一件:给「出错」设一个预算,像帧预算一样。做游戏的人对「预算」这两个字有肌肉记忆:顶点数有预算,DrawCall 有预算,骨骼数有预算。但很少有人给「这一帧允许抛几次异常」设过预算。我的经验值是这样的——健康的游戏,一帧的异常次数设计目标应该是 0;硬上限 1 次;超过就说明你把异常当控制流用了,那是设计问题不是性能问题。单次抛出的时间预算建议压在 0.05 毫秒以内(大概 1/330 帧),这样哪怕一帧真抛了一次,你也只花了 0.3% 的帧时间来兜底。还有一条最容易被忽略的:try 区域里别放大循环。「包装一个加载整关的函数」可以,「包住一个遍历五千个实体的循环」不行——后者会让引擎在那一整块代码上放弃优化,你付出的钱远比你省下的多。
第二件:划三条边界。
模块与模块之间的边界,是最容易翻车的一条。两个 Wasm 模块各自声明了一张叫「资源错误」的标签,它们不是同一个东西,互相接不住对方的异常。如果你在做引擎 + 插件、或者本体 + Mod 的架构,想让插件的错误被引擎兜住、引擎的错误被插件感知,标签就必须从宿主模块统一定义、由插件导入。这跟组件模型那篇讲的思路是一脉相承的:错误类型要写进契约里,而不是各写各的方言。顺便说,那边现在更倾向于用结果类型来表达跨语言的可恢复失败——这也侧面证明了一件事:跨语言边界上,异常能传递的语义其实很有限,它只能传「我失败了」这个事实,传不了「我为什么失败」的细节。细节得你自己塞进载荷里。
Wasm 与 JS 之间的边界,反而是最好用的一条。一个 Wasm 异常逃出模块、到了 JS 侧,会变成一个真正的 JS 异常对象,带着标签和载荷。TS 那边可以接住它、判断是不是某个标签,然后把载荷里的错误码读出来。这在混合架构(Rust 或 C++ 写逻辑、TS 写 UI)里价值极高:逻辑层崩了,UI 层还能弹个「资源加载失败,重试」的按钮——这就是我在导语里缺的那个按钮。反过来不行:JS 抛的异常不会自动变成 Wasm 异常,你得在边界函数里手动转一道。
线程边界,别想。异常对象没法跨 Worker 结构化克隆,必须自己序列化成普通数据再传。这条跟多线程架构那篇直接相关:物理线程炸了,主线程收到的应该是一条错误消息,而不是一个异常。任何「让异常自己飞过去」的幻想,在 Worker 边界上都会当场破灭。
第三件:把沙盒单独拎出来说。Lua 那种「报错 + 保护调用」的语义,本质上就是「沙盒内的异常」:玩家写的一段 Mod 逻辑炸了,你要的是「这个 Mod 停用、游戏继续」,不是「整个游戏关掉」。用原生 Wasm 异常做这层隔离,比让脚本里每个调用点都返回错误码干净得多——因为脚本里的报错是个「深度不确定的跳转」,返回值做不到这件事,这是 Lua 这么多年一直靠长跳转的原因,也是它在 Wasm 上一直难搞的原因。这块如果你在做 UGC 平台,跟沙盒安全那篇是同一套思路的两头。
参数化设计工具:三张表,抄走就能开工
表一:错误分层与机制选型。这张表建议你直接贴进团队的规范文档里,吵架的时候指着它说话。
| 错误类别 | 建议机制 | 抛出频率目标 | 单次代价预算 | 生产构建带栈? |
|---|---|---|---|---|
| 预期内失败 | 返回值 / 结果类型,不用异常 | 不限 | 纳秒级 | 不需要 |
| 罕见可恢复 | 原生异常 + 最外层统一兜底 | 低于 1 次/秒 | 低于 0.05 毫秒 | 不带 |
| 加载期失败 | 异常转成可读错误,交给 JS 侧 UI | 每次加载数十次以内 | 毫秒级可接受,但必须有进度条兜底 | 开发带,生产不带 |
| 致命 | trap / 中止,重建实例并上报 | 0(出现即 bug) | 不计 | 靠离线符号化还原 |
表二:跨边界传递能力。这张表决定了你的错误信息最远能走多远。
| 边界 | 异常能过去吗 | 过去之后长什么样 | 工程建议 |
|---|---|---|---|
| 模块内部 | 能 | 原生异常 | 放心用,但守住频率预算 |
| 模块与模块 | 有条件 | 需要双方认同一个导入标签 | 引擎统一定义错误标签,插件导入 |
| Wasm 到 JS | 能 | JS 异常对象,带标签与载荷 | 全局错误边界唯一入口 |
| JS 到 Wasm | 不能自动 | 需要在边界手动转 | 边界函数里显式转换,别指望自动 |
| 主线程与 Worker | 不能 | 无 | 序列化成错误码与描述 |
| 跨网络 / 跨存档 | 不能 | 无 | 错误码 + 版本号 |
表三:几个可以直接调到项目配置里的参数,附上「超了会怎样」,方便你说服制作人。
| 参数 | 建议区间 | 超了会怎样 |
|---|---|---|
| 每帧抛出次数 | 正常 0 次,硬上限 1 次 | 帧时间被慢慢吃掉,而且性能分析工具里它长得像普通函数调用,你根本找不到凶手 |
| 单次抛出耗时 | 低于 0.05 毫秒 | 一帧抛三五次就开始掉帧,且在低端机上表现成「莫名卡顿」 |
| try 区域代码规模 | 低于 200 行,不含大循环 | 引擎放弃内联,附近代码集体变慢,你还以为是别的地方的问题 |
| 抛出点密度 | 低于 1 个 / 千行 | 你在用异常表达正常分支,该改返回值了 |
| 生产构建是否带托管栈 | 关闭 | 包体涨一截,抛出变慢,换来的是玩家根本不会看的堆栈 |
| 加载失败自动重试次数 | 2 次,指数退避 | 无限重试 = 用户流量黑洞 + 服务器被打 |
这些区间是起点不是真理,不同项目的数字会差很远。但有一点我建议你别动:生产构建不带托管栈。玩家不会看你的调用栈,他只会看你的加载条卡在 61%。
初级路径:三句话,先把那个白屏按钮补上
如果你现在只想赶紧把游戏跑起来,别研究提案版本了,记住三句话就够用:
一,先问自己:我引的第三方库会抛异常吗?会(模型加载、正则、文件系统、序列化库经常干这事),那就把原生异常打开,别犹豫;不会,就关着,省钱省事。
二,就算关着,也留一条「加载失败」的返回路径。哪怕整个项目只有一个地方需要恢复——资源加载。因为那恰恰是玩家最容易撞上的地方,也是你最不想让他白屏的地方。
三,别在生产构建里开托管栈。开发开,出包关。这条没有任何讨论空间。
然后给你一个三十秒就能做完、但能救你一半差评的自查测试:把资源路径故意改错一个,在浏览器里打开,看页面是白屏,还是弹出一句人话。如果是白屏,你现在就去改,改完再回来读后面的。真的,这个测试比这篇文章剩下的部分加起来都有用。
进阶路径:把错误当成一套要设计的东西
如果你要做的是一套要给团队用、要给插件用、要活三五年的架构,那你要设计的是「错误契约」,而不是「错误处理」。
第一件事,定标签。给谁:引擎层定义一套公共的错误标签,插件只能导入不能自造。命名别用人类语言(「资源错误」这种),用稳定的编号加版本号,因为你以后一定会想加分类,而标签的参数表一旦改动,所有插件都得跟着重编。参考接口定义语言那套思路:错误类型是要进契约的,进契约就要版本化。
第二件事,定「谁负责兜底」。整个 Wasm 实例只能有一个最外层兜底点,位置就在跨到 JS 的那一面。所有逃出这一面的异常都会变成 JS 异常对象,那里是唯一能保证「玩家至少能看到一句话」的地方。别在中间层到处加 try——每加一层,你就多一个可能吞掉错误的位置,而且会顺手掐掉引擎的优化。
第三件事,性能怎么验,别靠感觉。在你的抛出路径上加两个计数器:这一帧抛了几次、这次抛了多久;再统计一次整局的分位数。你会得到一张很朴素的表,但它能立刻告诉你有没有人把异常当 goto 用。我自己的经验是,第一次统计出来的时候大概率会吓你一跳——你以为一年抛几百次,实际可能是几百万次。
第四件事,降级策略要提前写。异常提案在移动端 Safari 上的覆盖不是全量的,老系统上就是没有。所以你的方案里必须有一条「检测不到就退回返回码」的旁路,而且这条旁路要在开发期就跑通,不能等线上出问题才发现它从来没被验证过。特性检测和降级这两件事,本系列里反复出现,不是啰嗦,是真的每年都有人栽。
第五件事,如果是从 Emscripten 旧方案迁过来,一次全量重编。最容易翻的车不是你的代码,是你链接进来的第三方静态库——它们还是按旧的那套编的,两套 ABI 混在一起,轻则链接报错,重则链接过了、运行时静默坏掉。那种 bug 能让你查一个星期,最后发现是构建脚本少清了一次缓存。
三个会让你翻车的坑,和一个行业争议
坑一:以为 catch 能兜住 trap。我见过一个项目在几乎所有入口都包了 try,号称「永不白屏」。结果上线第一周,一次越界访存直接掀到 JS 层,所有兜底全白写。trap 只有最外层 JS 能看见,而且那时候 Wasm 实例的状态已经不可信了——正确的做法不是继续跑,是重建实例然后给玩家一个「重新开始」。别试着抢救一具已经开始腐烂的尸体。
坑二:两种 ABI 混着链接。上面提过了,但值得再说一次,因为它太常见、太隐蔽。判断方法很简单:如果你迁移之后发现某些错误路径的行为跟预期不符,先怀疑构建缓存,别怀疑自己的逻辑。
坑三:把异常当 goto,尤其在状态机和脚本虚拟机里。「跳出去比写状态机简单」——短期是真的简单,长期你会得到一帧抛几百次的怪物。而且它最坏的地方在于:性能分析工具里它长得跟普通函数调用一样,你盯着火焰图看半天,根本找不到凶手在哪。这个坑我真踩过,还是在一个已经上线的项目上,最后是靠加计数器才揪出来的。
然后是一个行业争议:跨语言边界的错误,到底用异常还是返回值?两派吵得很凶。异常派说:语义丰富、不污染函数签名、错误能带着上下文穿透好几层。返回码派说:可组合、跨语言友好、接口定义语言天生就更亲近结果类型。
我的立场很土:模块内部用异常,模块之间用返回值。异常是个好东西,但它是方言,适合在自家院子里说。你出了院门(跨模块、跨语言、跨网络),就得说普通话——一个错误码加一段描述,谁都能听懂。硬要把异常当成跨语言的通用语言,最后得到的是一堆各说各话的标签,谁也接不住谁。
FAQ
Q1:我的游戏从来不抛异常,这篇跟我有关系吗?
有关系,而且关系可能比你想的大。「我从来不抛」这句话通常有两层意思:一是我自己的代码不抛(大概率是真的),二是我引的库不抛(这个我很怀疑)。模型加载库、正则、文件系统、序列化、某些 JSON 实现,都是抛异常的老手。你可以选择关掉捕获,但你要知道你在赌什么:赌的是这些库的异常路径永远不会被触发——比如玩家上传了一个损坏的模型文件。这种赌局通常以白屏收场。
Q2:我用 Rust,panic 能不能当异常用?
在 Wasm 上基本不能。默认配置下 panic 就是中止,一响整个实例就废了,接不住也回不去。那意味着任何一次 unwrap 失手,玩家看到的就是静止画面。生产环境里,凡是输入来自玩家或网络的地方,别用 unwrap,用结果类型;引擎层可以像 Bevy 那样提供「可失败的系统」,把「这一帧出错了」变成一件可以处理的小事,而不是整个程序的大事。Rust 侧的栈展开支持这几年在推进,但离「浏览器里随便接住」还早,别赌。
Q3:iOS 上能用吗?要不要专门为它降级?
能用,但不是全量。桌面主流浏览器在 2021 到 2022 年间就陆续跟进了第一版提案,移动端要更晚一些,老系统上就是没有。所以答案是:要降级。方案也不复杂——启动时探一下能力,没有就走返回码那条旁路。关键是这条旁路得在开发期就被真实跑过,不能是纸面上写着「我们支持降级」。
Q4:开了原生异常,包体会不会变大?会不会把包体优化的成果吃掉?
会变大,但量级完全不同。旧方案的体积膨胀是全局性的(每个可能抛的函数都要加桩),原生方案的增量通常在个位数百分比,而且跟你 try 区域的密度正相关。相对于你从旧方案迁过来能省下的那一截,这点增量基本可以忽略。真正会吃掉你包体优化成果的,是「生产构建忘了关托管栈」这种低级事故——那个能一口气吃掉好几兆。
Q5:异常能不能跨 Worker 传?能不能存进存档?
都不能。Worker 边界要结构化克隆,异常对象不在可克隆的名单里;存档就更别想了,它连进程都出不去。跨这两道边界,你只能带错误码和一段人话描述。这也是我一直建议「错误码是主语言、异常是方言」的现实原因:你总会遇到一道异常过不去的墙,区别只在于你是提前想好了,还是上线后被玩家教做人。