PL 观点 | 未定义行为也有好的一面

是亦彼也,彼亦是也,彼亦一是非,此亦一是非。——《庄子•内篇》

如果你把所有的错误都关在门外时,真理也要被关在门外面了——泰戈尔

引子

Rust 官方团队 Ralf Jung 在 PL 观点 (PL Perspectives) 博客 上发表了一篇文章 《Undefined Behavior deserves a better reputation》 ,文中对 UB(未定义行为)有利的一面进行了详细的阐述。通过这篇文章,我们可以对 UB 有更深入的理解。

PLAI是计算机科学的两大学科分支。其中 PL (Programming Languages) 可以理解为是概括“程序设计语言自理论体系到其实现系统”的一个总称。

PL PerspectivesACM SIGPLAN(编程语言特别兴趣小组)的博客。SIGPLAN 的成员对编程语言概念和工具感兴趣,重点关注 PL 设计、实现、实践和理论的主题,或者 PL 思想和技术在其他领域的应用。

Ralf Jung 工作于马克斯普朗克软件系统研究所,也是Rust 官方团队成员之一,他研究 Rust 安全性 形式化验证系统 RustBelt 的论文获得了 2020 年 ACM 博士论文荣誉提名奖。

本文是我学习这篇文章的笔记,内容是围绕 UB以及 Ralf 的文章进行的二次创作,仅供参考。

什么是未定义行为

在计算机程序设计中,未定义行为(英语:undefined behavior)是指执行某种计算机代码所产生的结果,这种代码在当前程序状态下的行为在其所使用的语言标准中没有规定。常见于翻译器对源代码存在某些假设,而执行时这些假设不成立的情况。

一些编程语言中,某些情况下存在未定义行为,以CC++最为著名。在这些语言的标准中,规定某些操作的语义是未定义的,典型的例子就是程序错误的情况,比如越界访问数组元素。标准允许语言的具体实现做这样的假设:只要是符合标准的程序代码,就不会出现任何类似的行为。具体到 C/C++ 中,编译器可以选择性地给出相应的诊断信息,但没有对此的强制要求:针对未定义行为,语言实现作出任何反应都是正确的,类似于数字逻辑中的无关项。虽然编译器实现可能会针对未定义行为给出诊断信息,但保证编写的代码中不引发未定义行为是程序员自己的责任。这种假设的成立,通常可以让编译器对代码作出更多优化,同时也便于做更多的编译期检查和静态程序分析。

有时候也可能存在对于未定义行为本身的限制性要求。例如,在CPU的指令集说明中可能将某些形式的指令定为未定义,但如果该CPU支持内存保护,说明中很可能会还会包含一条兜底的规则,要求任何用户态的指令都不会让操作系统的安全性受损;这样一来,在执行未定义行为的指令时,就允许CPU破坏用户寄存器,但不允许发生诸如切换到监控模式的操作。

和未指定行为(unspecified behavior)不同,未定义行为强调基于不可移植或错误的程序构造,或使用错误的数据。一个符合标准的实现可以在假定未定义行为永远不发生(除了显式使用不严格遵守标准的扩展)的基础上进行优化,可能导致原本存在未定义行为(例如有符号数溢出)的程序经过优化后显示出更加明显的错误(例如死循环)。因此,这种未定义行为一般应被视为bug

来自《维基百科-未定义行为》

Rust: Unsafe vs Undefined

关于 Unsafe Rust 相关术语解释可以参考 《Rust 安全编码规范: Unsafe Rust 编码术语指南》

Rust 里的未定义行为

程序员承诺,代码不会出现未定义行为。作为回报,编译器承诺以这样的方式编译代码:最终程序在实际硬件上的表现与源程序根据Rust抽象机的表现相同。如果发现程序确实有未定义的行为,那么程序员和编译器之间的契约就无效了,编译器产生的程序基本上是垃圾(特别是,它不受任何规范的约束;程序甚至不一定是格式良好的可执行代码)。

未定义行为列表:

  • 数据竞争。
  • 解引用悬空指针或者是未对齐指针
  • 打破指针别名规则(引用生命周期不能长于其引用的对象,可变引用不能被别名)。
  • 使用错误的 调用 ABI
  • 执行使用当前执行线程不支持的目标特性(target features)编译的代码
  • 产生无效的值
    • 01 表达的 bool
    • 具有无效判别式的 枚举
    • [0x0, 0xD7FF] [0xE000, 0x10FFFF] 范围之外的 字符
    • 来自于未初始化内存的整数、浮点数、指针读取或字符串
    • 悬垂引用或 Box
    • 宽引用、Box 或 裸指针有无效的元数据
      • dyn Trait 如果元数据不是指向, Trait 与指针或引用指向的实际动态 trait 匹配的 vtable,的指针,则元数据无效
      • 如果长度无效,则切片数据无效
    • 具有自定义无效值的类型,比如 NonNull

Unsafe 不等于 未定义行为

Unsafe 仅意味着避免未定义的行为是程序员的责任。 Rust 程序员在编写代码过程中要确保不要触发未定义行为。

然而,Unsafe 的内涵更加广泛: 所有在 Rust 中产生未定义行为的代码是 Unsafe 的,但并非所有 Unsafe 的代码都会产生 未定义行为。

比如我们解引用裸指针,就必须要放到 unsafe 块中,但并不意味着,解引用裸指针就一定会产生未定义行为。Unsafe 的存在,是让开发者可以划分出 Safe 和 Unsafe 的区域,意味着 “不需要担心安全的区域” 和 “需要担心安全的区域(雷区)”,这样划分使得我们的代码更加安全和有保证。

而 Safe Rust 的含义,则是指不使用 Unsafe 块的情况下,编译器能保证程序的 健全性(Soundness),它不会产生未定义行为。

PL 观点: UB 有利的一面

按以往的观点, UB 通常都是有害的。但是今天 Ralf 告诉我们,从 PL 的视角看,UB 也有有利的一面。

Ralf 认为, UB 是编程语言设计者工具箱中一个有价值的工具。因为 UB 可以看作是程序员向编译器传达的其自身对代码的理解,以此可以帮助编译器实现更多优化。

思考一段代码

参考下面代码:


#![allow(unused)]
fn main() {
fn mid(data: &[i32]) -> Option {
    if data.is_empty() { return None; }
    return Some(data[data.len()/2]);
}
}

假如该代码在一个循环中被调用,其性能就会变得相对重要。对于该函数,能否实现性能改进呢?

上面代码中,包含一些隐藏成本:编译器会插入一个边界检查,以确保访问的数据不会超过数据所指向数组的大小。

但是作为程序员,我们知道这个检查完全没有必要。因为 data.len()/2 总是会小于 data.len()

如果有一种方法,可以让程序员告诉编译器这里不需要插入边界检查,是不是更好?


#![allow(unused)]
fn main() {
fn mid(data: &[i32]) -> Option {
    if data.is_empty() { return None; }
    match data.get(data.len()/2) {
        Some(&x) => return Some(x),
        None => unsafe { unreachable_unchecked() }
    }
}
}

现在使用get操作来访问数组,它返回一个Option,对于越界访问来说是None。如果我们得到的是None,则会调用一个特殊的函数unreachable_unchecked,它向编译器承诺这段代码是不可访问的。

这里的关键字 unsafe 表示我们正在做的事情不在语言的类型安全保证范围内:编译器实际上不会检查我们的承诺是否成立,它只是相信我们。

unchecked 这个短语是 Rust 的惯用语,这里是unreachableunchecked版本,它插入了一个运行时检查,如果达到这段代码,就会安全地中止程序。或者,更准确地说,它会触发 Rust 的 恐慌(Panic)。

经过一些内联,这段代码相关部分看起来像这样:


#![allow(unused)]
fn main() {
let idx = data.len()/2;
if idx < data.len() { // 自动插入边界检查
    ... // Access the array at idx.
} else {
    unreachable_unchecked()
}
}

由于我们告诉编译器else分支是不可达的,所以很容易优化掉这个条件,所以我们最后只需要直接访问数组中的idx元素。

事实上,Rust提供了get_unchecked作为get的替代方法,调用者必须保证索引在界内,所以Rust的程序员只需要写data.get_unchecked(data.len()/2)就可以有效地实现上面的mid函数。

UB 是把双刃剑

上面好像没谈到 UB,但其实只是 Ralf 偷换了术语而已。

可以查看标准库文档 std::hint::unreachable_unchecked 的介绍:hint 模块中包含了提示编译器进行优化的一些方法, unreachable_unchecked 就是其中之一。如果你滥用它,比如上面示例代码中的 else 其实是程序可达的路径,那么编译器对此的优化就会让其导致未定义行为。

所以,需要明白,编译器并不是真的知道这段代码是否有未定义行为,它只是在假设没有未定义行为的情况下进行优化。

unreachable_unchecked 本身是一种 UB 行为 ,不建议随便使用。这里使用它只是 Ralf 为了说明程序员如何使用它来向编译器传达额外的信息。但如果使用不当,也会产生 UB

再比如, Rust 里提供了一个 unchecked_add 函数。

在其他语言中,一个看起来无辜的加法操作+变成了程序员的承诺,即,程序员要保证这个加法永远不会溢出,但程序员可能不会为他们程序中的每一个加法都仔细做一个无溢出证明。Rust 中对加法操作会有溢出检查。而通过 unchecked_add函数,来告诉程序员,使用它可以在不可能有溢出的场景下,来省略一些检查成本。

这其实是一个 语言设计的问题: UB 是一把双刃剑,使用得当,可以很好地完成工作,而使用不当,会造成很大伤害。

UB 的未来

Rust 从 C/Cpp 的数十年 UB经验中学习到了很多。这方面最典型的一个例子就是可变引用使用不正确的别名有关的 UB

Rust 的类型系统可以确保可变引用永远不会与程序中正在使用的其他引用发生别名,这意味着,它们永远不会指向与其他引用相同的内存。然而 Unsafe Rust 可以很容易地打破这种保证,即,为一个可变引用创建别名。

对此,我们能做什么呢?来看一段代码:


#![allow(unused)]
fn main() {
let x = &mut 42;                // 安全地创建一个引用。
let xptr = x as *mut i32;       // 把这个引用变成一个原始(未检查的)指针。
let x1 = unsafe { &mut *xptr }; // 将指针转为引用...
let x2 = unsafe { &mut *xptr }; // ...两次,所以违反了唯一性。
*x1 = 0; // 未定义行为!
}

这段代码有 UB 的原因不难看出来,通过裸指针创建了两个可变借用互为别名。

在这里我们能否期望程序员可以通过心智内化这个别名规则,从而承诺他们会在写代码的时候来保证这套规则? 显然是不可能的,否则 C/Cpp 就不会有那么多 UB 了。 但是我们可以通过提供工具来帮助程序员: Miri

Miri 包含了一个 Stacked Borrows 模型来检查上面示例中那种非法别名。这也是 Ralf 在他的博士论文中提出来的。

Stacked Borrows不是Rust规范的一部分,也不是Rust中与别名相关的UB的最终版本。因此,未来仍有可能对这个模型进行修订,以更好地与程序员的直觉保持一致。上面的代码可能会被接受,因为x2实际上没有被用来访问内存。或者,也许&mut expr只有在unsafe 块之外使用时才应该做出这样的承诺。但那样的话,添加Unsafe 的东西真的应该改变程序的语义吗?像往常一样,语言设计是一个权衡的游戏。

小结

Ralf 的观点总结如下:

  1. UB 是语言设计者工具箱中的一个有用的工具。
  2. 语言设计者应该承认优化器有其局限性,并给程序员提供他们需要的工具来帮助优化器。
  3. Unsafe不是一个错误;它是一个特性,没有它,Rust 就无法在实践中使系统编程更加安全。
  4. 提议:"未定义行为 "可能需要重新命名。这个术语关注的是负面情况,而作为程序员或编译器作者,我们真正关心的是程序没有未定义行为。我们能摆脱这种双重否定吗?也许我们应该谈论 "确保定义良好的行为 "而不是 "避免未定义行为"。
  5. 大多数时候,确保定义良好的行为是类型系统的责任,但作为语言设计者,我们不应该排除与程序员分担这一责任的想法。

作为 Rust 语言使用者,通过 Ralf 这篇文章来了解 Rust 语言设计者如何看待 UnsafeUB ,对我们理解 Rust 语言也许更有好处,至少对我是这样。

感谢阅读。