|
1 | 1 | # 蚂蚁集团 | Trait Object 还是 Virtual Method Table
|
| 2 | + |
| 3 | + |
| 4 | +> Trait object 是 Rust 动态分发的实现方式。在 2021 年 4 月发刊的 Rust Magazine 中,Jiacai Liu 同学在《Trait 使用及实现分析》文章中介绍了 Rust 中 Ad-hoc 多态的使用方式,包括静态分发与动态分发,并且对 trait object 中的对象安全问题以及原因做出了详细解释。 |
| 5 | +> 那么,使用 trait object 就是 Rust 中动态分发的终点吗?事实上我们发现,在很多 Rust 代码中使用了原始的虚表而不是 trait object,这其中的原因又是什么呢? |
| 6 | +> 在本文中,会先简单介绍一下 trait object 与虚表,然后结合笔者挑选出的几个具有代表性的代码片段,讨论手动构造虚表而不使用 trait object 的优缺点。 |
| 7 | +
|
| 8 | +## 简介 |
| 9 | + |
| 10 | +在 Rust 中使用 trait 实现多态有两种方式,静态分发或者动态分发。静态分发使用 trait bound 或者 impl trait 方式实现编译期单态化,根据类型参数生成对应的结构或者函数。动态分发使用 trait object 的方式实现,而由于 trait object 是动态大小类型,无法在编译期确定类型大小,所以一般会使用指向 trait object 的引用或者指针来操作 trait object。而指向 trait object 的引用或者指针本质上是一个胖指针,其中包含了指向擦除了具体类型的对象指针与虚函数表。所以每次调用 trait object 的方法时,需要解引用该胖指针,所以部分观点认为动态分发比静态分发开销更大,而相反的观点认为使用静态分发会导致编译时间变长,编译后二进制文件膨胀以及增加缓存失效概率等问题,所以具体使用哪种方式就见仁见智了。 |
| 11 | + |
| 12 | + |
| 13 | + |
| 14 | +然而,标准的 `Trait` 结构也有着一些缺陷,比如由于对象安全的要求,一些 trait 无法通过 trait object 的方式使用。所以我们在使用或者阅读一些 Rust crate 的时候会发现,这些库实现了自己的 trait object 结构,比如标准库的 `RawWaker` 结构,`tokio` 的 `RawTask` 结构,`bytes` 的 `Bytes` 结构,`anyhow` 的 `ErrorImpl` 结构,以及关于类型擦除的内存分配器 [^1] 的讨论。在接下来的内容里,我对其中几个实现进行了一些粗浅的分析,并结合一些已有的讨论 [^2],尝试总结它们的共同点。笔者水平有限,如有错漏,烦请指出。 |
| 15 | + |
| 16 | +## Examples |
| 17 | + |
| 18 | +<!-- 在这里,我挑选了几个在 Rust 生态系统中广泛使用的 crate,甚至是 Rust 标准库的一些内容。它们的共同点在于,使用了手动实现的虚表来实现动态分发。 --> |
| 19 | + |
| 20 | +### `std` 中的 `RawWaker` {#RawWaker} |
| 21 | + |
| 22 | +Rust 异步编程的核心是 Executor 与 Reactor,其中 Reactor 部分主要是 `Waker` 结构。查看源代码发现 `Waker` 结构仅仅包装了 `RawWaker` 结构。而 `RawWaker` 的结构与指向 trait object 的胖指针十分相似,包含了一个指向任意类型的数据指针 `data` 与自定义此 `Waker` 行为的虚函数指针表 `vtable`。当调用 `Waker` 的相关方法时,实际上会调用虚表中对应的函数,并将 `data` 作为函数的第一个参数传入。 |
| 23 | + |
| 24 | +```Rust |
| 25 | +/// A `RawWaker` allows the implementor of a task executor to create a [`Waker`] |
| 26 | +/// which provides customized wakeup behavior. |
| 27 | +/// |
| 28 | +/// [vtable]: https://en.wikipedia.org/wiki/Virtual_method_table |
| 29 | +/// |
| 30 | +/// It consists of a data pointer and a [virtual function pointer table (vtable)][vtable] |
| 31 | +/// that customizes the behavior of the `RawWaker`. |
| 32 | +#[derive(PartialEq, Debug)] |
| 33 | +#[stable(feature = "futures_api", since = "1.36.0")] |
| 34 | +pub struct RawWaker { |
| 35 | + /// A data pointer, which can be used to store arbitrary data as required |
| 36 | + /// by the executor. This could be e.g. a type-erased pointer to an `Arc` |
| 37 | + /// that is associated with the task. |
| 38 | + /// The value of this field gets passed to all functions that are part of |
| 39 | + /// the vtable as the first parameter. |
| 40 | + data: *const (), |
| 41 | + /// Virtual function pointer table that customizes the behavior of this waker. |
| 42 | + vtable: &'static RawWakerVTable, |
| 43 | +} |
| 44 | + |
| 45 | +#[stable(feature = "futures_api", since = "1.36.0")] |
| 46 | +#[derive(PartialEq, Copy, Clone, Debug)] |
| 47 | +pub struct RawWakerVTable { |
| 48 | + clone: unsafe fn(*const ()) -> RawWaker, |
| 49 | + wake: unsafe fn(*const ()), |
| 50 | + wake_by_ref: unsafe fn(*const ()), |
| 51 | + drop: unsafe fn(*const ()), |
| 52 | +} |
| 53 | +``` |
| 54 | + |
| 55 | +在学习这部分代码的时候,我产生了一个疑问,为什么不使用 Rust 提供的 `Trait` 作为 `Waker` 的抽象,而是要手动实现一个类似 trait object 胖指针的复杂,危险且容易出错的 `RawWaker`。为了解开这个疑问,我尝试使用 `Trait` 来模拟 `RawWaker` 的功能。 |
| 56 | + |
| 57 | +```Rust |
| 58 | +pub trait RawWaker: Send + Sync { |
| 59 | + fn clone(&self) -> Box<dyn RawWaker>; |
| 60 | + |
| 61 | + fn wake(&self); |
| 62 | + fn wake_by_ref(&self); |
| 63 | +} |
| 64 | + |
| 65 | +impl Clone for Box<dyn RawWaker> { |
| 66 | + fn clone(&self) -> Self { |
| 67 | + RawWaker::clone(&**self) |
| 68 | + } |
| 69 | +} |
| 70 | + |
| 71 | +pub struct Waker { |
| 72 | + waker: Box<dyn RawWaker>, |
| 73 | +} |
| 74 | +``` |
| 75 | + |
| 76 | +根据虚表 `RawWakerVTable` 中要求的方法,我们可以写出一个简单的 `RawWaker` trait。这里遇到了几个问题,首先,`RawWaker` 要求实现 `Clone`,这样做的原因在 Saoirse Shipwreckt [^3] 的博客文章中有过简单的总结: |
| 77 | + |
| 78 | +> 当事件源注册某个 `future` 将等待一个事件时,它必须存储 `Waker`,以便稍后调用 `wake` 方法。为了引入并发,能够同时等待多个事件是非常重要的,因此 `Waker` 不可能由单个事件源唯一拥有,所以 `Waker` 类型需要是可克隆的。 |
| 79 | +
|
| 80 | +然而,`Clone` trait 本身不是对象安全的,因为它有着 `Sized` supertrait 限定。也就是说,如果我们使用 `pub trait RawWaker: Clone` 的写法,则该 `trait` 将无法作为 trait object 使用。所以在使用 `trait` 模拟的 `RawWaker` 中,我退而求其次的为 `Box<dyn RawWaker>` 实现了 `Clone`,并将具体的细节转移到了 `RawWaker::clone` 内,这样一来,每次调用 `clone` 方法都会构造一个新的 trait object,并且这些 trait object 会共享同一些数据。 |
| 81 | + |
| 82 | +其次,为了能够在多线程环境中使用,我要求 `RawWaker` 的 supertrait 为 `Send + Sync`,这样我们可以将其在多个线程间共享或者发送到某个线程中。 |
| 83 | + |
| 84 | +最后,为了通过指针使用 trait object,我们需要通过将该对象装箱在堆上。那么我们应该选用哪种智能指针呢?在上面的代码中,我使用了 `Box` 作为具体的指针类型,不使用 `Arc` 的原因是唤醒器中公共数据的共享方式应该由具体的实现决定。比如 `RawWaker` 的实现可以使用引用计数的方式跟踪另一个堆上的对象,或者全部指向静态全局的某个对象,比如: |
| 85 | + |
| 86 | +```Rust |
| 87 | +use std::sync::Arc; |
| 88 | + |
| 89 | +#[derive(Debug, Default)] |
| 90 | +struct RcWakerInner {} |
| 91 | + |
| 92 | +#[derive(Debug, Default)] |
| 93 | +pub struct RcWaker { |
| 94 | + inner: Arc<RcWakerInner>, |
| 95 | +} |
| 96 | + |
| 97 | +impl RawWaker for RcWaker { |
| 98 | + fn clone(&self) -> Box<dyn RawWaker> { |
| 99 | + Box::new(RcWaker { |
| 100 | + inner: self.inner.clone(), |
| 101 | + }) |
| 102 | + } |
| 103 | + |
| 104 | + fn wake(&self) { |
| 105 | + todo!() |
| 106 | + } |
| 107 | + |
| 108 | + fn wake_by_ref(&self) { |
| 109 | + todo!() |
| 110 | + } |
| 111 | +} |
| 112 | + |
| 113 | +static GLOBAL_RAW_WAKER: StaticWakerInner = StaticWakerInner {}; |
| 114 | + |
| 115 | +#[derive(Debug, Default)] |
| 116 | +struct StaticWakerInner {} |
| 117 | + |
| 118 | +#[derive(Debug)] |
| 119 | +pub struct StaticWaker { |
| 120 | + global_raw_waker: &'static StaticWakerInner, |
| 121 | +} |
| 122 | + |
| 123 | +impl Default for StaticWaker { |
| 124 | + fn default() -> Self { |
| 125 | + Self { |
| 126 | + global_raw_waker: &GLOBAL_RAW_WAKER, |
| 127 | + } |
| 128 | + } |
| 129 | +} |
| 130 | + |
| 131 | +impl RawWaker for StaticWaker { |
| 132 | + fn clone(&self) -> Box<dyn RawWaker> { |
| 133 | + Box::new(StaticWaker { |
| 134 | + global_raw_waker: self.global_raw_waker, |
| 135 | + }) |
| 136 | + } |
| 137 | + |
| 138 | + fn wake(&self) { |
| 139 | + todo!() |
| 140 | + } |
| 141 | + |
| 142 | + fn wake_by_ref(&self) { |
| 143 | + todo!() |
| 144 | + } |
| 145 | +} |
| 146 | +``` |
| 147 | + |
| 148 | +接下来我们将标准库的 `RawWaker` 与上述实现方式进行一些对比可以发现: |
| 149 | + |
| 150 | +- 由于我们发现 `Box<dyn RawWaker>` 首先经过了一层指针的包装,用来实现 trait object,而具体的 `RawWaker` 实现也很可能会使用指针来共享同一个对象。这样的不仅会在堆内存中占用额外的存储空间,产生许多小对象,也会由于解引用多级指针而带来额外的时间开销。 |
| 151 | +- 标准库的 `Waker` 位于 `core::task` 模块下,而 `Box` 与 `Arc` 等结构都位于 `alloc` 模块下,它们都是 `std` 的子集。在通常的 `std` 应用程序中,我们确实可以使用 `Arc` 等智能指针。但 Rust 不想在 `no_std` 的 futures 上妥协,所以我们必须使用特别的技巧来实现这种能力。 |
| 152 | + |
| 153 | +考虑到上面的这些原因,Rust 选择了使用数据指针与虚表的方式来实现高性能的动态分发。`std::task::RawWaker` 中的 `data` 指针既提供了类型擦除的能力,也实现了对象的共享,非常的灵活。 |
| 154 | + |
| 155 | +### `tokio` 与 `async-task` 中的 `RawTask` {#RawTask} |
| 156 | + |
| 157 | +在 `tokio` 与 `async-task` 的代码中,都将 `RawTask` 作为 `Task` 结构的具体实现。与刚才提到的 `RawWaker` 相似,`RawTask` 也通过虚表提供了类似于 trait object 的功能,然而,它们在内存布局上却有着不同的选择。下面以 `tokio::runtime::raw::RawTask` 为例。 |
| 158 | + |
| 159 | +```Rust |
| 160 | +#[repr(C)] |
| 161 | +pub(crate) struct Header { |
| 162 | + pub(super) state: State, |
| 163 | + pub(super) owned: UnsafeCell<linked_list::Pointers<Header>>, |
| 164 | + pub(super) queue_next: UnsafeCell<Option<NonNull<Header>>>, |
| 165 | + |
| 166 | + /// Table of function pointers for executing actions on the task. |
| 167 | + pub(super) vtable: &'static Vtable, |
| 168 | + |
| 169 | + pub(super) owner_id: UnsafeCell<u64>, |
| 170 | + #[cfg(all(tokio_unstable, feature = "tracing"))] |
| 171 | + pub(super) id: Option<tracing::Id>, |
| 172 | +} |
| 173 | + |
| 174 | +/// Raw task handle |
| 175 | +pub(super) struct RawTask { |
| 176 | + ptr: NonNull<Header>, |
| 177 | +} |
| 178 | +``` |
| 179 | + |
| 180 | +通过与 `RawWaker` 的结构相对比,我们发现 `RawTask` 将虚表部分移动到了数据指针 `ptr` 内。这么做的好处显而易见,`RawTask` 的内存结构更为紧凑,只需要占用一个指针的大小,缺点是多了一层解引用的开销。 |
| 181 | +所以,通过自定义类似 trait object 胖指针的结构,我们可以控制内存布局,使指针更瘦,或者更胖(比如 `async_task::raw::RawTask`)。 |
| 182 | + |
| 183 | +### `bytes` 中的 `Bytes` {#Bytes} |
| 184 | + |
| 185 | +`bytes` crate 提供用于处理字节的抽象,它包含了一种高效的字节缓冲结构与相关的 traits。其中 `bytes::bytes::Bytes` 是用于存储和操作连续内存切片的高效容器,这是通过允许多个 `Bytes` 对象指向相同的底层内存来实现的。`Bytes` 结构充当了接口的功能,本身占用四个 `usize` 的大小,主要包含了内联的 trait object。 |
| 186 | + |
| 187 | +```Rust |
| 188 | +pub struct Bytes { |
| 189 | + ptr: *const u8, |
| 190 | + len: usize, |
| 191 | + // inlined "trait object" |
| 192 | + data: AtomicPtr<()>, |
| 193 | + vtable: &'static Vtable, |
| 194 | +} |
| 195 | +``` |
| 196 | + |
| 197 | +它的虚表主要是 `clone` 方法,这允许 `Bytes` 的具体实现来定义具体的克隆或者共享策略。`Bytes` 的文档中举了两个例子 |
| 198 | + |
| 199 | +- 对于 `Bytes` 引用常量内存(例如通过 `Bytes::from_static()` 创建)的实现,`clone` 实现将是空操作。 |
| 200 | +- 对于 `Bytes` 指向引用计数共享存储(例如 `Arc<[u8]>`)的实现,将通过增加引用计数来实现共享。 |
| 201 | + |
| 202 | +可以看到,与 `std::task::RawWaker` 相似,`Bytes` 需要使用 `clone` 方法,并且具体的实现完全交给了实现方。如果选择 `Trait` 接口的方式,由于公共的数据部分已经是指针的形式,会引入额外的内存分配与解引用开销,感兴趣的同学可以尝试使用 `Trait` 的方式实现一下这两个例子,最终效果和上文中的 `RawWaker` 类似。而在内联了 trait object 之后,整个设计非常优雅,`data` 部分指向共享的内存,`vtable` 定义了如何进行 `clone`,其余字段作为独占的数据。 |
| 203 | + |
| 204 | +## 总结 |
| 205 | + |
| 206 | +Rust 提供了安全的抽象以避免产生安全问题或者错误。比如我们使用 `RC` 而不直接管理引用计数,使用 `Box` 而不是 `malloc/free` 直接管理内存分配。同样,`dyn Trait` 隐藏了复杂而又为危险的虚表实现,为我们提供了简单而又安全的动态分发。我们看到,上述手动实现虚表的代码中充斥着大量的 `unsafe`,稍有不慎,就会引入 bug。如果你的设计不能使用标准的 `dyn Trait` 结构来表达,那么你首先应该尝试重构你的程序,并参考以下理由来决定是否使用自定义的虚表。 |
| 207 | + |
| 208 | +- 你想要为一类指针对象实现多态,并且无法忍受多级指针解引用造成的性能开销,参考 [RawWaker](#RawWaker) 与 [Bytes](#Bytes)。 |
| 209 | +- 你想要自定义内存布局,比如像 C++ 中虚表一样紧凑的内存结构(虚表指针位于对象内),参考 [RawTask](#RawTask)。 |
| 210 | +- 你的 crate 需要在 `no_std` 环境中使用动态分发,参考 [RawWaker](#RawWaker)。 |
| 211 | +- 或者,标准的 trait object 确实无法实现你的需求。 |
| 212 | + |
| 213 | +## 相关链接 |
| 214 | + |
| 215 | +[^1]: https://github.com/rust-lang/wg-allocators/issues/33 |
| 216 | +[^2]: https://users.rust-lang.org/t/dyn-trait-vs-data-vtable/36127/3 |
| 217 | +[^3]: https://boats.gitlab.io/blog/post/wakers-i/ |
0 commit comments