FFI-unwind 项目组,在此 RFC 中宣布,正在努力扩展语言以支持跨越 FFI 边界的 unwinding。
我们已经到达了第一个技术决策点,这是一个我们内部讨论了相当长时间的问题。这篇博文阐述了问题的各方论点,并邀请 Rust 社区参加我们即将举行的会议,帮助最终确定我们的决定,该决定将正式化并作为我们的第一个语言变更 RFC 发布。此 RFC 将提出一个“MVP”规范,用于明确定义的跨语言 unwinding。
会议将于 3月2日 举行。
背景:什么是 unwinding?
异常是许多编程语言中常见的控制流机制。它们在 Java 等托管语言中尤其普遍,但也属于 C++ 语言,C++ 将它们引入了非托管系统编程领域。
抛出异常时,运行时会 unwinds 栈,本质上是向后遍历并调用清理或错误恢复代码,例如析构函数 (destructors) 或 catch
块。
编译器可能会实现自己的 unwinding 机制,但在 Rust 二进制文件等原生代码中,该机制通常由平台 ABI 提供。
众所周知,Rust 本身没有异常。但 Rust 确实 支持 unwinding!有两种情况会导致 unwinding 发生
- 默认情况下,Rust 的
panic!()
会 unwinds 栈。 - 使用 FFI,Rust 可以调用其他语言(例如 C++)中可能 unwinds 栈的函数。
- 在某些特殊情况下,C 库实际上可能导致 unwinding。例如,在 Microsoft 平台上,
longjmp
被实现为“强制 unwinding”(见下文)。
- 在某些特殊情况下,C 库实际上可能导致 unwinding。例如,在 Microsoft 平台上,
目前,当外部(非 Rust)代码调用 Rust 代码时,从 Rust “逃逸”的 panic!()
unwind 的行为是明确未定义的 (Undefined Behavior)。同样,当 Rust 调用一个 unwind 的外部函数时,一旦 unwinding 操作遇到 Rust 帧,其行为也是未定义的。这样做的主要原因是确保 Rust 实现可以使用自己的 unwinding 机制,该机制可能与平台提供的“原生”unwinding 机制不兼容。然而,目前 rustc
使用的是原生机制,并且没有计划改变这一点。
强制 unwinding
平台 ABI 可以定义一种特殊的 unwinding,称为“强制 unwinding”。这种类型的 unwinding 无论被 unwound 的代码是否支持 unwinding 都能工作。但是,如果被 unwound 的帧不支持 unwinding,则析构函数可能不会执行。
有两个常见的强制 unwinding 示例
- 在 Windows 平台上,
longjmp
被实现为强制 unwind。 - 在 glibc Linux 上,
pthread_exit
和pthread_cancel
被实现为强制 unwind。- 实际上,
pthread_cancel
可以导致各种 C 函数 unwound,包括read
和write
等常用函数。(完整列表请在 pthreads 手册页中搜索“cancellation points”。)
- 实际上,
任何跨语言 unwinding 规范的要求
- Rust 函数之间的 unwinding(尤其是 Rust panic 的 unwinding)可能不一定会使用系统 unwinding 机制。
- 实际上,我们今天确实使用了系统机制,但我们希望保留更改此机制的自由。
- 如果启用
-Cpanic=abort
,我们可以优化二进制文件的大小,移除大多数与 unwinding 相关的代码。- 即使使用
-Cpanic=unwind
,当已知不会发生 unwinding 时,也应该可以优化掉相关代码。 - 实际上,大多数“C”函数从不期望 unwind(例如,因为它们是用 C 而不是 C++ 编写的)。
- 然而,由于 unwinding 现在是大多数系统 ABI 的一部分,即使 C 函数也可以 unwind——最值得注意的是由
pthread_cancel
触发的取消点 (cancellation points)。
- 然而,由于 unwinding 现在是大多数系统 ABI 的一部分,即使 C 函数也可以 unwind——最值得注意的是由
- 即使使用
- 将行为从
-Cpanic=unwind
更改为-Cpanic=abort
不应导致 Undefined Behavior。- 然而,这可能难以维持,至少不会在不显著增大二进制文件大小的情况下实现。更多细节请参阅下文讨论。
- 当然,这可能会导致原本可以成功执行的程序中止 (abort)。如果 panic 会被捕获和恢复,就可能发生这种情况。
- 我们不能改变
libc
crate 中函数的 ABI(extern "C"
中的"C"
),因为这将是一个破坏性变更:不同 ABI 的函数指针具有不同的类型。- 这与调用
pthread_cancel
时可能执行强制 unwinding 的libc
函数有关。
- 这与调用
"C"
ABI 支持 unwinding?
主要问题:引入新的 ABI,还是允许 我们希望决定的核心问题是,Rust 定义的 "C"
ABI 是否应该允许 unwinding。
这并不是我们预料中会争论的问题。我们长期以来一直声明,通过 Rust 的 "C"
ABI 进行 unwinding 是未定义行为。部分原因是没有人花时间去弄清楚正确的行为是什么,或者如何实现它,尽管(正如我们稍后会看到的那样)还有其他选择此方案的充分理由。
无论如何,在 PR #65646 中,@Amanieu 提出实际上我们可以简单地定义跨越 "C"
边界进行 unwinding 的行为。在讨论中,我们发现关于 "C"
ABI 是否应该允许 unwinding 的问题并不像我们之前假设的那么明确。
如果 "C"
ABI 不允许 unwinding,将专门引入一个新的 ABI,称为 "C unwind"
,以支持 unwinding。
三种具体提案
项目组已将设计范围缩小到三种具体提案。其中两种引入了新的 "C unwind"
ABI,一种则没有。
每种提案都指定了各种类型 unwinding(Rust panic!
、外部(例如 C++)和强制(例如 pthread_exit
))在遇到 ABI 边界时,在 panic=unwind
或 panic=abort
编译模式下的行为。
请注意,目前 catch_unwind
不会拦截外部 unwinding(无论是强制还是非强制),我们的初始 RFC 也不会改变这一点。我们可能会在稍后决定定义一种 Rust 代码拦截外部异常的方式。
在全文中,由 panic!
生成的 unwind 将被称为 panic
-unwind。
"C unwind"
,最小规范
提案 1:引入 "C"
ABI 边界,panic=<任意>
panic
-unwind:程序中止- 强制 unwinding,无析构函数:unwind 正常行为
- 其他外部 unwinding:未定义行为
"C unwind"
ABI 边界- 使用
panic=unwind
:所有类型的 unwinding 正常行为 - 使用
panic=abort
:所有类型的 unwinding 都导致程序中止
- 使用
该提案提供了 2 种 ABI,每种适用于不同的目的:通常在与 C API 交互时使用 extern "C"
(确保在可能使用 longjmp
的地方避免析构函数),在与 C++ API 交互时使用 extern "C unwind"
。此提案的主要优点是,如果正确地将所有潜在的 unwinding 调用标记为 "C unwind"
,则在 panic=unwind
和 panic=abort
之间切换不会引入 UB(您的程序将改为中止)。
"C unwind"
,强制 unwinding 始终允许
提案 2:引入 这与上一个设计相同,不同之处在于当使用 panic=abort
编译时,强制 unwinding 不会在 "C unwind"
ABI 边界处被拦截;也就是说,它们会正常行为(尽管如果存在任何析构函数仍然是 UB),而不会导致程序中止。panic
-unwind 和非强制外部异常仍会导致程序中止。
对强制 unwinding 进行不同处理的优点在于它减少了可移植性不兼容。具体来说,它确保当目标平台和/或编译标志改变时,使用 "C unwind"
不会导致 longjmp
或 pthread_exit
停止工作(中止程序)。在提案 1 中,longjmp
将能够跨越 "C unwind"
边界,但在 Windows 上使用 MSVC 并处于 panic=abort
模式时除外;pthread_exit
将在 "C unwind"
函数内部工作,但在链接 glibc 并处于 panic=abort
模式时除外。此提案的缺点是,在 panic=abort
模式下,围绕 "C unwind"
调用的中止 stub 会变得更复杂,因为它们需要区分不同类型的外部异常。
提案 3:不引入新的 ABI
panic=unwind
:unwind 正常行为panic=abort
:panic
-unwind:不存在;panic!
中止程序- 强制 unwinding,无析构函数:unwind 正常行为
- 其他外部 unwinding:未定义行为
此提案的主要优点在于其简单性:只有一个 ABI,并且 panic=abort
的行为与 C++ 中的 -fno-exceptions
相同。然而,其缺点是,如果 FFI 调用通过 Rust 代码 unwind,切换到 panic=abort
在某些情况下可能会引入 UB(尽管仅限于非安全代码)。
另一个优点是,当使用 panic=unwind
编译时,libc
crate 中定义的现有函数(如 pthread_exit
和 longjmp
)的强制 unwinding 将能够 unwind 带有析构函数的帧,这在其他提案中是不可能的。
提案设计的比较表
在此表中,“UB”代表“未定义行为”。我们认为所有这些未定义行为的情况都可以在运行时检测到,但这样做所需的代码会带来不希望的代码大小开销,完全抵消了使用 panic=unwind
或非 unwinding 的 "C"
ABI 所带来的优化。因此,这段代码仅适用于调试构建。此外,实现此类检查的复杂性可能超过其益处。
请注意,通过包含析构函数的帧进行 unwinding 但不运行这些析构函数(例如,因为它们已被 panic=abort
优化掉)始终是未定义行为。
panic -unwind |
强制 unwind,无析构函数 | 强制 unwind,有析构函数 | 其他外部 unwind | |
---|---|---|---|---|
提案 1 和 2,"C" 边界,panic=unwind |
中止 | unwind | UB | UB |
提案 1 和 2,"C" 边界,panic=abort |
panic! 中止 (没有 unwinding 发生) |
unwind | UB | UB |
提案 1 和 2,"C unwind" 边界,panic=unwind |
unwind | unwind | unwind | unwind |
提案 1,"C unwind" 边界,panic=abort |
panic! 中止 |
中止 | 中止 | 中止 |
提案 2,"C unwind" 边界,panic=abort |
panic! 中止 |
unwind | UB | 中止 |
提案 3,"C" 边界,panic=unwind |
unwind | unwind | unwind | unwind |
提案 3,"C" 边界,panic=abort |
panic! 中止 |
unwind | UB | UB |