分配内存

使用 NonNull 会给 Vec(实际上是所有 std 集合)的一个重要特性带来麻烦:创建空的 Vec 实际上根本不会分配内存。这与分配一个零大小的内存块不同,全局分配器不允许这样做(这会导致未定义的行为!)。因此,如果我们不能分配内存,也不能在 ptr 中放置空指针,那么在 Vec::new 中应该怎么做呢?好吧,我们就在那里放一些其他的垃圾值!

这完全没问题,因为我们已经将 cap == 0 作为没有分配内存的哨兵。我们甚至不需要在几乎任何代码中对其进行特殊处理,因为我们通常需要检查 cap > lenlen > 0。推荐的 Rust 值是 mem::align_of::<T>()NonNull 为此提供了一个便利:NonNull::dangling()。在很多地方我们都想使用 dangling,因为没有真正的分配可言,但 null 会导致编译器做坏事。

所以

use std::mem;

impl<T> Vec<T> {
    pub fn new() -> Self {
        assert!(mem::size_of::<T>() != 0, "We're not ready to handle ZSTs");
        Vec {
            ptr: NonNull::dangling(),
            len: 0,
            cap: 0,
        }
    }
}
fn main() {}

我在那里偷偷地加入了断言,因为零大小类型需要在我们的代码中进行一些特殊处理,我想暂时推迟这个问题。如果没有这个断言,我们的一些早期草案将会做一些非常糟糕的事情。

接下来,我们需要弄清楚当我们确实想要空间时该怎么做。为此,我们使用全局分配函数 allocreallocdealloc,它们在稳定版 Rust 的 std::alloc 中可用。在 std::alloc::Global 类型稳定后,预计这些函数将被弃用,转而使用该类型的方法。

我们还需要一种方法来处理内存不足(OOM)的情况。标准库提供了一个函数 alloc::handle_alloc_error,它将以平台特定的方式中止程序。我们选择中止而不是 panic 的原因是,展开可能会导致分配内存,而当你的分配器刚刚返回“嘿,我没有更多内存”时,这样做似乎不是什么好事。

当然,这有点傻,因为大多数平台实际上并不会以传统的方式耗尽内存。如果你真的开始耗尽所有内存,你的操作系统可能会通过其他方式杀死应用程序。我们最有可能触发 OOM 的方式是一次性请求过多的内存(例如,理论地址空间的一半)。因此,panic 可能是可以的,不会发生什么坏事。不过,我们正在努力尽可能地模仿标准库,所以我们只会杀死整个程序。

好了,现在我们可以编写增长逻辑了。粗略地说,我们希望有这样的逻辑

if cap == 0:
    allocate()
    cap = 1
else:
    reallocate()
    cap *= 2

但 Rust 唯一支持的分配器 API 级别太低,我们需要做很多额外的工作。我们还需要防范一些特殊情况,这些情况可能发生在真正大的分配或空分配中。

特别是,ptr::offset 会给我们带来很多麻烦,因为它具有 LLVM 的 GEP inbounds 指令的语义。如果你有幸没有接触过这条指令,这里简单介绍一下 GEP:别名分析,别名分析,还是别名分析。对于优化编译器来说,能够推断数据依赖性和别名是非常重要的。

举个简单的例子,考虑以下代码片段

*x *= 7;
*y *= 3;

如果编译器可以证明 xy 指向内存中的不同位置,那么理论上可以并行执行这两个操作(例如,将它们加载到不同的寄存器中并独立地处理它们)。然而,编译器通常无法做到这一点,因为如果 x 和 y 指向内存中的同一个位置,则需要对同一个值执行操作,并且不能在事后简单地合并它们。

当你使用 GEP inbounds 时,你是在明确地告诉 LLVM,你要进行的偏移量在一个“已分配”实体的范围内。最终的好处是,LLVM 可以假设,如果已知两个指针指向两个不相交的对象,那么这些指针的所有偏移量也已知不会发生别名(因为你不会最终到达内存中的某个随机位置)。LLVM 经过高度优化,可以处理 GEP 偏移量,而 inbounds 偏移量是最好的,因此尽可能多地使用它们非常重要。

这就是 GEP 的作用,它怎么会给我们带来麻烦呢?

第一个问题是我们使用无符号整数索引数组,但 GEP(以及 ptr::offset)接受有符号整数。这意味着数组中看似有效的一半索引将导致 GEP 溢出,并实际上朝错误的方向移动!因此,我们必须将所有分配限制为 isize::MAX 个元素。这实际上意味着我们只需要担心字节大小的对象,因为例如 > isize::MAXu16 将真正耗尽系统的所有内存。然而,为了避免将 < isize::MAX 个对象的数组重新解释为字节时出现微妙的极端情况,std 将所有分配限制为 isize::MAX 个字节。

在 Rust 当前支持的所有 64 位目标上,我们都被人为地限制在远小于地址空间所有 64 位的范围内(现代 x64 平台只公开 48 位寻址),因此我们可以依赖于首先耗尽内存。然而,在 32 位目标上,特别是那些使用地址空间扩展的目标(PAE x86 或 x32),理论上可以成功分配超过 isize::MAX 字节的内存。

然而,由于这是一个教程,我们在这里不会特别追求最优,而是无条件地进行检查,而不是使用聪明的平台特定的 cfg

我们需要担心的另一个极端情况是空分配。我们需要担心两种空分配:所有 T 的 cap = 0,以及零大小类型的 cap > 0

这些情况很棘手,因为它们归结为 LLVM 对“已分配”的定义。LLVM 对分配的概念比我们通常使用的概念要抽象得多。因为 LLVM 需要处理不同语言的语义和自定义分配器,所以它无法真正深入地理解分配。相反,分配背后的主要思想是“不与其他东西重叠”。也就是说,堆分配、栈分配和全局变量不会随机重叠。是的,这与别名分析有关。因此,只要 Rust 的做法一致,它在技术上就可以在分配的概念上玩一些花招。

回到空分配的情况,在一些地方,我们希望偏移量为 0,这是泛型代码的结果。那么问题是:这样做是否一致?对于零大小类型,我们得出的结论是,对任意数量的元素进行 GEP inbounds 偏移量确实是合理的。这是一个运行时无操作,因为每个元素都不占用空间,并且可以假装在 0x01 处分配了无限个零大小类型。任何分配器都不会分配该地址,因为它们不会分配 0x00,并且它们通常会分配到比字节更高的最小对齐位置。此外,通常情况下,内存的整个第一页都被保护起来,防止被分配(在许多平台上是整整 4k)。

然而,对于正大小类型呢?这就有点棘手了。原则上,你可以说偏移量为 0 不会向 LLVM 提供任何信息:地址之前或之后都有一个元素,但它无法知道是哪个。然而,我们选择保守地假设它可能会做坏事。因此,我们将明确地防范这种情况。

好了,说了这么多废话,让我们来实际分配一些内存吧

use std::alloc::{self, Layout};

impl<T> Vec<T> {
    fn grow(&mut self) {
        let (new_cap, new_layout) = if self.cap == 0 {
            (1, Layout::array::<T>(1).unwrap())
        } else {
            // This can't overflow since self.cap <= isize::MAX.
            let new_cap = 2 * self.cap;

            // `Layout::array` checks that the number of bytes is <= usize::MAX,
            // but this is redundant since old_layout.size() <= isize::MAX,
            // so the `unwrap` should never fail.
            let new_layout = Layout::array::<T>(new_cap).unwrap();
            (new_cap, new_layout)
        };

        // Ensure that the new allocation doesn't exceed `isize::MAX` bytes.
        assert!(new_layout.size() <= isize::MAX as usize, "Allocation too large");

        let new_ptr = if self.cap == 0 {
            unsafe { alloc::alloc(new_layout) }
        } else {
            let old_layout = Layout::array::<T>(self.cap).unwrap();
            let old_ptr = self.ptr.as_ptr() as *mut u8;
            unsafe { alloc::realloc(old_ptr, old_layout, new_layout.size()) }
        };

        // If allocation fails, `new_ptr` will be null, in which case we abort.
        self.ptr = match NonNull::new(new_ptr as *mut T) {
            Some(p) => p,
            None => alloc::handle_alloc_error(new_layout),
        };
        self.cap = new_cap;
    }
}
fn main() {}