这是用户在 2024-12-20 24:26 为 https://zackoverflow.dev/writing/unsafe-rust-vs-zig/ 保存的双语快照页面,由 沉浸式翻译 提供双语支持。了解如何保存?

zackoverflow


当 Zig 比 Rust 更安全、更快时


关于 Rust 与 Zig 的争论在网上层出不穷,本文探讨了一个我认为提及不够的方面。



介绍 / 概述λ


我得知 Roc 语言将标准库从 Rust 重写为 Zig 后感到十分好奇。是什么让 Zig 成为了更优的选择呢?


他们写道,他们使用了大量不安全的 Rust 代码,这反而成了阻碍。他们还提到,Zig 提供了“更多在内存不安全环境下工作的工具,比如在测试中报告内存泄漏”,使得整个过程更加顺畅。


那么,Zig 是否是编写不安全 Rust 的更好替代方案呢?


我想亲自测试一下,通过构建一个需要大量不安全代码的项目,来看看不安全的 Rust 会有多难。


然后我会用 Zig 重写这个项目,看看是否会更简单/更好。


完成两个版本后,我发现 Zig 实现更为安全、快速且编写起来更简便。我将分享一些关于构建这两个版本的经验以及我所学到的东西。


我们正在构建的内容λ


我们将为一种使用标记-清除垃圾回收的编程语言编写字节码解释器(或虚拟机)。


垃圾回收是重要环节,既要实现其功能要高效安全,这相当困难,因为从根本上讲,它与借用检查器并不兼容。


在安全的 Rust 中,我能想到两种实现方式:使用引用计数1和使用区域+句柄2,但这两种方法似乎都比传统的标记/清除方法3要慢。


特定的字节码解释器实现源自我最喜爱的一本书:《编译器设计之道》。特别是,它是一个基于栈的虚拟机,适用于支持函数、闭包、类/实例等特性的语言。


您可以在此处查看此代码。


不安全的 Rust 实现λ


总的来说,这次体验并不理想,尤其是与编写常规的安全 Rust 代码时的愉悦感相比。我将具体分析其中的原因。


不安全的 Rust 很难λ


不安全的 Rust 很难。比 C 语言难得多,这是因为不安全的 Rust 对未定义行为(UB)有着许多细微的规则——多亏了借用检查器——这使得很容易不知不觉地破坏代码并引入错误。


这是因为编译器在假设你遵循其所有权规则的情况下进行优化。然而,一旦你违反这些规则,就会被视为未定义行为,编译器仍会继续应用相同的优化,可能会将你的代码转化为潜在的危险状态。


更糟糕的是,Rust 并不完全清楚哪些行为被视为未定义4,因此你可能会在不知情的情况下编写表现出未定义行为的代码。


缓解此问题的一种方法是使用Miri,它是 Rust 中层中间表示的解释器,能够检测未定义行为。


它完成了工作,但也自带一些瑕疵5


以下是 Miri 向我展示的未定义行为:

Miri telling me I did a nono


最具挑战的部分:别名规则λ


我在 Rust 中遇到的最具挑战性的未定义行为来源与 Rust 的别名规则有关。


如前所述,Rust 利用其借用/所有权规则来实现编译器优化。若违反这些规则,将导致未定义行为。


那么,我该如何书写这个呢?


诀窍在于使用原始指针。它们不像常规的 Rust 引用那样受到相同的借用约束,从而让你能够绕过借用检查器。


例如,你可以拥有任意数量的可变(*mut T)或不可变(*const T)原始指针。


那么解决方案应该很简单,对吧?并不尽然。


如果你将一个原始指针转换为一个引用&mut T&T),你必须确保在整个生命周期内,它遵守该类型引用的规则。例如,当其他可变或不可变引用存在时,不能存在可变引用。

fn do_something(value: *mut Foo) {
    // Turn the raw pointer into a mutable reference
    let value_mut_ref: &mut Foo = value.as_mut().unwrap();

    // If I create another ref (mutable or immutable) while the above ref
    // is alive, that's undefined behaviour!!

    // Undefined behaviour!
    let value_ref: &Foo = value.as_ref().unwrap();
}


这真的很容易违反。你可能会对某些数据创建一个可变引用,调用一些函数,然后在调用栈深入到第 10 层时,某个函数可能对同一数据创建了一个不可变引用,现在你就有了未定义行为。哇哦!


好的,那么如果我们完全避免使用引用,只使用原始指针呢?


嗯,这个问题在于原始指针不具备与引用相同的易用性。首先,你无法拥有以原始指针形式接收self的关联函数:

struct Class {
    /* fields... */
}

impl Class {
    // Regular associated function
    fn clear_methods(&mut self) {
        /* ... */
    }

    fn clear_methods_raw(class: *mut Class) {
        /* ... */
    }
}

unsafe fn test(class: *mut Class) {
    let class_mut_ref: &mut Class = class.as_mut().unwrap();
    // This syntax is nice and ergonomic
    class_mut_ref.clear_methods();

    // But with raw pointers you'll have to just call the function like in C
    Class::clear_methods_raw(class);
}


接下来,没有像 C 语言中那样优雅的指针解引用语法,比如ptr->field。代码中到处充斥着丑陋的(*ptr).field,真是令人作呕。


当你不得不进行链式解引用时,真的很糟糕。以下是我在代码中发现的两个“怪物”:

// The way to make these readable is to create a variable for each dereference,
// but that's annoying so in some places I got lazy.

// ew
(*(*closure.as_ptr()).upvalues.as_ptr().offset(i as isize)) = upvalue;

// ewwwwww
let name = (*(*(*ptr.cast::<ObjBoundMethod>().as_ptr()).method.as_ptr())
    .function
    .as_ptr()).name;


另一个问题是处理数组。


如果我有一个指向数据数组的原始指针(*mut T),我可以将其转换为切片 &mut [T],并且可以对其使用 for ... in 循环或任何便捷的迭代器方法(如 .for_each().map() 等)。


但将其转换为 `&mut [T]` 基本上是对数组中所有数据的引用,这使得再次违反 Rust 的别名规则变得非常容易。

unsafe fn do_stuff_with_array(values: *mut Value, len: usize) {
    let values: &mut [Value] = std::slice::from_raw_parts_mut(values, k);
    // I can use the ergonomics of iterators!
    for val in values {
        /* ... */
    }
    // I just have to make sure none of the Values are turned
    // into references (mutable or immutable) while the above slice is active...
}


再次,解决办法是避免使用引用,因此在某些地方我最终在数组的原始指针上编写了常规的 C 风格 for 循环。但与 Rust 的切片相比,原始指针很糟糕,因为你无法对它们进行索引,也不会得到越界检查。

pub struct Closure {
    upvalues: *mut Upvalue
}

unsafe fn cant_index_raw_pointer(closure: *mut Closure) {
    // Can't do this:
    let second_value = (*closure.upvalues)[1];

    // Have to do this, and no out-of-bounds checking
    let value = *(*closure).upvalues.offset(1);
}


Miri 采用堆叠借用模型,因此能够检测到与上述别名规则相关的未定义行为(UB),但修复这些问题颇具挑战性。


感觉很像我学习 Rust 的时候,有些规则存在,但我还没有形成扎实的思维模型。


我需要弄清楚自己所做为何出错,然后通过实验寻找解决之道。另请注意,反馈循环要慢得多,因为我的代码编辑器中没有 LSP 引导,每次都得重新编译程序并查看 Miri 的输出。


这真的毁了我的体验。到最后,我写的不是真正的 Rust,而是一种奇怪的半 Rust 半 C 语言,错误频发且脆弱不堪。


Zig 实现λ


在花费大量时间练习 Rust 中的黑魔法后,我兴奋地准备告别不安全的 Rust,学习 Zig,并开始用它重写项目。


除了不像不安全的 Rust 那样存在疯狂的未定义行为外,Zig 是一种理解你将进行内存不安全操作的语言,因此它围绕着使这种体验更好、更不易出错进行了设计和优化。以下是一些关键因素:


显式分配策略λ


在 Zig 中,任何分配内存的函数都必须传入一个 Allocator


这真是太棒了,因为我将垃圾收集器定制成了一个自定义的Allocator。每次分配/释放内存时,都会记录分配的字节数,并在需要时触发垃圾收集。

const GC = struct {
    // the allocator we wrap over that does
    // the heavy lifting
    inner_allocator: Allocator

    bytes_allocated: usize

    // and other fields...

    // `alloc`, `resize`, and `free` are required functions
    // to implement an Allocator
    fn alloc(self: *GC, len: usize, ptr_align: u29, len_align: u29, ret_addr: usize) ![]u8 {
        // let the inner allocator do the work
        const bytes = try self.inner_allocator.rawAlloc(len, ptr_align, len_align, ret_addr);

        // keep track of how much we allocated
        self.bytes_allocated += bytes.len;

        // collect if we exceed the heap growth factor
        try self.collect_if_needed();

        return bytes;
    }
};


我喜爱 Zig 这一设计选择的原因在于,它使得采用针对具体使用场景最优的不同分配策略变得毫无阻力且符合习惯。


例如,如果你知道某些分配具有相似且有限的生存期,你可以使用超快的碰撞竞技场分配(或线性竞技场分配)来加速你的程序。这类功能在 Rust 中存在,但在 Zig 中表现得更为出色6


一个检测内存错误的特殊分配器λ


使用时,它能检测到释放后使用和重复释放的错误。它会打印出数据分配、释放及使用时的完整堆栈跟踪信息。

// All you have to do is this
const alloc = std.heap.GeneralPurposeAllocator(.{
    .retain_metadata = true,
}){};


这在开发过程中尤其起到了救命的作用。


默认情况下非空指针λ


大多数内存安全漏洞是空指针解引用和数组越界索引。


Rust 的原始指针类型默认是可空的,并且没有空指针解引用检查。有一个NonNull<T>指针类型可以提供更多安全性。我不确定为什么它不是默认设置,因为 Rust 的引用本身是不可空的。


Zig 指针默认是非空的,你可以通过使用?语法(例如?*Value)选择加入可空性。当然,默认情况下也会启用空指针解引用检查。


我更倾向于这种方式,它让默认选项成为最安全的选择。


指针与切片λ


Zig 理解你将使用指针进行工作,因此它让这一体验变得非常出色。


不安全 Rust 版本的一个大问题是原始指针的使用体验极差。解引用语法糟糕透顶,而且我无法使用slice[idx]语法来索引原始指针数组。


Zig 的指针与 Rust 的引用具有相同的人体工程学特性,即点运算符同时充当指针解引用:

fn do_stuff(closure: *Closure) {
    // This dereferences `closure` to get the
    // `upvalues field`
    const upvalues = closure.upvalues;
}


Zig 还提供了一些额外的指针类型,帮助你区分“指向单个值的指针”和“指向数组的指针”:

const STACK_TOP = 256;
const VM = struct {
    // pointer to unknown number of items
    stack_top: [*]Value,
    // like a rust slice:
    // contains a [*]Value + length
    // has bounds checking too
    stack: []Value,
    // alternative to slices when
    // N is a comptime known constant
    stack_alt: *[STACK_TOP]Value
};


这些支持使用array[idx]语法进行索引,而常规指针(*T)则不支持,这对安全性来说非常棒。


有趣的是,在不同指针类型之间进行转换非常简单:

fn conversion_example(chars: [*]u8, len: u8) []Value {
    // Converting to a []T is easy:
    var slice: []const u8 = chars[0..len];

    // And back
    var ptr: [*]u8 = @ptrCast([*]u8, &slice[0]);
}


“传统”指针,如在 C/C++中常见的那些,极易引发错误。


Rust 通过在指针上添加一个外观层来解决这个问题:它的引用类型(&T&mut T)。但遗憾的是,对于 Rust 来说,其原始指针仍然存在与 C/C++中相同的问题。


Zig 通过简单地移除指针中的许多潜在危险操作,并增加额外的防护措施来解决这一问题。


基准测试结果λ


现在是时候展示大家期待的内容了——基准测试7

  结果λ


以下是一些成果图片,若想查看具体代码,请访问仓库


1. 第 35 个斐波那契数λ
Computing the 35th fibonacci number


Zig 比 Rust 快 1.54 ± 0.06 倍。


2. 方法调用压力测试λ
Stress testing the instance method calling


Zig 比 Rust 快 1.76 ± 0.04 倍。


3. 字符串相等性压力测试λ
Stress testing string equality


Zig 比 Rust 快 1.56 ± 0.05 倍。

  4. 动物园λ
Zoo


Zig 比 Rust 快 1.73 ± 0.04 倍。


为什么 Zig 更快?λ


从基准测试来看,Zig 实现的速度比 Rust 快了大约 1.56 到 1.76 倍。为什么会这样呢?


我尝试对两者进行分析,但从中并未获得任何有用信息。


我认为原因在于 Rust 版本中有几处我使用了索引而非指针(例如栈、下一条指令、调用帧栈以及一些用于 upvalue 的地方),因为要让它们使用指针并通过 Miri 的未定义行为检查过于复杂。


Zig 版本在这些地方使用了指针。因此,像操作栈顶、获取下一条指令或当前调用帧这样极其常见的操作,只需简单的指针解引用即可完成——而 Rust 则需要进行指针运算。


记住,这些操作在虚拟机中发生得极其频繁,因此这些额外的操作会累积起来。


我尝试重构了 Rust 版本,但每次尝试都耗费大量时间,且无法通过 Miri 的检查,因此稍作尝试后便放弃了。


如果有人对为什么 Rust 版本较慢有其他想法,或者有任何关于如何加速的建议,请告诉我!

  结论λ


编写大量不安全的 Rust 代码确实会削弱这门语言的美感。我感觉自己要么是在未定义行为的碎玻璃上小心翼翼地行走,要么就是在用一种怪异的半 Rust 半 C 的变异语言编写代码,这让人很不舒服。


Rust 的核心在于利用借用检查器,但当你频繁需要做借用检查器不认可的事情时……你真的应该使用这门语言吗?


在比较 Rust 与 Zig 时,Rust 的这一特性似乎并未得到足够多的讨论,但如果你为了性能而进行内存不安全的操作,这绝对是一个需要考虑的因素。


作为一个热爱 Rust 的人,我肯定会更多地探索在项目中使用 Zig。我喜欢能够选择不同的内存分配策略以及由此带来的性能提升机会。


在我编写 Zig 版本的过程中,我详细记录了学习与使用该语言时的思考与感受。我将整理这些笔记,并作为单独的博客文章发布。


查看这篇帖子在HNReddit上的讨论。

  资源λ


本由 LLVM 的 Chris Lattner 撰写的 3 部分系列文章“每个 C 程序员都应了解的未定义行为”,深入探讨了编译器优化如何导致含有未定义行为的代码变得不健全。


这段来自《Rustonomicon》的章节展示了一个违反 Rust 别名规则的例子,以及编译器优化可能引发的问题。


两年前我读到曼努埃尔·塞隆的一篇文章,它激发了我写这篇文章的想法。


查看《编译器构造》,这两个解释器均基于此书中的内容构建。


这里是包含 Rust 和 Zig 解释器代码的仓库。


感谢CalebDannyPhil Eaton为我校对这篇文章。

  脚注λ

1


引用计数指针存在一些性能开销:


  • 添加/删除引用会通过增加/减少引用计数浪费宝贵的 CPU 周期。


  • 引用计数是堆分配的,因此在添加/删除引用时会破坏缓存行,从而导致额外的性能开销


  • 引用计数在处理循环时表现不佳,你需要额外实现某种机制来检测自引用指针,并手动进行垃圾回收。


因此,最终你不得不承担维护引用计数的成本,并且还需要附加一个小型垃圾回收器来检测循环数据。


我所知的唯一一个实现了这种混合“引用计数 GC”设计的 Rust 项目是Goscript,它本身基于CPython 的设计


但这并不是说基于引用计数的垃圾回收总是缓慢的。Perceus 是一种针对函数式编程语言的引用计数垃圾回收设计,它利用了函数式语言中数据类型不可变的特性,从而大幅减少了垃圾回收的开销和执行时间。在某些情况下,它的性能甚至超过了 C++!这种策略被语言 KokaRoc 所采用。

2


使用 arenas+handles 策略是将对象分配到一个“arena”(通常是一个 Vec)中,而不是使用指针或引用,而是采用“handles”(通常是 Vec 的索引)。这让你能够绕过 Rust 的一些借用规则。


由于索引到竞技场比指针解引用更慢,因此存在性能开销。你不能简单地解引用一个指针,而是需要使缓存行失效以获取指向竞技场起始位置的指针,然后还得进行指针运算。此外,它并不能完全解决你的借用检查器问题,有时为了满足编译器的要求,你不得不进行不必要的克隆或其他额外工作。


我开始实施这一策略在这里,但尚未完成。它使用了一个generational-arena的分叉版本,该版本修改了句柄类型,使其具有更符合 CPU 缓存友好的 8 字节宽表示形式。同时,它确保句柄非零,并将其封装在NonZeroUsize中,以便 Rust 的空指针优化能够生效。

3


这并非一个完全有依据的说法。众所周知,引用计数通常比垃圾回收(GC)更慢。正如上文脚注中提到的竞技场策略所述,索引操作似乎会比指针解引用更慢。


但我实际上还没测试过这一点。大多数垃圾收集器之所以快速,是因为它们采用了诸如分代垃圾回收等精妙的优化技术。我们只是构建了一个简单的标记/清除收集器,而且我没有时间制作四个不同的版本来全面比较每种策略。


记住,我这么做也是为了好玩,想看看 Rust 在安全性方面与 Zig 相比表现如何。

4


我发现的最佳一站式资源是《Rust 参考手册》中的这个页面,但它有一个相当大的警告标志:


以下列表并不详尽。Rust 对于在 unsafe 代码中允许或不允许的内容没有正式的语义模型,因此可能存在更多被视为不安全的行为。以下列表仅是我们确认为未定义行为的情形。

5


Miri 能够检测到许多未定义行为(UB),甚至包括像释放后使用这样的问题,但我仍然在使用过程中遇到了一些困扰:

  •   它很慢


    解释器的速度并不算快,据我观察,它的执行速度比常规 Rust 慢了 400 多倍。我用 Rust 编写的测试在不到一秒内就能完成,而 Miri 却需要好几分钟。


  • 它并非适用于所有事物


    我尝试使用mimalloc分配器来加速内存分配,但 Miri 无法处理外部函数(mimalloc crate 调用了 C 库)。


    这相当糟糕,因为这意味着如果你使用了一个 FFI 库,就无法测试未定义行为。


  • 您使用的箱子可能存在未定义行为(UB)


    如果你在 Rust 程序中使用了一个 crate,而该 crate 存在某些未定义行为(UB),Miri 也会因此而恐慌。这很糟糕,因为无法配置让它跳过这个 crate,所以你只能选择自己 fork 并修复 UB,或者向 crate 的作者提出问题,希望他们能修复。


    这种情况在我参与的另一个项目中也发生过一次,我等了一天希望问题能得到修复,但最终修复后,我立刻又遇到了来自另一个库的未定义行为问题,于是只好放弃。

6


我最喜欢的 Rust 中的内存分配器是 bumpalo,它非常棒。但 Rust 语言整体上并不是围绕自定义分配器设计的(尽管目前仍在进行中)。


如果你想在 Rust 中使用 bump-allocate 分配一个 `Vec<T>`,你必须使用 bumpalo 的自定义 `Vec` 类型,这实际上是对标准库版本的复制粘贴,并修改为使用 bumpalo 的 arena 类型进行分配。如果你想要支持其他集合,例如 `BTreeMap<K, V>`,你将不得不从 Rust 的 std 库中复制粘贴并进行修改。


相比之下,Zig 的设计考虑到了自定义分配器。你可以创建一个 ArrayList<T> 并为其指定一个分配器,它将存储并使用该分配器。如果你不想让 ArrayList<T> 为分配器额外占用 8 字节(例如,当你有许多 ArrayList 并希望设计更符合 CPU 缓存友好性时),你可以创建一个 ArrayListUnmanaged<T>,仅在向数组列表中添加或移除元素时传递分配器。

7


像 Zig、Rust、C 和 C++这样的系统级语言通常性能相当。特别是 Zig 和 Rust,默认使用 LLVM 后端编译为机器码,两者都让你能够充分控制数据的内存布局,以编写高效的程序,并且它们都没有传统意义上的“运行时”。


因此,我认为相较于性能,开发者体验是一个更好的衡量标准。我在 Rust 上投入的努力是否比在 Zig 上更值得,或者反之亦然?


在这种情况下,即使我的 Rust 实现更快,我也会说“不”。处理所有不安全 Rust 的细微差别实在是个大麻烦。