宣布首个 FFI 解除堆栈项目设计会议

2020 年 2 月 27 日 · Kyle Strand、Niko Matsakis 和 Amanieu d'Antras 代表 FFI 解除堆栈项目组

FFI 解除堆栈项目组(在 此 RFC 中宣布)正在努力扩展该语言,以支持跨越 FFI 边界的堆栈解除。

我们已经到达了第一个技术决策点,这是我们内部讨论了相当一段时间的问题。这篇博文阐述了问题双方的论点,并邀请 Rust 社区加入我们即将举行的会议,以帮助最终确定我们的决定,该决定将正式化并作为我们的第一个语言变更 RFC 发布。此 RFC 将提出一个关于明确定义的跨语言堆栈解除的“MVP”规范。

会议将于 3 月 2 日举行。

背景:什么是堆栈解除?

异常是许多编程语言中熟悉的控制流机制。它们在 Java 等托管语言中尤其常见,但它们也是 C++ 语言的一部分,C++ 将它们引入了非托管系统编程领域。

当抛出异常时,运行时会解除堆栈,本质上是向后遍历堆栈并调用清除或错误恢复代码,例如析构函数或 catch 块。

编译器可以实现它们自己的堆栈解除机制,但在 Rust 二进制文件等本机代码中,该机制更常由平台 ABI 提供。

众所周知,Rust 本身没有异常。但是 Rust 确实支持堆栈解除!有两种情况会导致堆栈解除的发生

  • 默认情况下,Rust 的 panic!() 会解除堆栈。
  • 使用 FFI,Rust 可以调用其他语言(如 C++)中的函数,这些函数可以解除堆栈。
    • 在某些特殊情况下,C 库实际上可能会导致堆栈解除。例如,在 Microsoft 平台上,longjmp 被实现为“强制解除堆栈”(见下文)

目前,当外部(非 Rust)代码调用 Rust 代码时,从 Rust “逃逸”的 panic!() 解除堆栈的行为是明确未定义的。同样,当 Rust 调用一个解除堆栈的外部函数时,一旦堆栈解除操作遇到 Rust 帧,其行为也是未定义的。这样做的主要原因是确保 Rust 实现可以使用它们自己的堆栈解除机制,这可能与平台提供的“本机”堆栈解除机制不兼容。然而,目前 rustc 使用的是本机机制,并且没有计划更改此机制。

强制解除堆栈

平台 ABI 可以定义一种特殊的堆栈解除,称为“强制解除堆栈”。这种类型的堆栈解除无论被解除堆栈的代码是否支持堆栈解除都可以工作。但是,如果被解除堆栈的帧不支持堆栈解除,则可能不会执行析构函数。

以下是强制解除堆栈的两个常见示例

  • 在 Windows 平台上,longjmp 被实现为强制解除堆栈。
  • 在 glibc Linux 上,pthread_exitpthread_cancel 被实现为强制解除堆栈。
    • 实际上,pthread_cancel 可以导致各种 C 函数解除堆栈,包括诸如 readwrite 之类的常用函数。(要获取完整列表,请在 pthreads 手册页中搜索“取消点”。)

任何跨语言堆栈解除规范的要求

  • Rust 函数之间的堆栈解除(特别是 Rust panic 的解除堆栈)不一定使用系统堆栈解除机制
    • 实际上,我们今天使用的是系统机制,但我们希望保留更改此机制的自由。
  • 如果启用 -Cpanic=abort,我们可以优化二进制文件的大小,从而删除与堆栈解除相关的大部分代码。
    • 即使使用 -Cpanic=unwind,当已知永远不会发生堆栈解除时,也应该可以优化掉代码。
    • 实际上,大多数“C”函数都不会被期望解除堆栈(例如,因为它们是用 C 编写的,而不是用 C++ 编写的)。
      • 但是,由于堆栈解除现在是大多数系统 ABI 的一部分,即使是 C 函数也可以解除堆栈——最值得注意的是 pthread_cancel 触发的取消点。
  • 将行为从 -Cpanic=unwind 更改为 -Cpanic=abort 不应导致未定义行为。
    • 但是,这可能站不住脚,或者至少在不使二进制文件大得多的情况下站不住脚。有关更多详细信息,请参见下面的讨论。
    • 当然,它可能会导致曾经成功执行的程序中止。如果 panic 可能会被捕获并恢复,则可能会发生这种情况。
  • 我们不能更改 libc crate 中函数的 ABI(extern "C" 中的 "C"),因为这将是一项破坏性的更改:不同 ABI 的函数指针具有不同的类型。
    • 这与 pthread_cancel 被调用时可能执行强制堆栈解除的 libc 函数有关。

主要问题:引入新的 ABI,还是让 "C" ABI 允许堆栈解除?

我们想决定的核心问题是,Rust 定义的 "C" ABI 是否应允许堆栈解除。

我们没想到会讨论这个问题。我们早就声明,通过 Rust 的 "C" ABI 进行堆栈解除是未定义行为。部分原因是,没有人花时间弄清楚正确的行为应该是什么,或者如何实现它,尽管(我们很快就会看到)这种选择还有其他很好的理由。

无论如何,在 PR #65646 中,@Amanieu 提出,实际上我们可以简单地定义跨 "C" 边界进行堆栈解除的行为。在讨论这个问题时,我们发现 "C" ABI 是否应该允许堆栈解除的问题并不像我们假设的那样明确。

如果 "C" ABI 不允许堆栈解除,则将引入一个新的 ABI,称为 "C unwind",专门用于支持堆栈解除。

三个具体的提案

项目组已将设计空间缩小到三个具体的提案。其中两个引入了新的 "C unwind" ABI,一个没有引入。

每个提案都指定了每种类型的堆栈解除(Rust panic!、外部(例如 C++)和强制(例如 pthread_exit))在 panic=unwindpanic=abort 编译模式下遇到 ABI 边界时的行为。

请注意,目前,catch_unwind 不会拦截外部堆栈解除(强制或非强制),并且我们最初的 RFC 不会对此进行更改。我们可能会在稍后决定定义一种 Rust 代码拦截外部异常的方法。

在全文中,由 panic! 生成的堆栈解除将称为 panic 解除堆栈。

提案 1:引入 "C unwind",最小规范

  • "C" ABI 边界,panic=<any>
    • panic 解除堆栈:程序中止
    • 强制解除堆栈,无析构函数:堆栈解除正常运行
    • 其他外部堆栈解除:未定义行为
  • "C unwind" ABI 边界
    • 使用 panic=unwind:所有类型的堆栈解除都正常运行
    • 使用 panic=abort:所有类型的堆栈解除都使程序中止

此提案提供了 2 个 ABI,每个 ABI 都适合不同的用途:您通常会在与 C API 交互时使用 extern "C"(确保在使用 longjmp 的地方避免使用析构函数),并在与 C++ API 交互时使用 extern "C unwind"。此提案的主要优点是,如果您已正确将所有潜在的堆栈解除调用标记为 "C unwind",则在 panic=unwindpanic=abort 之间切换不会引入 UB(您的程序将改为中止)。

提案 2:引入 "C unwind",始终允许强制解除堆栈

这与之前的设计相同,只是在使用 panic=abort 编译时,强制解除堆栈不会在 "C unwind" ABI 边界处被拦截;也就是说,它们将正常运行(尽管如果存在任何析构函数,仍然是 UB),而不会导致程序中止。panic 解除堆栈和非强制外部异常仍然会导致程序中止。

区别对待强制解除堆栈的优点是,它可以减少可移植性不兼容性。具体而言,它确保当目标平台和/或编译标志更改时,使用 "C unwind" 不会导致 longjmppthread_exit 停止工作(中止程序)。根据提案 1,longjmp 将能够跨越 "C unwind" 边界,除非panic=abort 下在 Windows 上使用 MSVC,并且 pthread_exit 将在 "C unwind" 函数内部工作,除非panic=abort 下与 glibc 链接。此提案的缺点是,panic=abort"C unwind" 调用周围的中止存根变得更加复杂,因为它们需要区分不同类型的外部异常。

提案 3:没有新的 ABI

  • panic=unwind:堆栈解除正常运行
  • panic=abort:
    • panic 解除堆栈:不存在;panic! 使程序中止
    • 强制解除堆栈,无析构函数:堆栈解除正常运行
    • 其他外部堆栈解除:未定义行为

此提案的主要优点是其简单性:只有一个 ABI,并且 panic=abort 的行为与 C++ 中的 -fno-exceptions 的行为相同。但是,其缺点是,如果 FFI 调用通过 Rust 代码进行堆栈解除,则在某些情况下切换到 panic=abort 可能会引入 UB(尽管仅在不安全代码中)。

另一个优点是,从诸如 pthread_exitlongjmp 之类的 libc crate 中定义的现有函数进行的强制堆栈解除将能够在 panic=unwind 编译时解除带有析构函数的帧,这对于其他提案是不可能的。

拟议设计的比较表

在此表中,“UB”代表“未定义行为”。我们认为所有这些未定义行为的实例都可以在运行时检测到,但是这样做所需的代码会带来不希望的代码大小惩罚,完全抵消了使用 panic=unwind 或非解除堆栈的 "C" ABI 所带来的优化。因此,此代码仅适用于调试版本。此外,实现此类检查的复杂性可能会超过其好处。

请注意,在不运行这些析构函数的情况下(例如,因为它们已被 panic=abort 优化掉)通过具有析构函数的帧进行堆栈解除始终是未定义行为。

panic 解除堆栈 强制解除堆栈,无析构函数 强制解除堆栈,带有析构函数 其他外部堆栈解除
提案 1 和 2,"C" 边界,panic=unwind 中止 解除堆栈 UB UB
提案 1 和 2,"C" 边界,panic=abort panic! 中止(不发生堆栈解除) 解除堆栈 UB UB
提案 1 和 2,"C unwind" 边界,panic=unwind 解除堆栈 解除堆栈 解除堆栈 解除堆栈
提案 1,"C unwind" 边界,panic=abort panic! 中止 中止 中止 中止
提案 2,"C unwind" 边界,panic=abort panic! 中止 解除堆栈 UB 中止
提案 3,"C" 边界,panic=unwind 解除堆栈 解除堆栈 解除堆栈 解除堆栈
提案 3,"C" 边界,panic=abort panic! 中止 解除堆栈 UB UB