我们最近将新功能集成到了在 Rust 中实现的 CrowdStrike Falcon 传感器中。 Rust 是一种相对年轻的语言,有几个专注于安全和保障的功能。从 C++ 调用 Rust 相对简单,但我们遇到的一个绊脚石是 Rust 如何处理内存不足 (OOM) 情况。让我们首先定义“内存不足”的含义:具体来说,我们的意思是底层分配器为尝试分配返回 NULL。您可能从未见过 malloc() 在实践中返回 NULL。在默认配置的 Linux 上,这几乎是不可能的,正如 malloc 的手册页所述:默认情况下,Linux 遵循乐观内存分配策略。这意味着当 malloc() 返回非 NULL 时,不能保证内存确实可用。如果发现系统内存不足,一个或多个进程将被 OOM 杀手杀死。如果系统内存不足,malloc 仍将返回一个非 NULL 指针,但随后 OOM 杀手将介入并通过 SIGKILL 开始终止进程。在这种配置中使用 Linux 可能会让人在处理分配错误方面产生一种错误的安全感。但是,世界上有很多系统并没有运行愿意像这样过量使用内存的系统,重要的是要知道如果分配尝试失败,您的应用程序将执行什么操作。 Rust 中的错误处理通常通过返回一个 Result(强制调用者在 API 级别以某种方式处理错误)或恐慌(它展开堆栈并主要用于不打算出现的错误)来涵盖可恢复的(例如,索引超出数组末尾或在某些前置或后置条件下使断言失败)。鉴于这些模式,人们可能会认为 OOM 事件会导致恐慌,但事实并非如此:今天,OOM 事件导致 Rust 立即终止进程而没有展开。对于不熟悉这个特定问题的人来说,这种行为可能会令人惊讶,我在内部看到并公开声明(例如,libcurl 的作者在他们对 Rust 作为可能的后端的调查中)。我绝对不打算诽谤 Rust 语言或团队。在 OOM 上中止的选择当然是有道理的,但批准的 RFC 的动机部分将可失败的分配添加到标准库集合中指出它作为长期解决方案是不够的:许多集合方法可能决定分配(推送,插入、extend、entry、reserve、with_capacity、...) 并且这些分配可能会失败。在 Rust 历史的早期,我们做出了一个政策决定,不在 API 级别公开这一事实,而是倾向于中止。这是因为大多数开发人员不准备处理它,或者不感兴趣。随意处理分配失败可能会导致许多从未测试过的代码路径,从而导致错误。我们称这种方法为可靠的集合分配,因为开发人员模型是分配不会失败。
不幸的是,这种立场在 Rust 设计的一些环境中是不可持续的。该 RFC 试图建立一个基本的易出错集合分配 API,它允许我们的用户在需要时处理分配失败。该 RFC 是宝贵背景和未来信息的金矿,我强烈建议您阅读全文。它继续概述了几个用例和解决它们的计划。我们的用例类似于 RFC 中描述的服务器用例。我们希望在执行多个任务的用户空间程序中运行 Rust 实现的组件,并且希望任务上的 OOM 事件仅导致该任务失败;其他任务(在同一进程中)应该继续畅通无阻。不幸的是,RFC 中描述的解决方案尚不可用,至少在稳定的 Rust 上是这样(有些,但不是全部,每晚可用)。此外,实现所有这些可能具有挑战性,尤其是将 OOM 更改为 panic 和 unwind 而不是 abort:标准库或已发布的 crate 中可能存在不安全的代码,假设分配永远不会失败,并且变得不健全(即引入未定义的行为)如果允许分配失败解除。如果今天想要处理 OOM 事件,在稳定的 Rust 上,选项包括:让 OOM 事件中止该过程。这是迄今为止最简单的选项,因为它不需要额外的工作:您可以使用完整的 Rust 语言、标准库和第三方 crate。我认为这可能是绝大多数应用程序的正确解决方案。任何健壮的系统都需要能够因外部原因(硬件故障、系统库崩溃、root 用户杀死进程)而重新启动。然而,对于网络安全公司来说,将 Rust 组件集成到更大的 C++ 程序中是一种苦果,其中 C++ 部分可以从 OOM 中恢复而 Rust 组件不能,并且它对我们拥有的自动化测试产生不利影响,正如我们看到由 Rust 组件内部的 OOM 事件引起的崩溃。切换到 no_std 环境。这通常用于微控制器工作,但它可以用于任何环境。它禁用了大量的 Rust 标准库,包括那些分配内存的库,并且还会限制哪些第三方 crate 可用。根据您已经编写了多少代码,这可能会非常昂贵,特别是如果您使用与 no_std 不兼容的重要 crate。使用上面 RFC 中概述的 try_* 方法将分配失败转换为可以在 API 级别处理的结果。目前(Rust 1.48),这些仍然不稳定,因此只能在夜间编译器上使用,但也有第三方 crate 使它们可用:fallible_collections 扩展了许多标准库类型以添加建议的 RFC 方法,例如,和 hashbrown(它是标准库 HashMap/HashSet 实现)在其 HashMap 和 HashSet 上公开了一个 try_reserve 方法。如果您已经引入了不支持 no_std 的第三方依赖项,则选项 2 可能会非常困难。本博客的其余部分将扩展选项 3。选项 3 的一个直接问题是,没有一种好的方法可以知道您已找到并更新了可能分配的每个呼叫站点。上面的 RFC 提到了对“某种系统来防止函数永远正确分配”的渴望,它提到可以通过某种 lint 来实现。今天没有这样的 lint 可用,所以我们试图通过使用一个自定义全局分配器测试我们的 Rust 组件来覆盖这个,该分配器有意注入 OOM 事件。
Rust 允许您用自己的实现替换全局分配器。通常,这用于在系统分配器和 jemalloc 之间切换,但我们也可以将它用于边缘恶意目的:我们将编写一个自定义全局分配器,该分配器有时会故意失败。当然,在实际应用程序中这将是一件可怕的事情,因此我们将其使用限制在单个测试中。让我们从编写一个简单的库开始,该库分配几种不同的方式。此示例是使用撰写本文时最新的稳定版 Rust 编写的(1.48.0)。 [~]% cargo new --lib oom-demo 创建库 `oom-demo` 包 [~]% cd oom-demo # ... 编辑 src/lib.rs ... [~/oom-demo]% cat src/ lib.rs 使用 std::collections::HashMap; #[derive(Debug, Default)] pub struct Counter { items: HashMap<u32, Vec<u32>>, } impl Counter { pub fn push_key_value(&mut self, key: u32, value: u32) { self.items.entry (key).or_default().push(value); } pub fn values_for_key(&self, key: u32) -> Option<&[u32]> { self.items.get(&key).map(Vec::as_slice) } } #[cfg(test)] mod tests { use极好的::*; #[测试] fn it_works() { let mut c = Counter::default(); c.push_key_value(0, 1); c.push_key_value(0, 2); c.push_key_value(5, 100); c.push_key_value(5, 4); assert_eq!(c.values_for_key(0).unwrap(), &[1, 2]); assert_eq!(c.values_for_key(5).unwrap(), &[100, 4]); assert_eq!(c.values_for_key(1), None);这是一个简单的小库,它为给定的键累积值,但它在几个不同的地方分配,所以应该足以演示 OOM 注入全局分配器。不出所料,全局分配器是全局的,这意味着使用自定义分配器运行测试需要小心:cargo 测试将运行多个测试线程(这对我们来说可能有问题),替换全局分配器会影响由测试框架本身进行的分配。我们将通过将我们的 OOM 注入测试放入它自己的测试文件中来解决多线程问题,其中只有一个 #[test] 。将我们的 OOM 注入限制为仅调用我们库中的站点有点棘手。我们将使用 AtomicBool 来启用/禁用 OOM 注入,并且仅在我们调用库时打开它(这是将其限制为单个线程的另一个原因!)。创建一个测试目录,并将其放入 tests/oom-injection.rs: use oom_demo::Counter;使用 std::alloc::{GlobalAlloc, Layout, System};使用 std::ptr;使用 std::sync::atomic::{AtomicBool, Ordering}; struct OomAllocator { enable_oom_injection: AtomicBool, } impl OomAllocator { fn enable_oom_injection(&self) { self.enable_oom_injection.store(true, Ordering::Relaxed); } fn disable_oom_injection(&self) { self.enable_oom_injection.store(false, Ordering::Relaxed); } fn is_oom_injection_enabled(&self) -> bool { self.enable_oom_injection.load(Ordering::Relaxed) } } unsafe impl GlobalAlloc for OomAllocator { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { if self.is_oom_injection_enabled( ) { // 启用 OOM 注入 - 返回 NULL return ptr::null_mut(); } else { // 无 OOM 注入 - 遵循系统分配器 System.alloc(layout) } } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { System.dealloc(ptr, layout) } } #[ global_allocator] static GLOBAL: OomAllocator = OomAllocator { enable_oom_injection: AtomicBool::new(false), }; // 注意:在这个文件中我们只有一个 #[test] 是很重要的 // 以避免我们的 OOM 注入 // 全局分配器和 `cargo test` 多线程之间的不良交互! #[test] fn run_demo_with_oom_injection() { // 现在,只需从 lib.rs 重复测试。让 mut c = Counter::default(); c.push_key_value(0, 1); c.push_key_value(0, 2); c.push_key_value(5, 100); c.push_key_value(5, 4); assert_eq!(c.values_for_key(0).unwrap(), &[1, 2]); assert_eq!(c.values_for_key(5).unwrap(), &[100, 4]); assert_eq!(c.values_for_key(1), None);我们为我们的库运行相同的单元测试 - 覆盖我们期望可能分配的所有代码路径很重要
但它仍然不完整。我们从未真正启用 OOM 注入,如果启用,它会在每次尝试分配时返回 NULL,这不会给我们太多覆盖。最重要的是,当我们注入分配失败时,我们不知道分配尝试的来源。改变“return ptr::null_mut();”可能很诱人陷入恐慌,但根据 GlobalAlloc 特性的文档,这是明确不允许的:如果全局分配器展开,则这是未定义的行为。将来可能会取消此限制,但目前任何这些功能的恐慌都可能导致内存不安全。有很多选项可以决定何时实际返回 NULL;最简单的方法是在一定百分比的时间内随机返回 NULL(启用 OOM 注入时)。如果在您的代码中命中特定分配调用站点的机会很低,那可能不合适。我们稍后会讨论一些选项,但对于这个例子,它应该足够了。当我们注入 OOM 时,我们还将拉入 backtrace crate 以记录我们在调用堆栈中的位置。将此添加到 Cargo.toml:@@ -1,8 +1,11 @@ +use backtrace::Backtrace;使用 oom_demo::Counter;使用 std::alloc::{GlobalAlloc, Layout, System};使用 std::ptr;使用 std::sync::atomic::{AtomicBool, Ordering}; +const OOM_INJECTION_PROBABILITY: f32 = 0.1; + struct OomAllocator { enable_oom_injection: AtomicBool, } @@ -22,8 +25,14 @@ impl OomAllocator { unsafe impl GlobalAlloc for OomAllocator { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { - if self. is_oom_injection_enabled() { - // 启用 OOM 注入 - 返回 NULL + if self.is_oom_injection_enabled() + && rand::random:: () < OOM_INJECTION_PROBABILITY + { + // 生成回溯将需要分配。 + // 生成时禁用 OOM 注入并 + // 打印它,然后重新启用它。如果有多个线程,这将表现得 + // 奇怪 + // 在我们运行此测试时进行分配! + self.disable_oom_injection(); + println!("从 {:?} 注入 OOM", + Backtrace::new()); + self.enable_oom_injection();返回 ptr::null_mut(); } else { // 无 OOM 注入 - 遵循系统分配器 @@ -42,12 +47,14 @@ static GLOBAL: OomAllocator = OomAllocator { enable_oom_injection: AtomicBool::new(false), }; #[test] fn run_demo_with_oom_injection() { - // 现在,只需从 lib.rs 重复测试。 + // 启用随机 OOM 注入;多次重复测试。 + GLOBAL.enable_oom_injection(); + for _ in 0..1_000 { let mut c = Counter::default(); c.push_key_value(0, 1); c.push_key_value(0, 2); @@ -56,5 +63,6 @@ fn run_demo_with_oom_injection() { assert_eq!(c.values_for_key(0).unwrap(), &[1, 2]); assert_eq!(c.values_for_key(5).unwrap(), &[100, 4]); assert_eq!(c.values_for_key(1), None); + } + GLOBAL.disable_oom_injection();我们在生成和打印回溯时暂时禁用 OOM 注入,回溯本身分配内存。这是对我们的分配器确实是全局的这一事实的让步:如果我们想从我们的分配器本身内部分配内存,我们最终会递归回到我们自己的分配方法中。我们运行测试 1,000 次。这对于这个演示和库来说肯定是矫枉过正。如果我们使用随机性来决定何时注入 OOM,我们希望将其设置得足够高,以确保我们将命中我们库中的所有尝试分配。现在,如果我们运行 cargo test -- --nocapture,我们应该会看到一个回溯,后面跟着类似的东西,尽管如果你按照下面的操作,你可能会看到不同的内存分配量:
这是进步!我们打印了我们在何处注入 OOM 的回溯,然后我们从 Rust 获得了它在中止之前打印的 spartan 日志消息。如果您重新运行几次,您可能会在日志消息中看到不同的内存分配量和不同的回溯,因为我们的库有两个不同的内存分配点。回溯很大且嘈杂,但请尝试键入 oom_demo::Counter::push_key_value 周围的帧。通过几次运行来解释随机性,您应该会看到这两个回溯子集: 14: std::collections::hash::map::HashMap ::entry at std/src/collections/hash/map.rs: 704:19 15: oom_demo::Counter::push_key_value at src/lib.rs:10:9 --- 14: alloc::vec::Vec ::push at alloc/src/vec.rs:1210:13 15 : oom_demo::Counter::push_key_value at src/lib.rs:10:9 这是我们库的两个分配调用点:一个是我们要求输入哈希映射(可能必须重新分配以腾出空间)新的键/值对),以及另一个我们尝试推入向量的地方。我们现在可以更新我们的库并使用 fallible_collections 和 hashbrown 以一种我们可以捕获分配错误的方式来增长我们的容器。将 fallible_collections = “0.3” 和 hashbrown = “0.9” 添加到我们库的依赖项中,然后对 src/lib.rs 进行以下更改:@@ -1,4 +1,5 @@ -use std::collections::HashMap; +使用 fallible_collections::FallibleVec; +使用 hashbrown::{HashMap, TryReserveError}; #[derive(Debug, Default)] pub struct Counter { @@ -6,8 +7,10 @@ pub struct Counter { } impl Counter { - pub fn push_key_value(&mut self, key: u32, value: u32) { - self.items.entry(key).or_default().push(value); + pub fn push_key_value(&mut self, key: u32, value: u32) + -> Result<(), TryReserveError> + { + // 为新键腾出空间 - 如果 + // `key` 已经存在,这是不必要的存在,但每次都这样做可能比通过查找`key`来保护它更便宜(因为它只会在+ //需要时增长)。 + // 在实际代码中,要确定配置文件! + self.items.try_reserve(1)?; + // `.entry()` 不应再在此处重新分配,并且 + // 我们将 .push() 替换为 .try_push() 作为向量 + self.items.entry(key).or_default().try_push(value )?; + Ok(()) } pub fn values_for_key(&self, key: u32) -> Option<&[u32]> { @@ -22,10 +25,10 @@ mod tests { #[test] fn it_works() { let mut c = Counter::default(); - c.push_key_value(0, 1); - c.push_key_value(0, 2); - c.push_key_value(5, 100); - c.push_key_value(5, 4); + c.push_key_value(0, 1).unwrap(); + c.push_key_value(0, 2).unwrap(); + c.push_key_value(5, 100).unwrap(); + c.push_key_value(5, 4).unwrap(); assert_eq!(c.values_for_key(0).unwrap(), &[1, 2]); assert_eq!(c.values_for_key(5).unwrap(), &[100, 4]); assert_eq!(c.values_for_key(1), None);我们还需要更新我们的 OOM 注入测试,因为函数返回类型的更改以及我们在测试中的断言可能无效:如果我们尝试推送新的键/值对但分配失败,那对不会被推动。对 tests/oom-injection.rs 进行以下更改:
@@ -61,12 +61,10 @@ fn run_demo_with_oom_injection() { GLOBAL.enable_oom_injection(); for _ in 0..1_000 { let mut c = Counter::default(); - c.push_key_value(0, 1); - c.push_key_value(0, 2); - c.push_key_value(5, 100); - c.push_key_value(5, 4); - assert_eq!(c.values_for_key(0).unwrap(), &[1, 2]); - assert_eq!(c.values_for_key(5).unwrap(), &[100, 4]); + 让 _ = c.push_key_value(0, 1); + 让 _ = c.push_key_value(0, 2); + 让 _ = c.push_key_value(5, 100); + 让 _ = c.push_key_value(5, 4); assert_eq!(c.values_for_key(1), None); GLOBAL.disable_oom_injection();我们现在应该能够运行货物测试并看到单元测试和 OOM 注入测试都通过了。我们的库将不再因 OOM 事件中止,而是返回错误。这篇博客介绍了一种在不中止过程的情况下调整库以处理 OOM 事件的可能方法。一般来说,我不建议对库进行这种处理,因为它有多种实际成本:分配检查会产生运行时成本。您需要注意如何将分配失败转换为错误。例如,在循环中调用 try_push 可能是一个糟糕的主意,而您应该在循环之前尝试 try_reserve。这是您通常不需要考虑的额外问题。随着时间的推移,API 和实现复杂性成本会影响维护。如果合适,您可以通过恐慌而不是返回结果来抵消这一点,尽管要注意恐慌本身会分配内存。未来可能会有更好的解决方案——例如,在 Rust 的未来版本中完全支持对 OOM 的恐慌。我想尽量减少我们的变通方法,希望尽快切换到语言支持的技术。但是,如果您权衡成本并决定今天尝试在稳定的 Rust 中处理 OOM 事件是值得的,那么希望这篇博客能给您一些想法。如果您想执行上面概述的 OOM 注入全局分配器测试计划,请考虑几乎肯定有更有用的方法来决定何时注入 OOM 事件:您可以计算分配以试图覆盖每一个,您可以扫描回溯
......