在 Rust 中写入未初始化的缓冲区

Rust 中的非初始化缓冲区是一个由来已久的问题,例如

最近, John Nunley and Alex Saveau 想到了一种新方法,使用 Buffer Trait,现在已经出现在 rustix 1.0 中,我将在这篇文章中介绍。

更新:这个想法现在可以在一个独立发布的库中找到:buffer-trait元素周期表

Buffer trait 介绍

POSIX 的 read 函数会将文件描述符中的字节读入一个缓冲区,而且读取的字节数可以少于请求的字节数。使用 Buffer,rustix 中的 read 函数如下所示:

pub fn read<Fd: AsFd, Buf: Buffer<u8>>(fd: Fd, buf: Buf) -> Result<Buf::Output>

这使用了 Buffer Trait 来描述缓冲区参数。Buffer Trait 看起来像这样:

pub trait Buffer<T> {
    /// The type of the value returned by functions with `Buffer` arguments.
    type Output;

    /// Return a raw pointer and length to the underlying buffer.
    fn parts_mut(&mut self) -> (*mut T, usize);

    /// Assert that `len` elements were written to, and provide a return value.
    unsafe fn assume_init(self, len: usize) -> Self::Output;
}

(感谢 Yoshua Wuyts 对这个 Trait 的反馈和对整个想法的鼓励!)。

(Rustix自己的Buffer trait 是密封的,其函数也是私有的,但这只是Rustix暂时选择保留在不破坏兼容性的情况下发展该Trait的能力,代价是暂时不允许用户使用Buffer来定义自己的I/O函数)。

Buffer 是为 &mut [T] 实现的,因此用户可以向 read 传递一个要写入的 &mut [u8] 缓冲区,成功后它将返回一个 Result<usize>,其中 usize 表示实际读取的字节数。这与 rustix 中 read 的工作方式一致。使用这种方法如下

let mut buf = [0_u8; 16];
let num_read = read(fd, &mut buf)?;
use(&buf[..num_read]);

Buffer 也是为 &mut [MaybeUninit<T>] 实现的,因此用户可以向 read 传递一个 &mut [MaybeUninit<u8>] ,在这种情况下,他们将得到一个 Result<(&mut[u8], &mut [MaybeUninit<u8>])> 。成功后,会得到一对片段,它们是原始缓冲区的子片段,包含数据被读入的字节范围,以及剩余未初始化的字节。Rustix 以前有一个名为 read_uninit 的函数可以这样工作,在 rustix 1.0 中,它被这个新的支持Bufferread 函数所取代。使用方法如下

let mut buf = [MaybeUninit::<u8>::uninit(); 16];
let (init, uninit) = read(fd, &mut buf)?;
use(init);

这样就可以通过安全的 API 读取未初始化的缓冲区。

此外,Buffer 还支持读取 Vec 的剩余容量。spare_capacity 函数接收一个 &mut Vec<T>,并返回一个实现了 Buffer SpareCapacity newtype,它会自动设置向量的长度,以包括read后初始化元素的数量,从而封装了 Vec::set_len 的不安全性。使用这种方法看起来像

let mut buf = Vec::<u8>::with_capacity(1024);
let num_read = read(fd, spare_capacity(&mut buf))?;
use(&buf);

在 rustix 中,所有以前使用 &mut [u8] 缓冲区写入的函数现在都使用 impl Buffer<u8> 缓冲区,因此它们支持写入未初始化的缓冲区。

原理

read 是这样实现的

let len = unsafe { backend::io::syscalls::read(fd.as_fd(), buf.parts_mut())? };
unsafe { Ok(buf.assume_init(len)) }

首先,我们调用底层系统调用,它会返回读取的字节数。然后我们将其传递给 assume_init,它将计算要返回的 Buffer::Output。输出可能只是该数字,也可能是反映该数字的一对片段。

如果 T 不是 u8 怎么办?

Buffer 使用类型参数 T 而不是硬编码 u8,这样就可以被 epoll::waitkevent, and port::get 等函数用来返回事件记录而不是字节。使用方法如下

let mut event_list = Vec::<epoll::Event>::with_capacity(16);
loop {
    let _num = epoll::wait(&epoll, spare_capacity(&mut event_list), None)?;
    for event in event_list.drain(..) {
        handle(event);
    }
}

这将使用 drain 来耗尽 Vec,使其在每次等待前为空,因为 spare_capacity 会追加到 Vec 中,而不是覆盖任何元素。

循环内部没有动态分配;SpareCapacity 只使用现有的空余容量,并且只调用 set_len,而不是 resize

另外,由于 Buffer 也适用于分片,因此编写此代码时完全可以不使用 Vec

let mut event_list = [MaybeUninit::<epoll::Event>; 16];
loop {
    let (init, _uninit) = epoll::wait(&epoll, &mut event_list, None)?;
    for event in init {
        handle(event);
    }
}

错误信息

Buffer Trait 方法的一个缺点是有时会唤起 rustc 的错误信息,而这些错误信息并不明显。这种情况时有发生,所以我们现在在 rustix 的文档中有一节是关于它们的,还有一个示例显示了它们出现的地方。

安全使用缓冲区

Rust 的 std 目前包含一个基于 BorrowedBuf 的实验性 API,它有一个很好的特性,就是允许用户在不使用 unsafe 的情况下使用它,而且不会做任何效率极低的事情,比如初始化完整的缓冲区。为此,BorrowedBuf 使用了 “双游标 ”设计,以避免重新初始化已初始化的内存。

这里描述的Buffer  Trait 更为简单,避免了使用 “双游标”,不过它确实有一个unsafe 的必需方法。我们有办法修改它以支持安全使用吗?

BorrowedCursor 这样的Cursor  API 就可以做到。它支持安全地以增量方式写入未初始化的缓冲区。BorrowedCursor 的一个关键特性是,它从不要求急于初始化整个缓冲区。

有了这一点,Buffer  Trait 可能会看起来像这样:

pub trait Buffer<T> {
    // ... existing contents

    /// An alternative to `parts_mut` for use with `init`.
    ///
    /// Return a `Cursor`.
    fn cursor(&mut self) -> Cursor<T> {
        Cursor::new(self)
    }
}

impl<T, B: Buffer<T>> Cursor<T, B> {
    /// ... cursor API

    fn finish(self) -> B::Output {
        // SAFETY: `Cursor` ensures that exactly `pos` bytes have been written.
        unsafe { self.b.assume_init(selff.pos) }
    }
}

这样,用户就可以编写自己的函数,使用 Buffer 参数并使用cursor实现它们,而无需使用unsafe函数。

为什么要使用 parts_mut 和原始指针?

Buffer Trait 中的 parts_mut 函数看起来像这样:

fn parts_mut(&mut self) -> (*mut T, usize);

为什么要返回原始指针和长度,而不是 &mut [MaybeUninit<T>]?因为 &mut [MaybeUninit<T>] 会以一种微妙的方式变得不健全。我们为 &mut [T] 实现了缓冲区(Buffer),它不能包含任何未初始化的元素,而以 &mut [MaybeUninit<T>] 的形式公开缓冲区将允许未初始化的元素被写入其中。

有了原始指针,我们就可以让 assume_init 调用负责保证缓冲区已被正确写入。

展望未来

这个 Buffer Trait 的一个有限版本已经出现在 rustix 1.0 中,我们将拭目以待它在实践中的表现。

这个想法现在也可以在一个独立发布的库中找到:buffer-trait

如果效果不错,我认为值得考虑在 Rust 的 std 中使用这种 Buffer 设计,以替代 BorrowedBuf(目前还不稳定)。它更简单,因为它避免了 “双游标 ”模式,而且还有一个有趣的特点,即支持 Vec 空闲用例并封装了不安全的 Vec::set_len 调用。

 

阅读余下内容
 

《 “在 Rust 中写入未初始化的缓冲区” 》 有 111 条评论

  1. 关于这一点,有几件事情通常被误解或不被重视:

    – 未初始化字节不仅仅是一些垃圾随机值,它们还存在安全风险。心脏出血只是暴露了单元化缓冲区。未初始化的缓冲区可能包含秘密、密钥和指针,有助于破解 ASLR 和其他缓解措施。一如既往,Rust 设定的标准高于 “只要小心别出现这个 bug 就好”,因此安全的 Rust 子集要求让 uninit 无法读取。

    – Rust 语言已经可以高效地使用未初始化的缓冲区。这里的主要问题是,Rust 标准库中没有使用自定义非初始化缓冲区的 I/O API(只有内置的 Vec,以有限的方式)。以上只是对如何为自定义缓冲区设计 API,使其最有用、最符合人体工程学、最具互操作性的一些思考。这是一场争论,因为它可以通过多种方式实现,无论是否需要添加到语言中。

    • > 未初始化字节不仅仅是一些垃圾随机值,它们还存在安全隐患。

      只有在读取时才会这样。写入 “未初始化 ”内存[1]并将其读回是可以证明安全的[2],但目前在安全的 Rust 中并不可行。所链接的文章建议通过一些额外的复杂性来解决这个问题,我想这听起来是值得的。

      [1] 例如,将其作为 read() 系统调用的目标。

      [2] 因为它显然与 “初始化 ”同构

      • 显然,初始化内存不再是非初始化内存了。

        这里有一些有趣的边缘情况。通过 `&mut T` 写入内存会使内存对 T 初始化,但它的填充字节会被去初始化(这是因为写入可以是 memcpy,也可以从从未初始化填充字节的源复制填充字节)。

        • 请注意,如果你有一个“&mut T”,那么内存必须已经为 T 初始化,因此写入该指针不会初始化任何新内容(尽管如你所说,它可能会去初始化字节,但这只有在你使用转换或指针施法以某种方式访问这些填充字节时才会发生)。

  2. 我希望能有一个有用的 “冻结 ”固有特性,即使它只适用于基元类型,而不适用于通用用户类型。我相信 llvm 现在已经有了。

    我记得安全转换的工作也涉及到一种 “任意位模式 ”Trait?

    我也经历过在 Rust 中实现类似接口的痛苦,感觉就像是为了满足抽象机器的要求而跳过了一大堆圈(在某些情况下还影响了性能),对程序员和应用程序都没有好处。这确实是抽象机器本末倒置了。

    • >我也经历过在 Rust 中实现类似接口的痛苦,感觉就像为了满足抽象机器的要求而跳过了一大堆圈(在我的一些案例中,还损害了性能),对程序员或应用程序毫无益处。

      我已经在 $dayjob 实现了 TFA 所称的缓冲区 “双游标 ”设计,即底层(参考计数)[MaybeUninit<u8>],它有两个索引来跟踪已填充、初始化和未填充区域,另外还有将缓冲区分割为两个非重叠句柄的 API 等。为了让 miri 满意,我们当然需要以非同小可的方式处理 UnsafeCell,但它的性能并不比只处理 uint8_t* 的 C 代码差。

      • 明确跟踪初始化但未填充部分的原因是什么?既然你使用的是 u8,那么将初始化字节视为未初始化也没什么不好,那么知道初始化部分究竟有什么好处呢?我的意思是,在应用程序接口层面,你可以为初始化部分返回一个 &mut [u8],但实际上试图写入这个缓冲区的人,要么想从一个 &[u8] 复制进去,要么想写入一个 &mut [MaybeUninit<u8>]。

        • 这是为了给因某种原因无法使用 &mut [MaybeUninit<u8>] 的调用者提供一个初始化区域的 &mut [u8],例如,如果他们想将其与 std::io::Read impl 结合使用。特别是,Read impls 不鼓励但不禁止读取给定给它们的 [u8] 的内容,因此用转化为 [u8] 的 [MaybeUninit<u8>] 调用它们一般是不安全的。

    • 我同意,我想进一步说,我想知道为什么原始类型不是默认 “冻结 ”的。

      我完全理解不希望承诺的事情被清零,但我真的不明白为什么完全 UB,而不是 “它们具有内存/寄存器/编译器选择的任何初始值 ”会好得多。

      我想知道是否有人做过 UB 和冻结之间的性能比较?我找不到。

      • 这就假定编译器会为数值保留一个连续的位置,但这并不总是正确的(在寄存器的情况下几乎不正确)。如果编译器要求所有代码路径都产生相同的非初始化值,就会限制代码生成选项,从而降低性能(而性能正是使用非初始化值的根本原因!)。

        此外,未初始化的值可能位于内存页中,该内存页被回收后会再次映射进来,在这种情况下(因为还没有被写入),操作系统并不能保证它在第二次映射时具有相同的值。最近,在少数使用未初始化值的算法中发现了一个错误,就是因为这种影响。

        • > 相同的未初始化值,这可能会限制代码生成选项

          它几乎要求编译器在所有值首次 “出现 ”时对其进行初始化

          但如果涉及指针,这是不可能的,而且是完全危险的

          但对于小部分值是可行的,例如

          – 堆栈值(但可能会严重影响优化工作)

          – 某些分配,如 I/O 缓冲区(但 C alloc 不知道你在分配 I/O 缓冲区)

        • > 如果要求编译器使所有代码路径都产生相同的未初始化值,这可能会限制代码生成选项

          除了编译器根据某些路径的特性将其剪切为 UB 的情况外,您能否提供(比如在 x86_64 上)一个这方面的例子?换句话说,“未初始化值定义明确,但每次读取都可能不同 ”的情况比 “每次读取的值都相同 ”的情况更能优化性能。

          > 此外,一个未初始化的值可能位于一个内存页中,该内存页被回收后再次映射进来,在这种情况下(因为它还没有被写入),操作系统并不能保证它在第二次读取时具有相同的值。由于这种影响,最近在少数使用未初始化值的算法中发现了一个错误。

          在我看来,这听起来并不正确,至少对 Linux 而言是如此(假设我们没有用 madvise 或其他工具直接请求这种行为)。你有更多信息吗?

        • 但是,如果我们只需要在第一次读取内存时选择一个值,我不知道这会降低多少性能?

          我想,我们读取未标明字母的内存并指望读取时不保存数值的情况并不多。在读取 8 字节块进行对齐时会出现这种情况,但在其他地方会出现这种情况吗?

          • 如果你选择了一个值,你就必须存储它,如果你必须存储它,当寄存器分配失败时,它可能会溢出到内存中。从仅使用寄存器到使用堆栈/堆,很容易将程序的运行速度降低一两个数量级。如果这是在热路径中,我认为就是在热路径中,因为使用未初始化的值似乎毫无意义,否则可能会产生很大的影响。

            要真正知道这一点,唯一的办法就是进行测试。编译器及其优化取决于很多因素。由于指令缓存的存在,甚至指令的顺序和布局也会产生影响。你可以在以后再去做保证,但撤销是不可能的。

      • 在我看来,未初始化内存是 UB 并不是一个疯狂的默认设置(尽管它使屏蔽 simd 变得困难),大多数 UB 也是如此。但缺少逃生舱门可能会令人沮丧

          • 只有当你深入了解了硬件的实际工作原理(以及一定程度上的操作系统)之后

            并意识到有时 UB 甚至存在于硬件寄存器中

            同样的逻辑内存地址在硬件中可能同时有 5 个不同的值,而你并没有发现错误

            以及其他类似的乐趣

            因此,疯狂的是现实而不是编译器

            (在我看来,在 C 语言,尤其是 C++ 中,疯狂之处在于你很容易在不使用任何花哨技巧的情况下意外运行到 UB 中,而这只是日常代码中并不热门的愚蠢代码。)

            • 硬件寄存器或物理 DRAM 中没有 UB,如果你这么说,我想你并不熟悉硬件是如何工作的。(或者说,你并不了解 ISO C 文档中的 “UB ”有多疯狂)。

              编辑:如果高速缓存子系统或内存控制器配置错误,就会出现 “明显 ”违反内存一致性的情况,但这需要:(1)您在内核模式下运行,而不是在用户空间运行;(2)您遇到了错误,因此 GP 的说法不成立,即无错误的代码可能会遇到这种情况。

              • > 硬件寄存器或物理 DRAM 中没有 UB

                这似乎对特定定义非常敏感,而其他人可能并不认同。DRAM 的规格表定义了其在特定条件下的行为(如果向某个地址写入,将来会从同一地址读回相同的值)。如果违反这些条件,其行为就会……未定义。如果在错误的刷新时间、温度、电压或电离辐射水平下运行 DRAM,可能会出现奇怪的行为。甚至是非本地行为,即从一个单元读取的值取决于其他单元(RowHammer)。这怎么不是 UB?

                • 如果 C 程序访问未初始化内存 (UB),编译器完全可以根据语言规范重新格式化你的硬盘,并用加密挖矿替换你的操作系统代码。

                  我并没有从法律角度夸大其词,在实践中也只是稍微夸大了一点。在实际操作中,UB 可以在真实硬件上做一些非常奇怪、不直观的事情:

                  https://mohitmv.github.io/blog/Shocking-Undefined-Behaviour-

                  问题的关键在于,这种极端的 UB 不应该发生。这是编译器实现者的选择,他们并没有解决这个问题,而是在规范中允许 UB 逃生。更明智的做法是,编译器应该指出,例如,访问未初始化的内存会产生一个非指定值,如果从不同线程访问,甚至可能会产生多个不同的非指定值。这就捕捉到了我们期望发生的情况,但(根据 C 语言规范律师的说法)这也是已定义的行为。

                  在实践中,这意味着符合要求的编译器将确保在访问未初始化内存的任何情况下,都不会在某些架构上导致奇怪的边缘情况页故障,或其他事实上可能导致古怪 UB 的情况。

                  这并不是一个不合理的要求。

                  • > 这不是一个不合理的要求。

                    但这不正是与 DRAM 中的 Rowhammer 情况类似吗?当在规格边缘运行时,DRAM 的行为会变得不确定。(当然,Rowhammer 面临的一个挑战是,这究竟发生在规格的哪个边缘)。在这种情况下,写入一个物理地址会改变其他物理地址的内容。这在真实硬件上……是 “非常奇怪、不直观的东西”。当然,我们可以(也确实)要求 DRAM 供应商不要利用这种未定义的行为;但他们这样做是为了优化,使 DRAM 单元的尺寸更小、间距更近,从而以相同的价格获得更高密度的 DRAM 骰子。就像使用完全定义语义的语言可能会以牺牲性能为代价一样,购买规格更宽泛、行为更明确的 DRAM 也可能会以牺牲性能为代价……达到这些规格的边缘。

                    硬件和软件中的极端 UB 都是优先级的选择。你可能倾向于放弃性能,以获得更易于理解的系统(我也是!我的大部分工作都是在带片上 ECC SRAM 的无序锁步处理器上进行的,以最大限度地提高故障模式的可理解性),但整个市场显然不这么认为,无论是硬件还是软件。

                    • 硬件中的合法错误可能不属于编译器的工作范围。是的,这是一个不合理的要求。但我认为,编译器的工作应该是确保在严格遵守架构规范的参考机上正确执行的程序不会出现 UB。

                      我并不是说计算机体系结构应该没有 UB。如果能做到这一点,那就太棒了,但在实践中可能还差得太远。但编译器应该使用不会导致 UB 的构造,将高级指令映射到特定架构的低级实现中。这个要求并不过分。

                      编译器无法合理地保护你免受锤子攻击。但编译器应该保证,除非硬件出错,否则访问未初始化的内存除了返回未指定的内容或导致内存访问异常等合理情况外,不会产生其他影响。即使某些运行时值是不可预测的,也应预先确定行为是什么。

                      举个更具体的例子,如今大多数语言都明确定义了带符号整数溢出的情况:在任何二进制机器中都会发生的事情 (char)127 + (char)1 == -128。C 语言将其视为未定义的行为,正如我在上面的链接中提到的,这会导致本应是有限循环(无论是否有溢出)的程序编译成无限循环。编译器的这一 “优化 ”步骤本不应该发生。C 语言不愿意改变这一点,因为 C 语言编译器适用于非二元互补架构,而非二元互补架构下的行为会有所不同。在我看来,正确的做法应该是要求那些奇怪的深奥体系结构编译额外的溢出检查(可能使用明确违反标准的可选编译器标志禁用),而不是让所有 C 语言开发人员都承受带符号算术溢出 UB 带来的混乱。

                      这是期望值的问题。除了最初级的程序员,其他程序员都认为带符号的整数溢出是不可移植的。这没有问题。但他们希望得到一个合理的结果(例如,在二进制机器上绕一圈),即使这个结果是不可移植的。他们不希望编译器悄悄地、偷偷地将他们的程序逻辑改成根本不同的东西,因为 UB 意味着编译器可以为所欲为。

                    • > 硬件中的合法错误可能不属于编译器的处理范围。

                      但这正是问题所在。RowHammer 的 “bug ”在于,在可接受的低刷新率下,它发生在包络线 “允许 ”的一侧。而 RowHammer 和其他上百种可观察到的效果的 “UB ”在于,在包络线 “不允许 ”的一侧,行为是未定义的。系统设计者可以选择它们在包络线两侧的概率,权衡利弊是非常重要的优化机会。

                      用 C 语言编写软件,可能会出现未定义的行为,这正是作为软件工程师选择站在规范包络线最边上的原因。作为交换,你可以获得多种强大的优化功能,有些是编译器层面的,有些是职业层面的(如果你认为不需要学习如何正确理解你的语言至少也是一种优化的话)。

                • 我已经修改了我的说法,使其更加清晰,但在父辈的说法中,我们谈论的是在无错误的物理处理器上的无错误代码,而且我认为我们隐含地谈论的是用户模式代码,在这种模式下,人们无论如何都无法更改任何 DRAM 时序配置寄存器。

              • > 硬件寄存器中没有 UB

                绝对有。

                在 ARM 文档中,这被称为 “不可预测”。结果没有定义。它可能工作。也可能不行。可能会在寄存器中放入垃圾数据。

                • 我已经编辑/澄清了上面的说法。父母确实说过 “如果你没有出现错误”,而进入不可预测的状态就是有错误的代码。另外,“也许会在寄存器中放入垃圾数据 ”并不是 UB,在 UB 中这是更合理的事情(不过,ARMv8 之前的 UNPREDICTABLE 定义似乎允许 UB)。我认为用户空间/非特权代码无法访问这种 UNPREDICTABLE 状态(否则就会违反芯片的安全属性)–但如果我错了,我很想知道一个例子。

            • 如果遵循一些基本规则,我不觉得在 C 语言中意外遇到 UB 会那么容易。例外情况包括空指针取消引用、数组越界访问和符号溢出,所有这些都可以变成运行时陷阱。这些规则包括不进行指针运算、不进行类型转换以及采用某种所有权策略。这些规则都不难实现,在出现例外情况时,应谨慎对待,就像在 Rust 中使用 “不安全 ”一样。

          • 不,这对架构间的可移植性有一定意义。至少在 C 语言被发明出来的时候是这样的,而且当时还有一些狂野的架构。

            而且它也确实允许进行一些优化。但在现代的无序机器上,这可能没什么意义。

            • > 有一些疯狂的架构。

              现在的东西还是很厉害

              只是稍微少了点

              > 在现代失序机器上可能没什么意义。

              完全没有 UB 会扼杀大量与今天仍然相关的优化(而且不再与硬件相匹配,因为有些 UB 是硬件级别的)。

              失序机器并不能神奇地解决这个问题,它只是让一些优化程度较低的代码运行得更好,但不是全部

              很多低能耗/廉价硬件确实没有或只有非常有限的失序功能,因此它仍然非常重要,而且很可能在很长一段时间内都非常重要。

              • 很多东西!例如,生成的指针可能并不指向当前有效的对象。它甚至可能不是指向一个在语言对象模型中具有任何意义的对象。例如,它可能指向堆栈上的返回地址。或者是常量池中的一个常量。或者是计算中间的一个保存寄存器,而这个寄存器在原始程序中并不对应任何变量。

                总之,一旦启用了整数到指针的转换(假设目标程序具有扁平的地址空间),就会产生指针出处问题,而解决这个问题的唯一办法就是将某些内容改为 UB。

                • 我认为从 C/C++ 标准中定义的术语的严格意义上讲,这些都不是未定义的行为。指针投递是已定义的行为。我认为你指出的这些行为要么是实现定义的,要么是未指定的,这与 UB 是不同的。

                  这看似吹毛求疵,但依赖于实现定义或未指定行为的弊端在很大程度上是封闭和包含的。例如,你可能会遇到内存访问错误。原则上,UB 的缺点是完全不受限制的。正因为如此,它经常与优化传递产生不良影响,导致非常奇怪的错误。

                  • jcranmer 是正确的,指针出处相关问题并不是 “盒装和封闭的”。从这里开始:https://www.ralfj.de/blog/2020/12/14/provenance.html

                  • 指针出处是 C/C++ 意义上的 UB,尽管它在很大程度上是潜伏在 “整数/指针转换是实现定义的 ”这一实现定义行为中的。你能找到的最接近指针出处完整规范的是 TS 6010 (https://www.open-std.org/jtc1/sc22/WG14/www/docs/n3226.pdf),但需要注意的是,实际上没有编译器严格按照 TS 6010 的思路来实现出处。相反,这些编译器执行的是一种文档不全、漏洞百出、内部不连贯的出处定义…

                    > 这似乎有些吹毛求疵,但依赖于已定义或未指定的实现行为的弊端在很大程度上已被框住并包含在内。

                    ……之所以不连贯,正是因为出处与优化之间的交互并不是封闭和包含的。

                    直到人们开始将模型纳入正式语义,他们才真正意识到 “嘿,等等,这意味着要么我们的大部分优化是错的,要么我们的语义是错的”,而人们一致认为是语义出了问题。

      • > 为什么默认情况下不 “冻结 ”原始类型?

        它扼杀了_大量_优化,导致有问题的性能下降

        简而言之:总是冻结 I/O 缓冲区 => 是的,没有问题(一般情况下);冻结所有基元 => 性能问题

        (至少在实践中,理论上仍有许多可能,但分析计算成本会更高(如指数级增长),而且可能需要更多高级信息(所以 C 语言会倒霉))。

        对于足够原始类型的 I/O 缓冲区来说,“冻结 ”基本上总是没有问题的(我还依稀记得一些讨论,一些更多参与 Rust 核心开发的人可能想添加一些这样的功能,所以它可能仍然会发生)。

        为了说明为什么冻结的 I/O 缓冲区就是好的:有些系统已经总是(0 或 rand)初始化所有 I/O 缓冲区。很多系统会重复使用 I/O 缓冲区,在启动时初始化一次,然后继续重复使用。有些操作系统设置会(0 或 rand)初始化操作系统的所有内存分配(但这是为了让操作系统向进程中的内存分配器分配更多内存,而不是为每一次特定的分配调用,而且根本不会移除栈或寄存器值的 UB(也不会移除与堆值相关的各种站))。

        因此,对于 I/O 缓冲区来说,做更 “昂贵 ”的事情而不是冻结它们是很正常的。

        如前所述,有时冻结的东西在硬件层面上是未定义的(例如每次读取都可能返回不同的值)。对于 I/O 缓冲区来说,这可能是一个小众问题,你可能不会遇到,我也不确定它在现代硬件上有多常见,但它仍然是一个问题。

        但是,冻结对控制流有重大影响的基元,既会使某些优化变得不可能,也会使其他优化更难计算/检查/查找,甚至可能导致优化不再可行。

        这可能会涉及(如冻结会阻止)某些形式的死代码消除、某些形式的内联+解卷+const 传播等。这主要是(但不完全是)针对微优化,但微优化累加起来会导致(可能但不总是)重大的性能下降。Frozen 还与浮点数及其不同的 NaN 值有一些微妙的交互(特别是在信号 NaN 方面可能会有问题)。

        我在想,如果换一种 C/C++,将基元数组始终视为冻结数组(并且不发出 NaNs 信号),是否就能很好地工作,而不会出现任何明显的性能缺陷。如果是这样,Rust 是否应该采用这种方法…

    • 这不仅仅是抽象机器的问题。这也是为了防止使用未初始化的内存,因为这是个安全漏洞。

      像 ReadBuf 这样的抽象可以让安全代码高效地处理未初始化的缓冲区,而不必冒暴露随机内存内容的风险。

    • 已经讨论过 Rust:https://github.com/rust-lang/rfcs/pull/3605。简要说明:添加 “冻结 ”并不像看起来那么简单。

      • 添加 “冻结 ”看起来很简单。也就是说,基于值的 “冻结”、基于引用的 “冻结 ”虽然看似合理,却因为 MADV_FREE 而被破坏。

        有些人就是不喜欢它。

        目前健全的 Rust 代码完全不依赖于未初始化内存的值。添加 “freeze ”意味着它可以。在没有`freeze`的情况下,健全的 Rust 代码是不可能出现类似 heartbleed 的漏洞来暴露自由内存中的秘密的,但在有`freeze`的情况下,理论上是可能的。

        你是否认为这是一个现实的问题,很可能决定了你对`freeze`的态度。我个人认为这不是什么大问题,而且有几种算法因为没有`freeze`而从根本上减慢了速度,所以我很希望我们能加入它。

        • > 有些人就是不习惯。

          有些人–尤其是最接近操作语义工作原理的人–不适应就说明它实际上比看上去要难。

          冻结 “的问题与 ”整数到指针 “语义的问题如出一辙:这种变化会对与运算本身关系不大的事物产生影响,从而产生一种难以驾驭的 ”远距离行动”(spooky-action-at-a-distance)效果。

          更深层次的问题在于,虽然某种 “垃圾值 ”语义在高级语言中显然有其用武之地(支持未初始化的 I/O 缓冲区、矢量化的过度读取、结构中的填充字节等),但并不清楚垃圾值的各种微妙变化中哪一种最适合所有用例。

        • > 目前健全的 Rust 代码完全不依赖于未初始化内存的值。添加 `freeze` 意味着它可以。

          可以说,“asm!() freeze ”的存在已经打破了这种想法。当然,从名义上讲,asm!() 代码从非初始化字节读取的数据的稳定性得不到任何保证,但你还是可以这么做。

          如果说 “asm!() 代码把未初始化字节当成数字来使用就一定是不可靠的!”这种说法也不切实际,因为它的很多功能都很有用,比如用序列化的结构体与内核通信,还可以打开类似 mmap() 这样的接口,将可能未初始化的虚拟内存字节转换为确定的内核字节。

          更不用说 /proc/self/mem 和内核提供的类似调试工具了,它们能以序列化数据的形式窥探内存。

        • 现实一点说,在很多实际情况下,I/O 缓冲区都是重复使用的,因此至少对于 I/O 缓冲区用例来说,在分配时将其清零或初始化一次,然后在所有重复使用中将其视为冻结状态是非常可行的(从性能角度看,在大多数用例中都是如此)(不过这也会带来一个问题,那就是现在的 bug 可能会泄露 I/O 缓冲区以前的内容)。

          但我想这不仅仅是 I/O 缓冲区的问题;)

  3. 对于新的 Windows 开源 “编辑 ”程序[1]的创建者来说,写入未启动的缓冲区是 Rust 的痛点之一。不知道他对这篇文章有何看法。

    > 另一个问题是在 Rust 中使用未初始化数据的困难。我知道这涉及到 clang 中的一个属性,它可以据此执行相当激烈的优化,但这让我作为程序员的生活有时变得有点困难。说到 `MaybeUninit`,或者之前的 `mem::uninit()`,我感觉编译器工程的复杂性正在渗入编程语言本身,如果可能的话,我希望能避免这种情况。说到底,我最想做的就是在 Rust 中声明一个数组,不赋值,`read()`进去,然后神奇地从数组中读取数据就安全了。这在 C 语言中大致如此,而且我知道如果你做错了,它也是 UB 的,但有一点不同:我从未把它当作一个问题。而在 Rust 中却是这样。[https://news.ycombinator.com/item?id=44036021]

    • 未初始化缓冲区的基本问题在于,它们实际上需要只写引用才能存在,而 Rust 的类型系统没有(也不容易支持)只写引用,只有只读和读写引用。MaybeUninit 是这个问题的部分解决方案,但由于它是一个库解决方案,而不是语言解决方案,因此与语言的集成度不高,例如,从 MaybeUninit 结构中获取 MaybeUninit 字段就很困难。

      最令人头疼的是,未初始化内存最常见的用例(本文和你引用的讨论中都提到了这种情况)其实很容易获得合理、安全的抽象,因此当前的方案既需要使用不安全的代码,还可能重复错误的值计算,这并不是一种有趣的体验。(另外,I/O Traits 早于 MaybeUninit,这意味着想要处理未初始化内存最常见的地方就是无法正确处理的地方)。

      • 那么是否可以将只写作为 muts 的默认设置,这样它们至少在开始时是只写的,但也可以(a)在创建时的特定情况下,或(b)在对其进行某些操作后,或(c)在通过某些 API 创建时,使其成为读写器?

    • > 在 C 语言中大致是这样的,我知道如果你做错了,也是 UB 存在的问题,但有一点是不同的:我从来没有把它当作一个问题来考虑。而在 Rust 中却是这样。

      在编写 C 语言时,UB 并没有占据作者的大脑,而在 Rust 中,它确实应该占据大脑。这种对内存安全的懒惰态度正是许多 C 代码充满内存错误和安全漏洞的原因。

      • 但在这种情况下有一个重要的区别。在 C 语言中,只要在初始化之前不读取未初始化内存的指针,就不会有问题。你可以像往常一样写入这些指针。在 Rust 中,一旦你 “产生 ”了一个无效值(包括对未初始化内存的引用),它就是 UB。在 Rust 中,所有东西都使用引用,但在处理未初始化内存时,你必须严格避免使用引用,而是通过原始指针进行写入。这意味着你不能重用任何通过 &mut 写入的代码。此外,随着时间的推移,规则也会发生变化。有一次,我的不安全代码中包含了一个未初始化元素的 Vec,这并没有问题,因为我在写入元素(通过原始指针)之前从未产生过对任何元素的引用。但后来他们修改了 Vec 文档,说这是 UB,我猜是因为他们想保留使用引用的权利,即使你从未调用过返回引用的方法。

        • MaybeUninit 稳定后,这个问题就不那么严重了。现在,你可以坚持使用 &MaybeUninit<T> / &mut MaybeUninit<T>,而不必纠结于 *T / *mut T,并且只在已知初始化时才仔细跟踪将其转换为 &T / &mut T,你也不会因为类型不同而意外地在本应使用 T 的地方使用 MaybeUninit<T>。

          但这并不那么容易,因为许多 MaybeUninit<T> -> T 转换 fns 都不稳定。例如,TFA 中的代码需要 `&mut [MaybeUninit<T>] -> &mut [T]`,但`[T]::assume_init_mut()`是不稳定的。但重新实现它们只需复制 libstd 内隐,而这反过来通常只是一个简单的重新解释–单线程。

        • 我不明白这有什么区别。在 C 语言和 Rust 中,你都可以拥有指向未初始化内存的指针。在这两种语言中,除非在非常特殊的情况下,否则不能使用指针(这两种语言的指针用法完全相同)。

          在这方面有两个实际区别: C 指针比 Rust 指针更符合人体工程学。Rust 还有一个额外的特性叫做引用,它可以让编译器进行更积极的优化,但也有一个限制,那就是不能对未初始化的内存进行引用。

          • 我同意你的观点。我的观点是,附加功能(引用)为 C 语言中不存在的 UB 创造了新的可能性,这也是上文中被批评的 “在我看来并不存在问题 ”的理由。你不能将 C 语言与 Rust-without-references(无引用)进行比较,因为没有人这样编写 Rust。它不像 C++ 的 “无例外”(without-exceptions),后者是人们使用的一个合法子集。

        • 奇怪。我想我写破 Rust 代码已经有好几年了。如果我没理解错的话,类似于

           let mut data = Vec::with_capacity(sz);
              不安全 { data.set_len(sz) };
              buf.copy_too_slice(data.as_mut_slice());
          

          是 UB 吗?

          • 为一个未初始化的值创建引用是即时 UB,还是只有在引用被误用时才是 UB(例如,如果 copy_too_slice 读取了一个未初始化的字节),这是一个悬而未决的问题。具体的讨论是语言是否要求 “引用的递归有效性”,这将意味着构造一个指向无效值的引用是 “语言 UB”(你的程序没有被很好地指定,编译器允许 “误编译 ”它),而不是 “库 UB”(你的程序被很好地指定,但你调用的函数可能没有预期到一个未初始化的缓冲区,从而触发语言 UB)。请参见此处的讨论:https://github.com/rust-lang/unsafe-code-guidelines/issues/3…

            目前,团队倾向于不要求引用的递归有效性。这意味着,只要您能假定 `set_len` 和 `copy_too_slice` 从未从 “data`”中读取,您的代码就不会触发语言未定义错误。然而,它仍然被认为是库 UB,因为这种假设并没有在任何地方被记录或指定,也没有被保证–在你的程序或标准库中对安全代码的修改可以将其转变为语言 UB,所以通过做这样的事情,你正在编写脆弱的代码,在设计上放弃了很多 Rust 的安全性。

          • 没错。copy_too_slice 只适用于写入初始化的切片。在你的示例中,只有使用原始指针或调用新添加的 Vec::spare_capacity_mut 函数才能正确处理 vec 上的未初始化内存,该函数会返回一个 MaybeUninit 的片段。

          • 为什么不干脆

             let mut data = Vec::with_capacity(sz);
                data.extend(&buf[..sz]);
            

            Vec::extend 从可迭代数扩展容器。Vec/slice 是可迭代的。

            从文档中可以看到

            > 该实现专门用于片迭代器,它使用 copy_from_slice 一次追加整个片。

            当然,这个微不足道的例子也可以写成

             let mut data = buf.clone();
          • 是的,我也遇到过这种情况。你必须在读取之前将内存清零,并且/或者有一些追踪未初始化容量或初始化 len 的疯狂组合,我认为 Rust stdlib 的 &mut Vec 写 Trait 就因为这个问题被阉割了。

            严格来说,它比显而易见的做法更复杂、更慢,其存在只是为了满足抽象机器的需要。

            • 编写该代码的正确方法是使用 .spare_capacity_mut() 获得一个 &mut [MaybeUninit<T>],然后使用 .write_copy_of_slice() 将你的 Ts 写入其中,再使用 .set_len() 。这样做不会比原来不正确的代码慢(尽管显然更复杂)。

              • 哦,这非常好,我想自从我写了这段代码后,它就稳定下来了。

              • write_copy_of_slice 看起来并不稳定。我会用 godbolt 来解决这个问题,但我希望无论使用什么咒语,都能编译成 memcpy。

                • 正如我在 https://news.ycombinator.com/item?id=44048391 中写到的,在使用 MaybeUninit 时,你必须习惯于复制 libstd impl。对于我的代码,我在这些副本上加了 “TODO(rustup) ”注释,以提醒自己每次更新 toolchain.toml 中的 Rust 版本时都要重新查看它们

                  • 换句话说,“”“安全”“”稳定的代码是这样的:

                        let mut data = Vec::with_capacity(sz);
                        let mut dst_uninit = data.spare_capacity_mut();
                        let uninit_src: &[MaybeUninit<T>] = unsafe { transmute(buf) };
                        dst_uninit.copy_from_slice(uninit_src);
                        unsafe { data.set_len(sz) };
            • Valgrind 不会告诉你 UB 的情况,只会告诉你代码是否对内存做了不正确的处理,而这取决于优化器做了什么,如果你确实写了 UB 代码的话。你需要 Miri 来告诉你这类代码是否触发了 UB,它的工作原理是评估和分析编译器的中层输出,检查是否遵循了有关安全性的 Rust 规则。

        • 在 C 语言中,对于没有非值表示的类型,通过指针读取未初始化的值也不是 UB。

      • 我猜想,作者之所以没有真正考虑这个问题的主要原因是,尽管有可能误用 read(),但真正安全地使用它其实并不难。

        听起来,这里更困难的问题是如何向编译器解释 read() 并非被不安全地使用。

      • 对于 C 程序员来说,这个特殊的 UB 不需要思维空间的原因是,对缓冲区中超出写入长度的部分做任何事情都没有意义。

        其他大多数 UB 与你认为可以使用的数据有关。

      • 我的意思是,如果我用 C 语言为我的编辑器编写一个 UTF8 –> UTF16 转换函数,我可以编写

         size_t convert(state_t* state, const void* inp, void* out)
        

        现在,这个函数在实践中既能处理初始化数据,也能处理未初始化数据。此外,无论输出缓冲区是 `u8`(写入`文件`的字节缓冲区)还是 `u16`(使用 UTF16 的缓冲区),它都是透明的。我从来没有考虑过这是否行不通(在这种特定的上下文中;让我们忽略本例中写入 `out` 时的对齐问题),而且我也不记得在很长一段时间里编写此类代码时遇到过任何问题。

        如果我用 Rust 写等价代码,我可能会写道

         fn convert(&mut self, inp: &[u8], out: &mut [MaybeUninit<u8>]) -> usize
        

        现在问题对我来说已经很明显了,但至少我的意图很明确:”过来!把你的未初始化数组给我!我不在乎!”。但这并不是问题的终结,因为编写这样的代码在理论上是不安全的。如果你的 `out` 有一个 `[u8]` slice,你就必须把它转换成 `[MaybeUninit<u8>]`,但这样一来,函数理论上就可以写入未初始化的数据,这不就是 UB 吗?所以现在我不得不考虑这个问题,改写成这样:

         fn convert(&mut self, inp: &[u8], out: &mut [u8]) -> usize
        

        ……而且这也是不安全的,因为现在我必须将实际的 `[MaybeUninit<u8>]` 缓冲区(用于文件写入)转换为 `[u8]` 才能调用此 API。

        长话短说,这是我在 Rust 中编写代码时经常遇到的问题,但在 C 语言中却没有。这并不意味着 C 语言中的许多不安全因素不会让我担心,而只是意味着在我编写的 C 语言代码中,上述_particular__问题类型不会成为一个问题。

        编辑:另外,正如 usefulcat 所说。

        • 为什么不接受一个 &mut [MaybeUninit<T>] 并返回一个 &mut [u8],隐藏转换底层引用的不安全位?

          比如

           fn convert<'i, 'o>(inp: &'i [u8], buf: &'o mut MaybeUninit<u8>) -> &'o mut [u8].
          

          (实际上是个诚实的问题……因为上面的内容可能写不出来,而且我在手机上,无法尝试)。

          编辑:可以用: https://play.rust-lang.org/?version=stable&mode=debug&editio

          • 对于我的具体例子来说,这是一个合理的变通办法。但我相信,在另一个例子中,这种解决方案也是不可行的。换句话说,我只是想表达我认为 Rust 目前存在缺陷的总体想法。

            编辑:另外,我相信你的代码将无法通过我的第二部分,因为 `convert` 函数很难接受`[u8]`片段。将 `[u8]` 转换为 `[MaybeUninit<u8>]` 本身并不安全。

            • 是的,你需要做一些事情,比如接受一个 &mut [u8] 或 &mut [MaybeUninit<u8>] 的枚举,并创建几个 impl From<>,这样调用者就可以 .into() 他们想传递的任何内容…

              但我认为这并不是一个真正的缺陷,而是强类型的一个简单结果。如果你想把 “无论什么 ”作为参数,你就必须详细说明满足它的类型,无论是通过 Trait 还是具有特定变体的枚举等。你不能把东西直接投到 void 中,然后寄希望于最好的结果,还说结果是安全的。

    • 我认为这解决了他的问题。他说他想要一个能将不安全缓冲区转化为安全缓冲区的读取函数,而这个 API 就能做到这一点。

      我记得说服编译器从 MaybeUninit 给你一个安全缓冲区并不难。不过,这种类型的文档非常冗长,会让你对使用它所做的一切产生怀疑。思考这一切是痛苦的,但在 C 语言中又不是不需要思考。

    • 抽象出 `assume_init` 是个好主意!我想我可以在编辑器中使用类似的功能。我唯一担心的是 `read` 函数是根据参数类型模板化的。如果我不需要同一个函数的两个副本来切换 `[u8]`和 `[MaybeUninit<u8>]`,因为返回类型不同的话,我会非常喜欢。[^1] 我猜这种方法可以调整以避免这种情况?

      就我个人而言,与 `BorrowedBuf` Trait 相比,我也喜欢这种总体上更简单的方法,原因与文章中所述的相同。

      虽然这可能解决了我的部分痛点,但我想写的是,在理想的世界里,我可以在编写 Rust 的同时,即使根本不用考虑这个问题,也可以不用考虑太多。即使采用这种方法,我仍然需要决定我的 API 是否需要使用`[u8]`或`Buffer`,以防万一调用者可能想在调用链中传递一个未初始化的数组。这就需要为缓冲区参数创建通用的调用路径,最终可能会重复调用路径上的任何函数,尽管将其标记为 `Buffer`并不是我的本意。

      我想,如果有办法修改 Rust,让我们可以大胆地在书面上声明 “您可以将`[MaybeUninit<T>]`铸入`[T]`并将其传递到调用中_如果_您绝对确定没有任何东西从切片中读取”,那就已经很不错了。它可能还不会让我更舒服,但绝对会消除我在编写这种不安全的投递时的大部分顾虑。我说的 “占据我的思想 ”基本上就是这个意思: 这并不是说我完全不会考虑这个问题,而是说对于我确定满足了这个要求的代码(即类似于我在编写等价的 C 代码时知道的情况)来说,它不再是我更关心的问题了。

      编辑:我认为 jcranmer 提出的只写引用建议可以解决这个问题,https://news.ycombinator.com/item?id=44048450。

      [^1]: 对于简单的 “读取 ”系统调用来说,这当然不是问题,但对于更复杂的函数(例如我在本主题其他地方建议的 UTF8 <> UTF16 转换器 API)来说,这可能是个问题,特别是如果它像 simdutf 那样被加速的话。

  4. > 无需做任何低效的事情,例如初始化整个缓冲区

    有这么低效吗?如果你的代码对 IO 吞吐量非常敏感,那么重复使用缓冲区并在启动时进行一次初始化似乎更可取。

    几年前,我需要一个这样的缓冲区,但并不存在,于是我写了一个:https://crates.io/crates/fixed-buffer 。我喜欢它是一个没有类型参数的普通结构体。

    • > 这样效率会很低吗?

      有可能。例如,如果你有一个大型缓冲区(为提高吞吐量而调整),但最终却因为某种原因满足了大量的小型请求。偶尔也会有人在文章中重新发现,用 calloc 代替 malloc + memset 可以节省大量性能,这要归功于操作系统只在第一次出现页面错误(如果出现过的话)时进行清零,而不是在整个缓冲区进行 O(N) 运算。

      如果在错误的循环中,这种操作会从 O(N) 迅速膨胀到 O(吓人)。

      https://github.com/PSeitz/lz4_flex/issues/147

      https://github.com/rust-lang/rust/issues/117545

      如果我没看错对数日志图,那么在 1GB 数据集上的运行速度要比 100 倍慢得多。当然,避免 init 并不是唯一的解决方案,但这也是一种解决方案。

      > 那么重复使用缓冲区似乎更可取

      缓冲区重用可能是一种选择,但在缓冲区所有权复杂的代码中(例如,在线程间传输,而初始线程不一定会一直存在等),最明智的重用方法之一可能是将所述缓冲区返回给分配器,甚至是操作系统。

      > 并在启动时进行一次初始化。

      对于长期存在的进程来说,这可能是个不错的选择,但对于通过 xargs 产生的进程来说,这可能是个糟糕的选择。

  5. 如果你想提高性能,那么在 Rust 程序的一小部分中放弃使用 C 语言也是合理的,不是吗?

    • 的确如此,不过即使是一小段程序,你最终也需要大量的模板–对 Crate 的依赖、调用 Crate 的 build.rs、告诉 Rust 调用者关于 C 函数的 extern 声明、如果你的一小段程序不是那么小的话,可能还需要一些 bindgen……而且无论如何,你最终还是需要在 & / * / MaybeUninit 之间做一些thunking。因此,如果有一种 “纯 Rust ”的方法可以用`unsafe`来做,那么写这种方法往往更容易。纯 Rust impl 还有一个好处,就是你可以用 Miri 来验证它,这与 C impl 的情况不同,因为 Miri 无法模拟任意的 C 代码。

    • 是的,但 C 语言工具链非常麻烦,而且交叉编译和编译到 WASM 等工作也更加困难。如果你能保持程序纯粹的 Rust 风格,那真的很不错。

      Go 也有类似的特点。

      • Go 的情况要糟糕得多,因为它的工具链在 C 依赖关系方面没有提供任何帮助。

        在 Rust 中,你可以将 C 库封装为一个 crate,最终开发者并不需要关心太多。如果你想使用他们的系统头文件,你需要让他们知道,但如果头文件是捆绑的,这就不是问题了。Cargo 知道如何将任何 C 构建的对象文件吸收到最终的二进制文件中,而且它能正常工作。

        而在 Go 中,无论在哪种情况下,最终开发者都需要费尽心思,在依赖关系树上的任何依赖关系使用 C 语言库时,管理所有必要的 cgo 调用。这真的非常不支持。

    • 你可以一直使用不安全代码。这是关于如何让代码在没有不安全块的情况下做到这一点。

  6. 与未指定与未定义有关。我记得有些 C 代码试图耍花招,从已分配的内存中读取数据。比如

    int* ptr = malloc(size); if(ptr[offset] == 0) { }

    这段代码假定已分配缓冲区中的值不会改变。

    然而,在审查中有人指出,通过这些步骤,值是可以改变的:

    1) malloc 从一个新的内存页进行分配。在写入之前,该页面通常不会映射到物理页面。

    2)由于页面没有映射,读取时只会返回默认值(通常为 0)。

    3) 向同一页面写入另一个分配。这将页面映射到物理内存,从而改变原始分配的值。

    • 从一个未映射页面读取的值与从映射后的同一页面读取的值不同,这是一个操作系统错误 (*)。如果这是一个已经分配的页面,并且已经写入了一些内容,那么从该页面读取就会重新分页,然后产生实际内容。如果这是一个新页面,而操作系统的合约是提供归零页面,那么在映射之前和映射之后的读取都会产生归零结果。

      可能发生的情况是,代码中的 UB 可能导致编译方式使比较变得不确定。

      (*): ……或者,我们讨论的不是普通的用户空间程序,而是直接进行未分页访问的更高权限层,但我认为情况并非如此,因为你讨论的是 malloc。

        • 发言者搞错了/说错了。

          最接近 “有条件地返回内核 ”的情况是将页面交给 madvise(MADV_FREE),但这仍然不会产生他们所说的行为。读取和写入仍然会产生相同的内容,要么是原始页面内容,因为内核还没有释放页面;要么是零,因为内核已经释放了页面。即使操作顺序是读取 -> 内核释放 -> 写入,也与他们的说法不符,因为读取会产生原始页面内容,而不是零。

          也就是说,他们所说的代码与你的代码不同,他们的代码是专门进行越界读取的。(他们说:”如果你碰巧分配了一个 128 字节的字符串,而 malloc 恰好返回了一个距离页面末尾 128 字节的地址,那么你将写入 128 字节,空结束符将是下一页的第一个字节。所以,他们说的很清楚,0 是在分配之外的)。

          所以绝对有可能出现这样的设置:字符串的分配恰好被另一个当前为 0 的分配所跟随 -> `data[size()] ! = ‘’` 检查被执行并成功 -> `data` 返回给调用者 -> 谁拥有下面的分配,谁就会在第一个字节写入一个非零值 -> 谁调用了 `c_str()` ,谁就会在 128B 字符串的末尾运行。这与页面无关;它可能发生在单个页面的范围内。这也是一个非常明显的越界错误,它竟然通过了任何形式的代码审查,而且还需要某种灰胡子来指出,这让我大惑不解。

          • 我不认为他们在分配 128 字节或访问越界内存。

            他明确指出 128 字节的文件名分配了 129 字节。https://www.youtube.com/watch?v=kPR8h4-qZdk&t=1417s

                • 遗憾的是,这一假设也是错误的。只有在没有 MMU 的情况下,内核才能启用 MAP_UNINITIALIZED,在这种情况下,页面已经在物理内存中,因此第一个指针取消引用将读取正确的字节,而不是因为 “未初始化 ”而读取一个假的 0。

  7. 请原谅我的无知,但我以为 Rust 的全部意义都在于成为 C 语言的 “安全 ”现代替代品,因此所有新缓冲区都会被清零,而清零的代价在当今是微不足道的。为什么 Rust 在这一点上半途而废?

    • 对每个人来说,成本可能都无法忽略不计?

      Rust 正被广泛使用,其设计目标是能够应用于从顶级 PC、服务器、微控制器到浏览器中的虚拟机等各个领域。

      并不是所有人在任何时候都能接受所有的取舍。

    • 其他语言可以轻松实现这种安全性。Rust 的不同之处在于,它试图在运行时不做额外工作的情况下提供这种级别的安全性(因为否则人们就会把它和 Java/C# 放在同一堆,继续使用 C/C++ 来提高速度)。

    • 不是这样的。情况确实如此。但在 “如今可忽略不计 ”还不够可忽略不计的情况下,这一点仍然很重要,而不安全 + MaybeUninit 则是实现这一点的捷径。

      • 另外,并非每个类型都有一个有效的 “全为零 ”值。

      • 呸。在我看来,“使用 C 语言而首先不使用 Rust ”就是逃生舱门,Rust 根本就不应该去那里。哎呀,真是一团糟。

        • Rust 提供了托管语言的所有安全保证,却没有任何性能缺陷。它恰恰是要取代 C/C++ 的,因为 Rust 的不安全部分用得非常少,导致的 bug 和安全漏洞也少得多。

          安全的抽象可以有效地处理未初始化的内存,这对于从编译器中获得最佳代码以及减少编写此类代码时犯错的可能性非常重要。

          用 C 语言来实现这一点是一种情绪化的过度反应,而不是冷静地处理一个已经有解决方法的小角色,即使它确实涉及到使用不安全的代码。

        • 这就是为什么我们要花费大量精力来构建更好的抽象,而不需要你编写任何不安全的代码。

    • 如果你的缓冲区是 64 GiB 呢?(好吧,实际上操作系统会根据需要将其清零,但仍然如此)

      • 我很想知道内存清零对性能的实际影响。我敢打赌,在几乎所有程序中,提前清零内存的性能成本都可以忽略不计。

        • 这里有一个非常愚蠢的例子:

            $ cat bigbuf.c 
            #include <stdlib.h>
            #include <stdio.h>
            #include <string.h>
            #define DEFINITELY_BIG_ENOUGH 2U*1024*1024*1024
            int main(int nargs, char ** args)
            {
              char * definitely_big_enough = malloc(DEFINITELY_BIG_ENOUGH);
              if (nargs > 1)
              {
                memset(definitely_big_enough,0, DEFINITELY_BIG_ENOUGH);
              }
              sprintf(definitely_big_enough,"%s",args[0]);
              return 0;
            }
            
            $ /usr/bin/time ./bigbuf 
            0.00user 0.00system 0:00.00elapsed 100%CPU (0avgtext+0avgdata 1356maxresident)k
            0inputs+0outputs (0major+80minor)pagefaults 0swaps
            $ /usr/bin/time ./bigbuf 1 
            0.05user 1.25system 0:01.31elapsed 99%CPU (0avgtext+0avgdata 2098468maxresident)k
            0inputs+0outputs (0major+524369minor)pagefaults 0swaps
          

          在不同的操作系统上,具体情况具体分析。当然,这是一个白痴才会写的程序,但缓存等东西往往比中位数情况要大得多,尤其是在 Linux 上,因为你知道存在超量提交的情况。

          • 非白痴会使用 calloc(DEFINITELY_BIG_ENOUGH),这很可能会消除差异,因为内嵌程序可以依靠 mmap(ANONYMOUS)为如此大的分配创建零页。更现实的测试方法是使用大量反复调用然后释放的小分配,因为 a) 释放小分配并不能释放底层页面,因此重新分配时不能依赖于它已经为零,b) 将小分配归零并不能像将大分配归零那样摊销归零的成本。

      • Rust 的重点当然是 “以性能为代价的安全性”,如果需要额外的性能,就不要使用 Rust。不要为了迎合而修改语言!

        • Rust 的重点显然不是 “以性能为代价的安全性”,恰恰相反。重点在于创建一种语言,在这种语言中,安全性不会像大多数其他语言那样以性能为代价。

          • 然而,在一篇关于 “安全 ”语言的文章中,代码示例却充满了 “不安全”……什么?

            • 不安全 “只是编译器证明和程序员证明之间的障碍。

              人们在发表这样的评论之前,真的应该多读读 Rust 的安全语义,每次在什么地方提到 Rust,都会碰到一些表面上的误解,这让人相当恼火。

            • 在正常的安全代码中,编译器会确保你的代码没有 UB。在不安全代码块中,程序员确保代码没有 UB。

              安全抽象是通过确保不可能违反操作的前提条件而从不安全操作中创建出来的。要么在运行时进行检查,并在违反时返回错误或中止程序;要么在编译时使用类型系统和借用检查器进行验证(在编写代码时,任何可能违反先决条件的程序都必须出现类型错误)。

              如果 Rust 没有不安全机制,访问底层不安全操作的唯一方法就是下放到 C/C++/Assembly 中,或者在编译器中进行硬编码。其他语言也是这么做的,而且从人机工程学角度来看,这种做法更糟糕,因为在项目中添加全新语言和构建系统的门槛相当高。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注


京ICP备12002735号