Unsafe Rust 并不和 C 语言一样:从 Aliasing 谈起
在社区中你可能听到一种说法:在 unsafe
块里你可以像 C1 语言那样写。然而事实并不是这样,C 语言的约束太差,事实上给了程序员太多“宽松”的东西。而在 Rust 里,unsafe 真的很 unsafe —— 当你要接管编译器为你做的安全保证,危险就暗藏在其中。
从 Aliasing XOR mutability 说起
考虑这样一个函数
fn alias_example(x: &mut i32, y: &mut i32) -> i32 {
*x = 114;
*y = 514;
return *x;
}
对于 C 语言新手程序员而言,这个函数看上去似乎等价于
int alias_example(int *x, int *y) {
*x = 114;
*y = 514;
return *x;
}
真的是这样吗?想想看,假如 x
和 y
实际上是指向同一个内存区域的不同名指针怎么办?一个 C 程序员可以很自然的回答: x
被先赋值为 114
,然后又被赋值为 514
,最后函数整体返回 514
。
然而并非如此。实际上,Rust 使用了严格的 aliasing 规则,根据 Rust 的规则,同一个变量永远不能有两个可变引用。这直接导致编译器的优化产生不同的结果。不考虑内联,编译器可以安全的假设,x
和 y
永远指向不同的内存地址。因此,在第 2 行赋值之后,编译器可以知道 *x
一定是 114, 从而直接返回 114.
但如果我们在 unsafe
块中直接违背这个规则呢?这会导致 Undefined Behavior(未定义行为)。在 Rust 中,unsafe
块只是允许你绕过编译器的安全检查,但这并不意味着你可以违背 Rust 的 Invariant(不变式)假设。例如,Rust 的 unsafe
代码仍然需要遵循 Rust 的 aliasing 规则。
#[inline(never)]
fn alias_example(x: &mut i32, y: &mut i32) -> i32 {
*x = 114;
*y = 514;
return *x;
}
pub fn main() {
let mut a = 42;
let mut b = 84;
let ret = alias_example(&mut a, &mut b);
println!("a: {a}, b: {b}, ret: {ret}");
unsafe {
let mut x = 1;
let still_x = &mut x as *mut i32;
// undefined behavior!
let ret = alias_example(&mut x, &mut *still_x);
println!("x: {x}, ret: {ret}");
}
}
在这个例子中,我们在 unsafe
块中创建了一个指向 x
的裸指针 still_x
,然后将其作为第二个参数传递给 alias_example
函数。显然这违背了 aliasing 规则,因此产生一个 undefined behavior。严格来说,该程序编译运行的结果是不确定的。但是在 rustc 1.88.0 开启 -O
(3 级优化)后,我们可以看到输出如下:
a: 114, b: 514, ret: 114
x: 514, ret: 114
Compiler Explorer 链接: https://godbolt.org/z/Y6s15h9eP
正如我们所料,编译器对 alias_example
函数进行了优化,虽然 *x
在函数中被第二次赋值为 514,但编译器接下来根本没有用到 *x
的值,而是直接返回了 114。生成的汇编代码如下:
example::alias_example::h26925842407e8957:
mov dword ptr [rdi], 114
mov dword ptr [rsi], 514
mov eax, 114
ret
而如果去掉 -O
优化选项,编译器就不会进行这样的优化了。将会正确的返回 514。
example::alias_example::h26925842407e8957:
mov qword ptr [rsp - 16], rdi
mov qword ptr [rsp - 8], rsi
mov dword ptr [rdi], 114
mov dword ptr [rsi], 514
mov eax, dword ptr [rdi]
ret
这说明,如果你的代码中包含 unsafe
块,不同的优化级别下,可能会导致不同的行为。事实上,之前的代码实际上等价于使用了 restrict
关键字 的 C 代码:
int alias_example(int* restrict x, int* restrict y) {
*x = 114;
*y = 514;
return *x;
}
在 -O3
编译选项下,生成如下的汇编
alias_example:
mov DWORD PTR [rdi], 114
mov eax, 114
mov DWORD PTR [rsi], 514
ret
与 Rust 的行为一致。如果去掉 restrict
关键字,g++ 编译器(和任何一个成熟的编译器)都将默认 x
和 y
是可能 aliasing 的,因此不敢进行激进优化,只能按原样生成汇编代码:
alias_example:
mov DWORD PTR [rdi], 114
mov DWORD PTR [rsi], 514
mov eax, DWORD PTR [rdi]
ret
如果这个函数被大量执行,将会影响到性能。这不是一句玩笑。老练的 C 程序员都应该知道自从 C99 开始 memcpy
函数的类型签名就包含 restrict
关键字:
void* memcpy( void *restrict dest, const void *restrict src, size_t count );
The behavior is undefined if access occurs beyond the end of the dest array. If the objects overlap (which is a violation of the restrict contract)(since C99), the behavior is undefined. The behavior is undefined if either dest or src is an invalid or null pointer.
因为任何一个现代机器上,memcpy
都不是像 PDP-11 那样一个一个字节拷贝的,而是使用 SIMD 指令一次性拷贝多个字节。编译器需要知道 dest
和 src
不会 aliasing 才能进行这样的优化。你可以试一试用 Compiler Explorer 手动实现一个 memcpy
,比较删掉 restrict
关键字后二者的影响。删掉 restrict
关键字后,编译器必须先比较两个指针是否相差在 count
直接以内(正如 restrict
保证),如果不是,才可以放心使用 SIMD,否则只能分情况讨论。对于 memcpy
这种被大量调用的函数,这一点点比较开销会导致可观察到的性能下降。
而你没法保证写 C 语言的都是顶尖高手程序员,懂得时时刻刻加入 restrict
关键字,承担一旦用错就会出现未定义行为的后果。更多新手甚至连 const
也懒得或者不会使用。还有一些项目,代码质量明显堪忧,undefined behaviour 满天飞(虽然这也与 C/C++ 有一些不应该有的 undefined behaviour 有关),人们甚至总结出“不要开启优化选项,编译器会搞烂你的代码”的建议。——也可以理解,一些项目开出的工资没有办法雇佣能 C 语言期盼的不会犯错程序员。
这也是为什么 Rust 明明比 C 语言抽象程度更高,却可能生成更高效的代码的原因。更多的约束允许编译器获得更多信息,进行更多优化。C 语言不只是不安全。它还慢。 事实上,在当今世界,C 语言反而正在拖慢代码的速度。2
C also requires padding at the end of a structure because it guarantees no padding in arrays. Padding is a particularly complex part of the C specification and interacts poorly with other parts of the language. For example, you must be able to compare two structs using a type-oblivious comparison (e.g., memcmp), so a copy of a struct must retain its padding. In some experimentation, a noticeable amount of total runtime on some workloads was found to be spent in copying padding (which is often awkwardly sized and aligned).
类型系统并不只是一个繁琐的工具,只是用来保证人们想当然“一眼就能看出的”程序的安全性。它还给编译器更多信息,从而自动生成更高效的代码——而对于大多数程序员而言,并不会花费那么多时间在极致的性能优化上。 这很反一些程序员的直觉:它们认为越贴近底层、越像汇编、抽象程度越低的语言速度越快。它们会想当然地觉得,写汇编是最最快的,而 C 语言比汇编慢 10%,C++ 比 C 又慢 10%,Rust 又比 C++ 慢 10% ——这来自于错误的“前人经验”,在编译优化还没有那么成熟的时代,作为“稍微抽象了一点的汇编”的 C 语言确实通常能写出很高效的代码,而 C++ 等语言编译器不够聪明,无法进行足够的优化。而当今世界随着编译技术的发展,编译器已经可以进行非常复杂的优化,甚至可以在大多数情况下超越手写的优化代码。例如一些人津津乐道的“位运算加速技巧”在如今已经不是魔法,而是编译器的常规优化。
扩展阅读:一个 C 语言位运算加速被编译器自动实施的例子
在算法题中,一个完全二叉树指的是叶子结点只能出现在最下层和次下层,且最下层的叶子结点集中在树的左部的二叉树。我们可以用一个数组直接存储完全二叉树,且节点用层次遍历编号,总是有编号为 的节点,左子节点编号为 ,右子节点编号为 。因此我们可以用 x * 2
和 x * 2 + 1
来计算左子节点和右子节点的编号。
#include <stdio.h>
int left_child(int x) {
return x * 2;
}
int right_child(int x) {
return x * 2 + 1;
}
int left_child_faster(int x) {
return x << 1;
}
int right_child_faster(int x) {
return x << 1 | 1;
}
int get_most_rchild(int x, int maxn) {
while (x < maxn) x = right_child(x);
return x;
}
int main() {
int x = 1;
int lc1 = left_child(x),
lc2 = left_child_faster(x),
rc1 = right_child(x),
rc2 = right_child_faster(x);
printf("%d %d %d %d", lc1, lc2, rc1, rc2);
}
在信息学竞赛中,许多人会写 x << 1 | 1
来加速完全二叉树叶子节点 index 的计算。然而现在这种技巧已经不再是魔法了。编译器会自动将 x * 2 + 1
优化为 x << 1 | 1
,你的技巧只不过是让自己读代码变得更困难,实际上对于两个函数,编译器完全会生成一模一样的代码:
left_child:
lea eax, [rdi+rdi]
ret
right_child:
lea eax, [rdi+1+rdi]
ret
left_child_faster:
lea eax, [rdi+rdi]
ret
right_child_faster:
lea eax, [rdi+1+rdi]
ret
get_most_rchild:
mov eax, edi
cmp edi, esi
jge .L6
.L7:
lea eax, [rax+1+rax]
cmp esi, eax
jg .L7
.L6:
ret
.LC0:
.string "%d %d %d %d"
main:
sub rsp, 8
mov r8d, 3
mov ecx, 3
mov edx, 2
mov esi, 2
mov edi, OFFSET FLAT:.LC0
mov eax, 0
call printf
mov eax, 0
add rsp, 8
ret
使用 g++ 15.1, -O1
(相对很保守的优化)下的结果。
可以看到,xxx_child
和 xxx_child_faster
生成的代码完全一样。而 main
和 get_most_rchild
函数内直接把函数自动内联,根本没有生成 call
指令,甚至 main
内结果也在编译期给出,没有任何运行时计算。
但编译器优化也同时为 Rust 的 unsafe
块带来了更高风险。因为在 unsafe
块中,编译器不只是不再为你提供安全的保证,它还需要你将 Rust 的所有不变式熟记于心。这是人们随意说出“unsafe
块里像 C 语言一样写就好了”的时候所经常忽略的。
编译器优化与 Undefined Behavior
在 C 中一个经常被争论的点是编译器是否应该利用 undefined behavior 来进行激进的优化。C/C++ 语言的标准允许编译器在遇到 undefined behavior 时进行任意行为(比如发射核弹炸毁你的家 :P ),包括假定 undefined behaviour 永远不会发生。(如果发生了,编译器作者假定程序员将会很乐意看到意外的行为)实际上 GCC 等编译器确实会在高的优化等级下,利用这一点进行优化。一个经典的例子是 signed integer overflow。
int foo(int x) {
return x + 1 > x; // either true or UB due to signed overflow
}
如果一字一句的翻译这段代码,在许多平台,int
上溢时会按补码规则处理,得到一个负数值。因此,写这段代码的人会期望这个函数能“证明”一个整数将在当前架构下溢出。C 语言的前几节课讲到原码、反码、补码的时候,一些老师会用类似的代码作为例子向学生生动阐述溢出的效果(虽然应该不会开优化)。然而,signed integer overflow 实际上是 undefined behavior,因此编译器在激进优化时可以假定它永远不会发生。此时编译器会发现 x + 1
的结果永远不会小于 x
,因此可以直接将其优化为 return true;
foo:
mov eax, 1
ret
如果学生不小心打开了优化开关——那它可能会很惊讶的发现代码的行为和老师的例子完全不一样。
类似的例子还有很多。例如被许多人用来证明 “C 语言的优雅” 的著名算法,如《雷神之锤 III 竞技场》源代码中平方根倒数速算法之实例。
float Q_rsqrt( float number )
{
long i;
float x2, y;
const float threehalfs = 1.5F;
x2 = number * 0.5F;
y = number;
i = * ( long * ) &y; // evil floating point bit level hacking(对浮点数的邪恶位元hack)
i = 0x5f3759df - ( i >> 1 ); // what the fuck?(这他妈的是怎么回事?)
y = * ( float * ) &i;
y = y * ( threehalfs - ( x2 * y * y ) ); // 1st iteration (第一次迭代)
// y = y * ( threehalfs - ( x2 * y * y ) ); // 2nd iteration, this can be removed(第二次迭代,可以删除)
return y;
}
实际上,这个代码是 undefined behaviour。C 的 strict aliasing 规则^[https://en.cppreference.com/w/c/language/object.html#Strict_aliasing]规定:
Given an object with effective type T1, using an lvalue expression (typically, dereferencing a pointer) of a different type T2 is undefined behavior, unless:
- T2 and T1 are compatible types.
- T2 is cvr-qualified version of a type that is compatible with T1.
- T2 is a signed or unsigned version of a type that is compatible with T1.
- T2 is an aggregate type or union type type that includes one of the aforementioned types among its members (including, recursively, a member of a subaggregate or contained union).
- T2 is a character type (char, signed char, or unsigned char).
These rules control whether when compiling a function that receives two pointers, the compiler must emit code that re-reads one after writing through another:
因此,int*
和 float*
根本不应该指向同一块内存区域。这是 undefined behaviour。对于许多程序员而言这简直是荒谬的事情,因为它极大的限制了作为底层语言的 C 语言的能力。程序员会觉得,float
和 int
都是一样的大小,为什么不能直接将一个数字的位模式转换为另一个数字的位模式?这不是 C 语言的强项吗?
但如果你站在编译器开发和优化者的角度,这个规则就变得非常有意义:当你看到 int*
和 float*
同时出现的时候,假定两者不会指向同一块内存区域,可以让编译器进行更激进的优化。因为如果两者指向同一块内存区域,编译器就必须在每次读取 int
或 float
时都重新从内存中读取,而不能直接使用寄存器中的值。让我们再回到最开始的 aliasing 例子。如果有以下代码:
int alias_example2(int *x, float *y) {
*x = 114;
*y = 514;
return *x;
}
编译器根本没法揣摩你的心意,尤其是像 C 这样的语言,你编译出来的代码还经常作为动态链接库,你根本不知道用户会传入什么。如果你不按 strict aliasing 就没法优化这段代码,说不定它会被大量调用,成为一个性能下降的节点呢?权衡之下,考虑到对同一块内存区域进行不同类型的解释是很罕见的情况,编译器更应该尝试将它优化成:
alias_example2:
mov DWORD PTR [rdi], 114
mov eax, 114
mov DWORD PTR [rsi], 0x44008000
ret
由于 undefined behaviour 广泛而常见,甚至在一些地方是难以绕开的(例如操作系统,Linux 的编译包含了 -fno-strict-aliasing
规则),有人认为3 GCC 等编译器 “using undefined behaviour in the C Standard as an excuse to fuck up their own compiler is what’s beyond demented” (拿 C 标准中的未定义行为作为借口来搞砸自己的编译器,简直是疯子干得出来的事情)
然而我实际上不那么认为。难听点说,这是非常不负责任的行为。如果你不在乎性能而喜欢非常确定的行为可以用 Python 不是吗?为什么要用 C/C++ 为难自己?如果你在乎性能,就应该知道你在这里觉得无所谓的 5~10% 差异是别人可能刚需的。编译器作者不是只为你服务。这是一个多方权衡的 trade-off。
实际上,我认为这种想法这是倒果为因了,应该说:是写编译器的人需要有足够强的假定来给他们进行优化,因此才将一些行为定义成 undefined behaviour.
Defining a semantics with that property is not a simple task. A naive semantics, such as the one used in , will give the example program4 a defined meaning and thus force the compiler to print 13. Compared to such a naive semantics, we have to “add” some undefined behavior to obtain the desired optimizations. But of course we should not add “too much” undefined behavior! We have to be careful that “desired” programs are still well-defined. This includes all safe programs, but should also include enough unsafe programs to still make unsafe Rust a useful language for implementing data structures such as Vec, and to minimize the chances of programmers accidentally running into undefined behavior. 5
unsafe
is really unsafe
而你必须负起责任来。
Rust 与 C/C++ 的一个区别大概是后者有人惯着你。我敢说大部分以为自己会写 C/C++ 语言的人实际上都不懂得 C/C++ 语言的 undefined behavior —— 因此使用各种屎上糊屎的手段解决问题。例如极端地,禁止开优化。
但是 Rust 不会让你这样。一大原因是,Rust debug 模式的产物确实太慢,与 Release 模式可以轻易到达 6 倍差距,人们不得不开优化。另一件事情来自 Rust 的设计哲学:Safe Rust 被期望是永远安全的,即使是初学者也不会写出段错误和未定义行为,如果尝试,那么编译器一定会警告。Safe Rust 的编写者从而能从底层的细节中解脱出来,专注关心业务逻辑,有时候则关心向编译器证明自己是对的。而一旦 Unsafe Rust 的编写者不够负责,信任链条就会在这里断裂,从而可能产生危险的漏洞。
以 Unsafe Rust 随堂小测 - 知乎 中给出的代码为例子,Unsafe Rust 的编写者必须非常熟练于找出代码可能被传入的多种边缘情况,否则,一不留神,便可能写出看似正确的 unsound 代码。
例如第 1 题,初始化上的未定义行为。
/// !!!unsound!!!
pub fn bytes_of<T>(val: &T) -> &[u8] {
let len: usize = core::mem::size_of::<T>();
let data: *const u8 = <*const T>::cast(val);
unsafe { core::slice::from_raw_parts(data, len) }
}
编写者必须得想到多种情况:
T
可能是MaybeUninit
,此时得到的&[u8]
包含未初始化的内存,而读取未初始化内存是未定义行为T
可能包含对用户无感知的 padding,但是任何对 padding 的访问,包括读,都是未定义行为T
可能是包含内部可变性的类型(例如Cell
),此时对&T
的访问不能保证指向的内存不变
第 6 题。对齐上的未定义行为。
/// !!!unsound!!!
pub fn ffi_static_mut<T>(val: T) -> &'static mut T {
unsafe {
let size: usize = std::mem::size_of::<T>();
let ptr: *mut T = libc::malloc(size).cast();
if ptr.is_null() {
std::process::abort();
}
ptr.write(val);
&mut *ptr
}
}
显然这就是非常经典的在 unsafe rust 中当成 C 写——例如使用 malloc 函数。但是你们有没有想过,为什么 malloc 分配的指针犹如魔法一样,可以给任何类型使用,无需担心什么问题?尤其是对于初学者而言,它们甚至可能没有质疑过,为什么 malloc 可以给所有类型指针使用这件事情。malloc 能给所有类型使用似乎是理所当然的事情。
——然而不是。这个函数体现了 Rust 和 C 不一样的地方,C 保证了所有类型都是由对齐已知的内置类型组合而来,因此 malloc 的规范里保证了得到的指针总是与任意 object type 都能对齐6。不能对齐有很多危害,例如现代 CPU 经常在 SIMD 指令上要求地址必须对齐。
但是 Rust 没有这个保证。Rust 的类型可能包含更复杂的 align,如果传入的 T
需要更大的对齐,那么 malloc
返回的指针可能无法满足 T
的对齐要求。此时,返回的指针不能被安全的使用,这是未定义行为。
第 7 题,恐慌安全
/// !!!unsound!!!
pub fn replace_with<T>(v: &mut T, f: impl FnOnce(T) -> T) {
unsafe {
let ptr: *mut T = v;
let val = ptr.read();
ptr.write(f(val));
}
}
编写者必须考虑到 f
可能会 panic 的情况。此时,ptr.write
将会被打断,因此不会对 v
写入正确的值——而 panic 后被从 v
里面 move 出来的 val
将会被析构,因此外部的 v
还保留着 val
的同一个值,这将会导致双重析构,从而导致未定义行为。这通常会导致程序直接 segfault。
这些例子都表明,编写 Unsafe Rust 代码的责任在于开发者,这不止是说说而已。开发者必须充分理解 Rust 的内存模型、计算机底层原理、体系结构、Rust 的各种不变式和内置 trait,以确保代码在任何时候都能保证安全性和正确性——或者产生一个编译错误。
这种心智负担比全是 unsafe,全部要开发者自己承担的 C/C++ 某种意义上还要大。C/C++ 写出 bug 大可以怪调用者没遵守调用约定,但是 unsafe rust 的编写者因为自己的 unsafe 模块考虑不周全出了 bug,所有人都只会怪 unsafe 模块的编写者。
结语
经常有人说:对 C++ 和 Rust 极度了解,懂得该怎么驾驭 unsafe,适当的使用 unsafe 可以极大提高数据结构设计的灵活性
那么问题是,谁才是对 C++ 和 Rust 极度了解呢?到底有多少人能做到“极度了解”呢?如果你是一个 C/C++ 程序员,你可能会觉得自己已经很了解了,但是你真的了解吗?大部分写 C/C++ 的人应该都远远称不上了解。尚且不说需要系统学习计算机底层的各种 alignment padding 需要编译原理来理解为什么的 aliasing 还有被誉为只有语言律师才能记得的各种 undefined behavior。就单说代码习惯,大多数人都能习惯性给每一个只要现在不变的类型写成 const T &
甚至加上 __restrict
吗?能在保证没有 exception 的时候习惯性加上 noexcept
吗?
这些都可能直接影响到性能,例如移动构造函数不加 noexcept
,各种 STL 容器直接给你改成调用拷贝构造,影响性能。
写这一篇文章大概就是为了感叹这样的问题。“在 unsafe 里就像 C 那样写就好”。然而 unsafe
并不是拿来给你在 Rust 的约束中找到一丝自由的 —— 它是允许经验丰富的人,在镣铐中跳舞,在遵守 Rust 的大量假设和不变式的同时,编写类型系统无法表达的那一部分——而你必须熟悉的是这一整套类型系统构筑起的约定、优化和安全边界。这是 Rust 的设计哲学。程序员不可信任,能在熟练掌握 unsafe 的终究是少数人。
如果你没准备好去承担它们,那你也许根本不该碰 unsafe。
脚注
-
由于这里不需要特别区分,下文的 C 语言或者 C++ 语言可能说的是这两者 ↩
-
David Chisnall. 2018. C Is Not a Low-level Language: Your computer is not a fast PDP-11. Queue 16, 2 (March-April 2018), 18–30. https://doi.org/10.1145/3212477.3212479 ↩
-
该论文中的 example 即本文开头的代码,但是数字有不同
↩fn example(x: &mut i32, y: &mut i32) -> i32 { *x = 13; *y = 42; return *x; }
-
Understanding and Evolving the Rust Programming Language ↩
-
returns a pointer that is suitably aligned for any object type with fundamental alignment. https://en.cppreference.com/w/c/memory/malloc ↩