前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【Rust 研学】Rust Nation UK 2024 | Rust ABI 稳定之路

【Rust 研学】Rust Nation UK 2024 | Rust ABI 稳定之路

作者头像
张汉东
发布2024-04-22 11:28:09
3820
发布2024-04-22 11:28:09
举报
文章被收录于专栏:Rust 编程

本系列为我学习 Rust Nation UK 2024 大会的笔记,不会是所有演讲,只拣一些我感兴趣的内容。本文原视频回放在 The path to a stable ABI for Rust[1] ,演讲者为 Rust 官方团队成员也是当前库团队共同 Leader Amanieu D'Antras,也是 parking_lot 库的作者。

“Rust ABI 什么时候稳定 ? 省流:现在只明确了有哪些挑战和可能的解决方案,但具体什么时候 ABI 能稳定没有确切时间。建议十年内不要再问这个问题

为什么需要稳定的 ABI

从 API 谈起

介绍 ABI 之前,先来说一说 API。

简单来说,API 就是软件应用之间进行通信的一种接口。你用 Rust 写一个 hello world 程序,使用 Rust 标准库提供的 println! 宏就是一个 API,它内部会调用底层操作系统的相关 C API 进行通信,最终输出 “hello world” 到终端显示。

Rust 中每个 crate,也就是编译器的一个编译单元,对应于 API 模型中的一个组件。因此 crate 由一些模块组成,最终会把一些 public 的 API 导出供其他 crate 调用,隐藏一些 private 的接口供内部调用。

当 Rust 中 公共的 API 函数签名类型发生了变化,那么就认为这是一个 Breaking Change 的变化,因为它让 API 契约发生了根本性变化,导致下游依赖该 API 的组件就会发生编译和运行 breaking。

但是当你修改公共 API 类型的私有字段,则不会导致下游依赖出现问题,这算是合理的变化。但是前提是这种修改不会导致这个类型的内存布局发生变化。

但是你如果增加了公共字段,就会导致下游依赖发生 breaking 错误。

但是这种修改有时候也无法避免,所以 Rust 提供了一种策略:非详尽(non-exhaustive)属性。

通过这个属性,下游依赖库就不会把这个结构体当作再也不会变化的类型了,从而避免了上游增加新的公共字段而引发的问题。

cargo book 有一些关于 Non-breaking changes 和 breaking changes 的对比,可以去学习。

Rust 编译模型

Rust crate 从源码编译为 rlib 文件(Rust ABI 的静态库),有点像 C 的 .o 文件和头文件一样。它会被下游依赖。

一个 rlib 是一个类似于 tar 文件的存档文件,这个文件格式是特定于 rustc 的。它包括:

  1. 对象代码,即代码生成的结果。在常规链接过程中使用。每个代码生成单元都有一个单独的 .o 文件。
  2. LLVM 位码,是 LLVM 中间表示的二进制表示形式,作为 .o 文件中的一个部分嵌入其中。
  3. rustc 元数据,保存在名为 lib.rmeta 的文件中。
  4. 一个符号表,通常是一个包含符号和包含该符号的目标文件的偏移量的列表。

当项目里所有 crate 被编译完成之后,它们会被链接到一个二进制文件中。这些被链接的所有 crate,要求必须使用同一个版本的 Rust 编译器。所以,现在这些 crate 还必须提供源码才能一起编译。

如果现在 Rust 有一个稳定的 ABI ,我们就可以不必拿到源码,而只需要得到每个 crate 的二进制共享库(动态库)就可以链接。并且每个 crate 都可以用不同的 Rust 版本。

这些二进制共享库之间的接口,就是 ABI

第三方库 abi_stable

目前通过第三方库 abi_stable 能使用到稳定的 Rust ABI ,但是 Rust 语言还未支持。

这个库主要由两部分组成:

  • crate 元数据。主要是定义了 Rust 语言常见的 用于编写 Rust API 的各种语言项。
  • 运行时编译的代码。包括通过动态链接器进行符号解析时使用的最小元数据。

stable ABI 的应用场景

  • 系统库
  • 框架&插件系统

作为二进制共享库有很多好处:

  • 避免代码大小膨胀
  • 动态加载,无需重新编译

Rust 稳定 ABI 的目标

  • 库应该能编译为稳定 ABI 的动态库
  • 编译后的库应该向后兼容相同库的老版本
  • 依赖稳定 ABI 的 crate 应该向前兼容相同库的新版本
  • 用不同 Rust 编译器版本编译的库应该能自由链接,这意味着它们被 rustc 向前和向后兼容
  • ABI 稳定应该尽可能优先于 API 的稳定
    • 如果没有 breaking changes 的 API 变化,ABI 也不应该 break

为什么稳定 ABI 很难

现在距离 Rust 1.0 发布已经过去近 10 年了,还没有稳定的 ABI 。为什么这么难?

当前面临两个挑战

  • 实现细节并未被 Rust 明确定义,(我理解为是没有 Rust 语言规范)。比如调用约定、符号混淆、内存布局、crate 元数据格式等等。
  • 在二进制级别,crate 之间会泄漏实现细节。比如,如果一个字段是私有的,仍然可以按值来移动它。另外,内联函数和泛型的工作方式是在编译时分发到不同的 crate 中。
其他语言怎么稳定 ABI ?

C-ABI

C-ABI 现在是事实标准。

C 的 ABI 细节其实也是没有定义细节,比如 int 的类型到底是多大,内存布局等。都是各大平台厂商自己来定义。那么因为 C 语言足够古老,历史足够长,依赖漫长的时间作用,就成为了现在的事实标准。

C 语言另外一点是没有 private 的概念,头文件中所有内容也默认地成为了 ABI 的一部分。

Cpp ABI

Cpp ABI 同样没有标准 ABI,也是由平台定义的。Cpp 也保留了头文件。

Java

Java 因为是有虚拟机运行时生成字节码并提供 jit 编译,所以避免了 ABI 稳定的问题。Java 的一切在运行时都知晓。但是 Rust 作为系统语言,无法提供一个 Java 这样的运行时。

Swfit

Swift 语言是苹果设计的,差不多和 Rust 1.0 同时发布,同时也吸取了 Rust 语言的一些设计。Swift 5 在 2019 年稳定了 ABI。对于苹果系统和应用开发起到了很多积极效果。

Rust 官方团队未来的稳定 ABI 计划也深受 Swift 启发

Rust 稳定 ABI 之路(挑战)

首先,需要明确定义那些实现细节

但是这里面有一些问题:一旦定义,就无法被改变。

但这个问题解决方案比较简单:需要一种可扩展的元数据格式,以便兼容未来新增的 Rust 特性。暂且不表。

重要的是,如何解决泄漏实现细节的问题。

考虑这个案例。

在 crate A 中定义了结构体 Foo ,然后在 crate B 中使用它。

对于 crate B 来说,crate A 中的公开 API 类型总是有固定的大小、内存布局、固定的字段偏移量等信息。

但是当 crate A 的 Foo 结构体增加了一些私有字段之后,crate B 就完全不知道它的大小、内存布局等,甚至不知道字段偏移量,因为 Rust 编译器会对其自动重排来优化。

那么 crate B 如何与 crate A 一起“工作”呢

解决方案是让 crate A 导出一个类型描述符,其中包含使用此类型所需的所有信息,包括大小、内存布局、析构函数等信息。该类型描述符可以作为共享库的一个符号导出,然后 crate B 用动态链接器加载该符号。字段偏移量也会是单独的符号,交给动态链机器来处理。因为字段重排不被看作是 ABI 的一个 breaking 变化。

crate B 在编译时通过一个动态的栈分配来使用 Foo 类型,因为它的大小是未知的。

还需要一个适配器(adapter)来调用 Foo::new 方法。不能直接调用 new 是因为 new 是按值(by value)返回类型,但是 Rust 调用约定要求传递一个类型必须要知道它的大小和布局。所以需要这个适配器来传递类型。

当存在多个依赖的结构体怎么办?比如 Foo 作为 Bar 的私有字段。

解决方案就是需要在程序启动加载 crate A 时初始化 Bar 的类型。

对于枚举类型来说,它如果被标记为 Non Exhaustive ,编译器会认为它的布局是不确定的,所以也需要类型描述符。类似于结构体类型描述符,但是其中的变体是通过函数而非偏移量来获取。

可以利用类型中没有使用到的 niche 值进行优化。

niche 一般翻译为“利基”,这个词用在商业和市场领域可能比较适合。但是在技术领域,比如在 Rust 中就不太好。 在Rust编程语言中,"niche"具有特定的含义,指的是一种类型中的未使用的值,可以用来进行枚举类型的内存布局优化。 Rust “niche”指的是某个类型可能存在的未被使用的值,这些值不会代表该类型的有效状态。例如,对于一个不可为空的指针来说,0通常不是一个有效的地址,所以可以视为一个“niche”。对于布尔类型(bool),它只有两个可能的值:truefalse。任何不是这两个值的布尔类型的表示都可以被视为一个“niche”。如果一定要翻译的话,可以翻译为“利优化值” ,直接表达其意,代表它隐藏了某种优化空间。

图中的Option<T>枚举,它有两个变体:Some(T)None。如果Tu64类型,size_of::<Option<u64>>()将会是16字节:8字节用于数据T,8字节用于辨识标签。但是,如果我们将T放入Box中(一个指针),因为指针不能为0(这是一个“niche”),我们可以用0这个值来代表None变体,从而不需要额外的空间来存储辨识标签。这就是为什么size_of::<Option<Box<()>>>()等于8字节,因为Box指针本身可以用来区分SomeNone变体。对于引用来说,它不可能为空(一个niche 信息),所以 &mut Option<T> 也不可能为 None 。

niche 值和 niche 信息的存在,让类型描述符更加复杂

泛型的实现方式是单态化(Monomorphization)。可以理解为它只是一种编译时模版,为每组类型生成专用的实例,就是单态化。

所以,crate A 中的泛型其实并不知道 crate B 中通过哪些具体类型来使用它。

对于 稳定 ABI 来说,这也是一个挑战。因为当 crate A 里的泛型发生变化,crate B 的代码会遭到破坏。

对这个问题的解决方案是:多态化(Polymorph)。只需要创建一个可以处理任何类型的单个函数。该函数将类型描述符作为参数。

对于泛型的 trait 限定来说,需要一种 trait 描述符。

trait 描述符更加复杂,包含关联类型、关联常量和方法等更复杂的元素。

总的来说,要达到稳定的 ABI ,crate 之间不能依赖对方的私有实现。解决方法是通过引入类型描述符、trait 描述符、泛型编译时多态化、描述符导出为符号等方法来解决。

不幸的是,有一些限制导致只能实现稳定重要特性的 95% 。其中比较重要的限制是:

  • 泛型函数无法转换为函数指针。
  • 某些 SIMD 指令需要编译时常量时无法和多态函数一起工作,因为它需要一些运行时才能获取的值。
  • trait 特化还未稳定。
  • 动态分配类型和 trait 描述符在某些环境不可用。

性能优化

如上所述,稳定 ABI 的解决方案实际上引入了一些中间层。那么如何优化呢?

有时候会通过泄露实现细节来获取性能提升。比如这个例子,结构体 Foo 如果增加私有字段怎么办?

我们知道稳定 ABI 的一个 “niche” 点是内存布局永远不会改变,即,它是被“冻结的”。这允许编译器和工具在处理类型实例时做出一些优化,因为它们可以依赖于该类型布局的稳定性。

通过使用#[abi_stable]属性冻结类型布局,实际上是在向编译器明确地暴露了这个类型的内存布局,并承诺这个布局将来不会更改。如果编译器知道一个类型的内存布局不会改变,那么在执行某些操作时,就不需要通过类型描述符来间接访问这个类型的实例。这样可以直接操作内存,减少了函数调用(如memcpy)的需要。编译器可以针对已知且不变的内存布局进行特定的优化,如对齐优化、预取指令的插入等,以提高缓存一致性和访问效率。

另外一个问题是内联函数。

Rust 严重依赖于内联函数的性能。比如你不能简单地内联迭代器的 map 方法,很可能会导致 rust 程序的性能被破坏。

map 是泛型方法,可能被多次调用,或者可能被传递到其他上下文中。内联这样的函数可能导致编译器生成非常大的代码,这可能会适得其反,影响程序的性能和缓存利用效率。

稳定 ABI 会承诺内联函数也永远不会被改变。

  • 当函数被标记为内联导出(#[inline(export)])时,它们被编译到使用它们的下游crate中,因此,这些函数访问的任何内容隐式地成为了ABI的一部分。
  • 为了避免意外依赖于非ABI稳定的接口,Rust要求使用#[inline(export)]标记的函数只能访问公开字段或用#[abi_stable]标记的私有字段。

结构体Foo被标记为#[abi_stable],意味着所有字段都被认为是稳定的,并可以被认为是ABI的一部分。即使有私有字段field2,它也被包括在内,因为整个结构体被标记为稳定。

在第二个Foo结构体的例子中,只有字段field3被标记为#[abi_stable]。这意味着即使field1是公共的,只有field3被显式地视为ABI稳定。私有字段field2没有被标记,因此不被视为ABI稳定的部分。

这样,在不牺牲代码的封装性和安全性的前提下,有意识地选择哪些部分可以为了性能而暴露,哪些部分应该保持私有。这种灵活性设计也算是Rust语言的一个重要特点

在 ABI 级别,需要明白哪些是 breaking changes,和前面的 API 级别的 changes 列表有所区别。

将来也会增加类似于检查 API 语义化版本的工具来检查 ABI 的版本。

后记

Amanieu 并未给出 Rust Stable ABI 的确切实现时间里程碑,目前还处于早期提案阶段。可想而知,这应该是一个相对漫长的时间。

现在想使用 stable abi,只能通过一些第三方库来实现。比如 rodrimati1992/abi_stable_crates[2]ZettaScaleLabs/stabby[3]avl/savefile[4] 等。

也有人建议,不如实现一个 COM协议,但是另外一些人说这同样是“地狱”。

目前 Rust 官方(Josh Triplett)也给出了一个方案:`crabi`[5] 。这个提案讨论了开发一个新的应用程序二进制接口(ABI),名为“crabi”,以及一个新的内存表示形式repr(crabi),用于在拥有安全数据类型的高级编程语言之间实现互操作性。

提案强调crabi将是 Rust 对 C ABI 的严格超集,确保即使对于crabi尚未支持的功能,用户仍然可以选择使用它们自己转换到原始 C ABI 的功能,同时仍然使用crabi所支持的内容。

社区也有人对 crabi进行了反驳,认为其实不需要稳定的 ABI[6],真正需要的只是类型信息。他写文章指出稳定的 ABI 可能并不是阻碍 Rust 成为与其他语言互操作的语言的主要问题。在不同语言之间建立桥梁,并确保跨库的类型安全,才是更根本的挑战。作者举例了几种旨在解决多语言二进制互操作性问题的工具,如cbindgendiplomat、和safer-ffi,它们都利用了某种形式的类型信息。他认为,编译器具有解决互操作性问题的潜力,因为它拥有所有必要的信息。如果我们能以某种结构化的方式暴露这些信息,每个人都能从中受益。他提出了一个名为`ctti`[7]的示例crate,它提供了编译时的类型信息。虽然它尚未完全成熟,但已经证明了类型信息的充分性。作者的结论是,为了实现真正的语言间互操作性,我们需要的是构建其他必要互操作性生态系统部分的基础——类型信息。

对此,你怎么看待?

感谢阅读。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2024-04-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 觉学社 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 为什么需要稳定的 ABI
    • 从 API 谈起
      • Rust 编译模型
        • 第三方库 abi_stable
          • stable ABI 的应用场景
          • Rust 稳定 ABI 的目标
            • 为什么稳定 ABI 很难
              • 其他语言怎么稳定 ABI ?
            • Rust 稳定 ABI 之路(挑战)
            • 性能优化
            • 后记
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档