《Rust 性能圣经:从源头扼杀内存分配的专家策略》
《Rust 性能圣经:从源头扼杀内存分配的专家策略》
作者:Rust 技术专家
一、 策略一:借用,而非拥有(Slices over String/Vec)
这是 Rust 最核心的性能哲学,也是新手最容易忽视的一点。
【解读】
在 C++ 或 Java 中,如果你需要一个“子字符串”,你很可能会调用一个 substring() 方法,它会分配一块新内存并拷贝数据。在 Rust 中,这是彻头彻尾的“反模式”(Anti-pattern)。
Rust 提供了“切片”(Slices)——&str 和 &[T]。切片是一个“视图”(View)或“借用”(Borrow),它只包含一个指向原始数据的指针和一个长度。创建切片是一个 O(1) 操作,它不产生任何堆分配。
【实践】
假设你需要一个函数,它接受一个字符串并返回返回第一个单词。
反模式(产生分配):
// 返回了一个新的、在堆上分配的 String
fn get_first_word_allocating(s: &String) -> String {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return s[..i].to_string(); // <-- 昂贵的分配和拷贝
}
}
s.to_string() // <-- 昂贵的分配和拷贝
}
**专家策略(零分配):
// 返回了一个 &str 切片,它只是指向原 String 的一个视图
// 注意:参数也应该是 &str,以增加灵活性
fn get_first_word_zero_alloc(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[..i]; // <-- 零分配,只是创建了一个指针和长度
}
}
&s[..] // <-- 零分配
}
【专业思考】
借用检查器(Borrow Checker)是你的性能伙伴。它在编译时就严格保证了 &str 切片不会比它所指向的原始 String 活得更久(防止悬垂指针)。这种安全保证使得“零分配视图”成为可能和默认选项。
**永远问自己:我真的需要“拥有”这些数据吗?还是“视图”就足够了?**
二、 策略二:复用缓冲区(clear & with_capacity)
**读】**
当你使用 Vec::new() 或 String::new() 时,你得到的是一个在栈上的结构体,其内部指针为 null,容量为 0。当你第一次 push 时,它会触发第一次堆分配。当它填满后(例如 4 个元素),下一次 push 会触发再分配:它会请求一块更大(通常是 2 倍)的内存,把所有旧数据拷贝过去,然后释放旧内存。
这个“增长-拷贝-释放”的循环是性能的灾难。
【实践】
1. 预分配容量 (with_capacity)
如果你大概知道需要多少空间,请一次性分配好。
// 反模式:可能触发 5-10 次(取决于 N)再分配
let n = 1000;
let mut vec = Vec::new();
for i in 0..n {
vec.push(i);
}
// 专家策略:一次分配,零再分配
let n = 1000;
let mut vec = Vec::with_capacity(n); // <-- 提前声明
for i in 0..n {
vec.push(i); // <-- 循环体内没有分配
}
2. 复用缓冲区 (clear)
在热循环中(例如游戏循环或 Web 服务器的请求处理器),最大的浪费是丢弃一个 Vec 再创建一个新的。
// 反模式:每次循环都分配和释放
fn handle_request_allocating(request: &Request) -> Vec<u8> {
let mut buffer = Vec::new(); // <-- 每次都分配
buffer.extend_from_slice(&request.header);
buffer.extend_from_slice(&request.body);
buffer // 返回
// 'buffer' 在这里被丢弃,内存被释放
}
// 专家策略:在循环外创建,在循环内复用
let mut reusable_buffer = Vec::with_capacity(1024); // <-- 在循环外创建一次
loop {
let request = get_next_request();
// 使用 'reusable_buffer' 处理请求
reusable_buffer.clear(); // <-- 关键:清空,但不释放内存
reusable_buffer.extend_from_slice(&request.header);
reusable_buffer.extend_from_slice(&request.body);
send_response(&reusable_buffer);
}
【专业思考】
clear() 是一个 O(1) 操作。它只是将 vec.len 重置为 0,但 vec.capacity 保持不变。这使得你的热循环在“预热”一次后,可以达到零分配的吞吐量。
三、 策略三:在栈上安家(SSO & smallvec)
【解读】
对于那些“绝大多数情况很小,但偶尔可能很大”的数据,每次都进行堆分配是极大的浪费。例如,一个函数返回的错误信息列表,通常只有 1 或 2 个。
栈分配几乎是“免费”的(只是移动栈顶指针)。我们希望尽可能在栈上完成工作。
【实践】
这就是“小尺寸优化”(Small Size Optimization, SSO)的用武之地。Rust 核心库的 String 没有 SSO,但我们可以使用 smallvec 这个 crate。
`SmallVec<[T; N]>一个智能的 Vec。它在结构体内部(即在栈上)内联了一个大小为 N 的数组。
-
当你
push第 1 到 N 个元素时,它们被直接存入这个内联数组。没有堆分配。 -
当你
push第 N+1 个元素时,它才会“溢出”(Spill),触发一次堆分配,并将所有数据移到堆上,后续行为和普通Vec一样。
use smallvec::{smallvec, SmallVec};
// 假设我们处理用户输入,绝大多数情况下,分割后的 "words" 不会超过 16 个
fn process_input(input: &str) -> SmallVec<[&str; 16]> {
let mut words = SmallVec::new(); // <-- 在栈上分配了 16 个 &str 的空间
for word in input.split_whitespace() {
words.push(word); // <-- 只要 'words.len() <= 16',这里就没有堆分配
}
// 如果 input 有 20 个单词,只有第 17 次 push 会触发一次堆分配
words
}
【专业思考】
smallvec 是一个典型的 Rust 专家工具。它让你能够根据业务场景的统计数据(“大多数情况 N 很小”)来选择最佳的数据结构,在不牺牲功能的前提下,将“常见路径”(Common Path)的性能优化到极致。
四、 策略四:写时复制 (Cow<T>)
**【】**
Cow<T> (Clone-on-Write) 是一个非常聪明的枚举,它代表“借用的”(Borrowed)或“拥有的”(Owned)数据。
它解决了一个常见问题:你有一个函数,它接受一些数据。在某些情况下,你需要修改这些数据并返回修改后的版本;但在大多数情况下,你只是原样返回它们。
【实践】
想象一个“反转义”函数,它处理一个字符串,如果找到转义符(如 \\),就将其替换为(\)。
反模式:
// 无论是否需要反转义,它总是分配一个新的 String
fn unescape_allocating(s: &str) -> String {
let mut new_s = String::with_capacity(s.len()); // <-- 立即分配
// ... 复杂的
// ... 逻辑 ...
if !found_escape {
return s.to_string(); // 浪费了 'new_s',又分配了一次
}
new_s
}
专家策略 (Cow):
use std::borrow::Cow;
fn unescape_cow(s: &str) -> Cow<'_, str> {
// 1. 第一次遍历,只检查,不分配
match s.find('\\') {
None => {
// 没有找到'\',什么也不做。返回一个借用。
Cow:::Borrowed(s) // <-- 零分配
}
Some(first_escape_index) => {
// 2. 真的要修改,现在才分配
let mut new_s = String::with_capacity(s.len());
new_s.push_str(&s[..first_escape_index]); // 拷贝第一部分
// ... 在 'first_escape_index' 之后继续处理 ...
// (省略复杂逻辑)
Cow::Owned(new_s) // <-- 只有在必要时才分配一次
}
}
}
【专业思考】
Cow 完美体现了 Rust 的“按需付费”(Pay-for-what-you-use)原则。它将“读”路径和“写”路径的开销完全分离开。在“读”路径(最常见的路径)上,你实现了零分配。
结论
在 Rust 中,内存分配是一种明确的选择,而非被动的接受。
-
初学者使用
String和Vec。 -
熟练者使用
&str、`&T]和with_capacity`。 -
专家则会根据具体场景,灵活运用
clear()复用、smallvec进行栈优化,以及Cow来避免不必要的“拥有”。
你的目标是让代码的“热路径”(Hot Path)中没有 alloc 和 realloc。Rust 的借用检查器和丰富的类型系统(Cow, SmallVec)是你实现这一目标的、最强大的盟友。
更多推荐


所有评论(0)