《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 中,内存分配是一种明确的选择,而非被动的接受

  • 初学者使用 StringVec

  • 熟练者使用 &str、`&T]with_capacity`。

  • 专家则会根据具体场景,灵活运用 clear() 复用、smallvec 进行栈优化,以及 Cow 来避免不必要的“拥有”。

你的目标是让代码的“热路径”(Hot Path)中没有 allocrealloc。Rust 的借用检查器和丰富的类型系统(Cow, SmallVec)是你实现这一目标的、最强大的盟友。


Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐