11-2.【错误处理】调用 throws 函数时,Swift 编译器和 runtime 做了哪些异常处理操作?

15 阅读3分钟

在 Swift 中,throws 的实现并非像 C++ 或 Java 那样依赖昂贵的栈回溯(Stack Unwinding) ,而是采用了一种更高效的 “隐式返回值 + 寄存器检查” 机制。

当编译器处理一个 throws 函数时,它实际上是在底层进行了一场精密的“签名重写”。


1. 编译器:函数签名的隐式重写

当你定义 func fetchData() throws -> Data 时,编译器在生成 SIL (Swift Intermediate Language) 阶段会将其转换成类似下面的形式:

  • 原始逻辑:返回 Data 类型,可能抛出 Error
  • 编译器视角:返回一个特殊的元组或联合体,物理上通过两个路径返回。

关键:错误寄存器(The Error Register)

Swift 调用约定(Calling Convention)为错误预留了一个专门的硬件寄存器(例如在 ARM64 架构上是 x21 寄存器)。

  1. 正常返回路径:函数结果按常规存放在结果寄存器(如 x0)中。此时,错误寄存器(x21)会被置为 0 (null)
  2. 错误返回路径:函数将错误对象的指针存入 x21,然后直接执行返回指令(ret)。

2. Runtime:零成本的“异常”传递

之所以说 Swift 的 throws 是“零成本”的(在没有错误发生时),是因为它的运行时操作极度简化:

调用方的操作 (Caller Side)

当你写下 try fetchData() 时,编译器在汇编层级插入了检查逻辑:

  1. Call:调用函数。

  2. Test:函数返回后,立即检查错误寄存器(x21)是否为非零。

  3. Branch

    • 如果为 0:继续执行下一行代码(Happy Path)。
    • 如果 非 0:提取错误对象,并跳转到 catch 块或向上游继续抛出。

无需栈回溯 (No Stack Unwinding)

在 C++ 中,抛出异常需要遍历调用栈并查找处理程序(析构局部变量),这极其缓慢。

而在 Swift 中,因为 throws 函数在退出时已经通过常规指令清理了栈帧,所以传播错误仅仅是一次寄存器值的检查和一次条件跳转(Branch) 。这在现代 CPU 上通过分支预测几乎是瞬时完成的。


3. 性能损耗到底在哪?

虽然 throws机制很轻量,但以下操作会产生开销:

  • 错误对象的装箱 (Boxing) :如果你抛出的错误是 enum,Swift 需要将其包装进一个 any Error 容器中。这可能涉及到**堆内存分配(Heap Allocation)**和引用计数(ARC)操作。
  • 类型检查:在 catch 块中使用 isas? 进行错误类型匹配时,运行时需要查询元数据,这有一定的性能开销。

4. Swift 6.0 的进化:Typed Throws

为了进一步消除上述“装箱”开销,Swift 6 引入了 Typed Throws (throws(MyError)):

  • 原理:明确指定错误类型后,编译器不再需要使用通用的 any Error 容器。
  • 优化:如果 MyError 是一个简单的 enum,编译器可以直接在寄存器中传递它,完全避免堆分配
  • 结果:在泛型代码和嵌入式系统(如 Swift Embedded)中,这种错误处理的性能可以逼近返回一个简单的 Int

总结:底层的平衡

操作传统异常 (C++/Java)Swift throws
正常路径开销存在(维护异常表/栈保护)接近零(仅多一个寄存器初始化)
错误路径开销极大(解析 DWARF 调试信息,解构栈)极小(寄存器赋值 + 跳转)
实现本质运行时搜索处理程序编译时自动生成的条件分支