返回资讯中心
外部精选
软件工程
#开发者实时流

开发者提出零成本异常模型,无需栈展开和堆分配

来源标题:Exception Excursions (a new zero-cost exception model)

开发者在其自研语言 Menehune 中提出一种新的零成本异常实现方式,无需栈展开、堆分配或运行时库,同时保留 try/catch 的简洁用法。作者认为传统返回值方式虽廉价但需手动检查错误码,而新方案在权衡上有本质不同。

Hacker News 最新mwksoul2026/11/7 · 今天25 分钟阅读

以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。

我研究程序设计语言已有数年,期间一直在打造自己的语言 Menehune。在此过程中,我着手解决一些历来只有少数几种解法的问题。这是(暂定的)系列文章的第一篇:一种截然不同的异常实现方式,它带来了一套完全不同的权衡取舍。

那么……如果异常既不需要栈展开,也不需要堆分配,更不需要运行时库,却仍能保留 try/catch 那种简洁易用的体验呢?在讨论这一点之前,先来看看目前的格局。

最古老的机制是返回值。函数返回一个整数,其中某些值表示成功,另一些表示失败,由调用方手动检查。C 和 POSIX 是典型例子:open 返回文件描述符或 -1,read 返回读取的字节数或 -1,全局的 errno 则携带详细的失败状态。你应该知道 POSIX 声明的那些 ESOMETHING 错误吧?对,就是那些。

这种做法非常廉价——返回值可以放进一个寄存器。没有运行时机制,没有内存分配,没有栈展开,也没有运行时库。它可以在内核、引导加载程序或微控制器中无缝工作——只要函数能返回的地方就能用。

此外,通常还会有一个 strerror 函数,它接收一个 ESOMETHING 值,返回一段静态字符串来解释该错误的含义——这种模式从 POSIX 到各种自定义 C 库随处可见。它对调试很有用,但也意味着你的错误描述被锁在全局表或人工约定之后。

这种做法也很容易被忽略。没有任何机制强制调用方检查返回值。编译器无法可靠地告诉你你忘了检查,因为错误只是一种约定,而不是一种独立的类型。此外,错误码必须与返回值共享取值范围,从而侵占有效域的一部分:-1 不是合法的文件描述符,NULL 不是合法的指针,每个 API 都必须划出自己保留的错误域。

函数式编程——以及后来的 Rust——通过把错误嵌入类型签名,解决了“容易被忽略”的问题。函数返回 Result<Value, Error>。调用方如果不解包或处理错误,就根本无法访问值。

这是结构上的巨大改进。编译器强制要求处理,类型系统在调用链上追踪错误。没有保留的哨兵值,没有 errno,也没有隐藏的约定。

代价是错误传播具有传染性。每个可能失败的函数都必须返回错误类型,这迫使调用链上的每个调用方显式地传播或处理它。语言用 ?、try! 或 monadic do 记法之类的语法糖来绕开这一点。你仍然需要在每一个调用点写上 ?,或者写一个显式的 match。

C++、Java、C#、Python 和 JavaScript 走了第三条路:把错误当作控制流事件,而不是返回值。你 throw,运行时沿着栈回溯并展开到最近的 try 块,然后执行匹配的 catch 块。

这是迄今为止最符合人体工学的方案。调用点不需要显式的语法标记,传播完全隐式,错误处理器在视觉上与正常路径分离。try/catch 块读起来清晰明了,主流程保持原样。

代价是这套机制不可见且昂贵。栈展开需要复杂的运行时栈遍历。编译器必须为每个可能抛出异常的函数生成 .eh_frame 展开表、landing pad 和 personality 函数。在 C++ 中,这实际上意味着每个带有析构函数的函数都要生成,无论它是否真的会抛出异常!在 Itanium C++ ABI 下,这种静态表膨胀和元数据管理会带来可观的成本,而且是预先支付的。格式也许叫 DWARF,但二进制文件可一点都不“矮小”。

这种静态二进制体积的代价,加上抛出对象所需的堆分配和不可预测的动态时序,正是操作系统内核这类关键软件完全禁用异常的原因。macOS 驱动是用 C++ 写的,但不使用异常——不是因为错误处理不可取,而是因为在低延迟或内核空间的约束下,栈遍历、展开表和运行时分配是不可接受的。

上述三种范式都把错误放在了某个地方:

它们都没有提供一种在正常路径上零成本、无内存分配、同时又符合人体工学的错误机制。

我为 Menehune 做的设计,目标正是占据设计空间中的第四个点:异常就是一个带标志位的返回值,而这个标志位就是 throw 与普通返回之间的唯一区别。

核心概念可以归结为一句话:异常就是携带一个在途标志位的返回值。

没有栈扫描,没有 .eh_frame,没有 personality 函数,没有堆分配,也没有运行时库。抛出路径和标准返回路径使用完全相同的机器指令,区别仅在于一个几乎总是清零的标志位。

该设计依赖三个主要组件:

异常载荷用一个 64 位数值 ID 表示。标志位的表示方式取决于目标平台:

mov rax, <64-bit exception ID> ; 加载异常载荷 stc ; 设置在途进位标志 ret ; 立即返回给调用方

ARM64 / RISC-V(寄存器丰富):拥有 31 个以上通用寄存器的架构可以通过专用一个辅助返回寄存器,完全避免改动 CPU 标志状态:

mov x0, <64-bit exception ID> ; 加载异常载荷 mov x1, #1 ; 设置在途错误标志 ret ; 立即返回给调用方

两种方式都只需要三条指令。抛出就是返回。

标准函数 ABI 将标志位和辅助暂存寄存器视为易失的,因此 Menehune 要求可能抛出的函数必须在其签名中显式标注 throw。

这不仅仅是静态分析的 lint——它是一份显式的 ABI 契约:

由于不抛出的函数无法抛出异常,编译器不需要在它们周围生成处理代码。正常路径保持为干净的机器码。

(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →