最近 Cloudflare 的故障复盘又掀起了一波对 Rust 的讨论。在这次大规模服务中断的故障中,一行 Rust 代码承担了重要的一环,成为了压死骆驼的最后一根稻草。

由于输入数据量激增,远超出了之前预期的数值,原本开发者认为永远不会出问题的 unwrap 调用 panic 了。
很多人质疑 Rust 不是宣称内存安全吗,为什么还是会导致这么严重的线上问题。甚至有人说,Rust 增加的使用负担远超过了它带来的实际收益,Rust 根本不如 Go、C++ 实用。事实真的是这样吗?
什么是内存安全
当我们在讨论内存安全时,究竟是在讨论什么?内存泄漏算内存安全范畴吗?OOM 算吗?空指针异常算吗?
这些在我看来都不算内存安全问题,因为处理得当都不会产生意料之外的结果,这里我们逐个分析一下。
内存泄漏
从定义上来说,内存泄漏是指某块内存在程序后续生命周期中永远不再被释放。那么它显而易见的后果就是程序内存可能持续上升(如有问题的逻辑被反复执行),并进而导致内存耗尽。但内存泄漏的危害也仅限于此了,它并不会导致其他正在使用的内存区域被篡改,因此也不会影响到程序正常的逻辑。所以从分类上来讲,内存泄漏应该算是一个资源管理问题。
Rust 能避免内存泄漏吗?很遗憾,不能。因为标准库里有大把的 API 和用法会很轻松地制造内存泄漏:
Box::leak:一个故意泄漏堆内存的方法std::mem::forget:移动一个值但并不析构它,对Box、Vec等内部管理内存的类型操作会造成泄漏Rc、Arc:循环引用会导致引用计数永远不归零,如果成为孤岛则被视为泄漏
上述方法对于 Rust 来说都是 safe 的,内存泄漏是会造成资源利用率降低,但并不会产生致命 bug 和安全问题。
OOM
上面说到内存泄漏最终可能演变为 OOM,但 OOM 为什么也不算内存安全问题?其实这里要分情况讨论。
在 Rust 中,标准库默认的 OOM 行为是 panic^。Panic 行为根据编译配置可能会 unwind 或直接 abort,通常来说都会立即停止程序,以防更严重的后果发生。
很多现代编程语言对 OOM 都有类似的处理,例如 Swift 和 Go 都会直接 abort,无任何恢复手段。在我看来不提供恢复手段也是一种不错的选择,毕竟一般开发者在面对 OOM 时可以回旋的余地并不多,直接崩溃可以更早暴露问题。而 C++(标准库)和 Java 在 OOM 时会抛出异常,如何处理取决于开发者,但经典的 try-catch-print 做法也有将问题隐藏起来的风险。
而如果你使用的是 C,malloc 默认的 OOM 行为是直接返回 NULL,如果处理不当就会变成一个野指针问题,风险最高。
空指针异常
严格来说,在很多不常用指针的语言中,我们应该用空值异常或者空引用异常来表述。真正的空指针在 C、C++ 中是未定义行为,属于内存安全的范畴。而在使用 Swift、Rust 和 Java 等语言时,编译器或运行时都可以保证访问空值或空引用不会产生未定义行为。
例如在 Swift 中,一个对象可以使用 unowned 修饰,表示这个变量不参与其指向对象引用计数的增减。它的实际作用与 Objective-C 中的 unsafe_unretained 一样,都可以防止我们在循环持有对象时产生内存泄漏。但假设一个 unsafe_unretained 变量指向的对象释放了,我们再去访问这个变量其实就是在访问迷途指针了,这种未定义行为可能会造成严重的后果。而 Swift 的 unowned 变量可以在我们访问已释放对象时立即产生一个 fatal error,直接终止程序,因此 Swift 在这方面的表现也是安全的。
野指针
现在我们来说说这个真正的内存安全问题。野指针指向的是一个非法的地址,可能是其他人正在使用的空间,也可能是内核不允许访问的空间。野指针最大的危害是其隐蔽性,当你释放了一块内存,这块内存的页面往往还没有被操作系统回收,此时读写这个指针不会有任何的异常状况出现。如果内存分配活动不频繁,这个野指针甚至可以让程序“正常运行”很长时间。
但野指针指向的地址终究会被使用,因为内存分配器认为这个地址是空闲的。此时再使用这个野指针,就会导致数据损坏了。所幸现在大部分 malloc 实现都可以通过 cookie 值来检测野指针访问的行为,尽可能早地将程序 abort 掉,但仍做不到实时检测(假设你很长时间都没有进行 malloc / free)。
而在 Swift 或 Rust 中,直接的指针操作是 unsafe 代码,我们无法不经意写出野指针问题。而 Rust 通过所有权系统,可以进一步防止竞态和数据不一致的问题。考虑以下的代码:
fn evaluate(&mut self) {
for feature in &self.features {
self.process_feature(feature);
logger::debug!("feature processed: {feature}");
}
}
fn process_feature(&mut self, feature: &str) {
// ...
}能够看出其中潜在的问题吗?这段代码在 Rust 中无法通过编译,因为我们在访问 features 变量时可能会修改 self。假设我们日后不小心在 process_feature 方法里加入了什么操作 features 变量的代码,就有可能导致我们正在循环中访问的 feature 引用变成野指针,而编译器可以保证这种情况永远不会发生。
在 C++ 中,相同的代码不会触发任何编译错误,那我们就可能会很不幸地写出野指针问题。而这些问题如果被恶意利用,就很可能会变成一个 CVE。
可用性 vs 正确性
我们常说没有银弹,不同的场景适用不同的工具。Rust 偏好 panic 的哲学是正确性大于一切,可以因此牺牲可用性。当程序产生 panic 时,说明程序当前的状况是程序员没有预料到的,或者预料到但暂时不想处理的。那么当状况产生时,我们就需要及时止损,以免风险安静地进一步升级。
应该没有语言会选择牺牲正确性来换可用性的设计路线,但不同语言在保证正确性这件事上确实投入的不一样。例如 Objective-C 中对一个 nil 指针调用方法不会产生异常或未定义行为,就是可用性和正确性权衡的结果。如果直接 abort,那么就不能在不影响当前代码的情况下保证可用性。而如果产生未定义行为,又完全牺牲了正确性。静默处理就变成了一个折中的方案,它不算好,但也不完全坏。
在金融、交通和基建等领域,正确性是至关重要的事情。不妨思考下面几个问题:
- 当银行系统存在一个 bug 时,你希望它是转账功能宕机一天还是账户余额凭空蒸发?
- 当 FSD 存在一个 bug 时,你希望它是忽然让你接管还是急打方向撞上护栏?
- 当数据库存在一个 bug 时,你希望它是让你的网站 500 几个小时还是出现数据丢失?
Rust 无法保证你的代码不存在 bug,毕竟你到底想求和还是算平均数,编译器根本无法得知。它能做的只有尽可能减少程序员意料之外的问题,让你可以集中精力在业务逻辑上。
当开发一些不那么严肃的程序时,如果不愿意,你完全没必要使用 Rust。用 C++ 写一款单机游戏,出再大的 bug,天不会塌,最多就是让玩家多给你几个差评。可用性和正确性在开发效率面前,可能不值一提,你要考虑的是在资金花完之前赶快把游戏搞上架。
复杂度不会凭空消失
在 Swift 6 刚推出时,有很多人抱怨 Apple 把一门简单的语言搞得越来越复杂,Swift 💊
但很不幸的是,有些东西它就是这么复杂。在软件工程中有个概念叫本质复杂度(essential complexity),它指的是抛开外界干扰,解决一个问题需要做的最基本的事情。如果你要开发一个 app,不管用什么框架,最终都要完成那些功能。
编程语言、框架和工具解决的都是不必要复杂度(accidental complexity),有的工具减少不必要复杂度,有的工具会带来其他的不必要复杂度。Rust 和 Swift 6 中的一些新特性解决了内存问题、线程问题不好排查的不必要复杂度,但暴露了这些问题的本质复杂度。
用 Swift 6 中的 isolation 特性举例,它让我们在调用一些 MainActor 代码时必须保证上下文环境正确。Sendable 特性的引入,让我们原本的多线程代码直接无法编译。但实际上,我们只是在平时根本不关心线程安全的问题,而潜在的问题也没能造成严重的后果。
再看下面这段伪代码:
fn purchase_product(user: UserId, sku: SkuId) -> Result<OrderId> {
let price = ProductService::lookup_price(sku);
if StockService::remaining_stocks(sku) == 0 {
bail!("no stocks");
}
if !BankService::deduct(user, price) {
bail!("insufficient balance");
}
Ok(StockService::schedule_shipment(user, sku))
}如果这是单机运行的服务,那么逻辑看起来并没有什么问题。但如果代码是并发执行的,即便是使用了 Rust,这段代码也仍存在非常多的问题。Rust 解决了不当的多线程操作可能会导致的内存问题,但业务逻辑或者说订单并发处理的本质复杂度仍然存在。防止超卖是一个业务问题,它很难在语言、框架层面解决。
因此,面对必然存在的复杂度,我更希望有手段将它们提前暴露出来,而不是在未来以意外复杂度出现。易用、性能、安全是编程语言中的一个不可能三角,我们必须根据场景和自身情况舍弃其中一项。
重视测试的价值
回到 Cloudflare 的这次故障上,代码层面的问题真的没办法杜绝吗?如果我们参考 SQLite 的测试方法,或许就真的可以避免这些问题了。虽然在很多项目中使用这套方法并不实际,但至少可以给我们一些启发。
100% 分支覆盖
do_something().unwrap() 是一行代码,但即便是 100% 代码行覆盖也并不能保证这行代码的健壮性。因为在这行代码中,隐含了两个分支 —— Ok 和 Err。我们的所有 test cases 可能命中的都是 Ok 这个分支,那我们就忽视了某些会导致 Err 的场景。
但如何测试异常分支呢?的确,某些异常在实际环境中很难出现,例如 malloc 失败。SQLite 的做法是自定义 malloc 实现,在测试环境中故意注入错误来测试异常处理的代码。
如果某些分支确实只是防御性代码,SQLite 会通过宏定义,在覆盖率测试时将取值直接固定,从而移除编译产物中的防御性分支。
100% MC/DC 覆盖
MD/DC 是“Modified Condition/Decision Coverage”的缩写,指不同输入对结果的影响。不妨观察下面这段代码:
if( (mask & (SQLITE_OPEN_MAIN_DB|SQLITE_OPEN_TEMP_DB))!=0 ){ ... }可以发现只要测试 mask 包含 SQLITE_OPEN_MAIN_DB 和 mask 为空两种场景,即可完成 100% 分支覆盖,if 的两个分支都能测试到。但这就足够了吗?假设所有的 test cases 中都没有检测过 SQLITE_OPEN_TEMP_DB 的情况,那如何保证它可以正常工作?
SQLite 在这里通过 testcase 宏来强制覆盖一些额外的场景,像这种位运算的操作,我们可以观测所有的比特位排列是否都被测试过。
综上,通过这些测试手段,我们不仅可以在重构老代码时更有信心,还可以进一步确保新代码本身的健壮性。
写在最后
其实截至目前,我们也不清楚 Cloudflare 那行代码到底为什么会失败。也许它根本与内存问题无关,也许前面的 append_with_names 方法就是人为返回了 Err,也许这一切与 Rust 也没有任何关系…
不过借助这个事件再次把内存安全的问题拿出来说说也挺好的,至少能够温故而知新。