Rust 性能陷阱:那些看起来很优雅但很慢的写法(下)

39 阅读6分钟

Rust 性能陷阱:那些看起来很优雅但很慢的写法(下)

这是《Rust 性能陷阱:那些看起来很优雅但很慢的写法》的下半部分,接下来我们将讲完剩余的部分内容。

过度使用 Arc<Mutex>

在 Rust 并发编程中,当需要共享可变状态时,很多人的第一反应是使用 Arc<Mutex<T>>

use std::sync::{Arc, Mutex};

let shared_data = Arc::new(Mutex::new(Vec::new()));

但实际上,Arc<Mutex<T>> 并不是银弹,我们其实是需要根据实际场景来决定如何使用锁的,比如:

  • 读多写少的场景下,使用 RwLock 替代 Mutex,性能要好得多;
  • 对较大数据的场景下,要减少锁竞争,使用分片(sharding)将共享数据分成多个分片,每个分片用一个独立的 Mutex 保护;
  • 使用 lock-free 数据结构替代锁,可以使用 crossbeam crate 中的 Queue、Stack 等,能实现安全的并发访问,性能更高;
  • 通过传递消息的方式避免共享可变状态,更符合 Rust 共享不可变,可变不共享的理念,可以使用 Rust 标准库的 channel。

async/await 的滥用

async/await 是 Rust 异步编程的核心语法,能让异步代码写起来和同步代码一样优雅:

async fn process() {
    let data = fetch_data().await;
    let result = handle_data(data).await;
    save_result(result).await;
}

很多人会误以为异步代码比同步代码快,盲目将所有代码都改为异步,但这反而有可能导致性能下降。

异步本身是有开销的,编译器会将 async 函数编译成一个复杂的状态机,异步运行时需要不断 poll 状态机,判断是否能继续执行,这就会带来额外的调度开销。如果 async 函数嵌套较深,会生成多层嵌套的 Future,进一步增加内存占用和调度开销。

真正能发挥出异步优势的是 IO 密集型任务,比如网络请求、文件读写等。而对于 CPU 密集型任务,用同步反而性能更好。

动态分发的代价

Rust 的 trait 可以实现多态,而 dyn Trait(动态分发)能让我们写出更灵活、更通用的代码,比如:

trait Draw {
    fn draw(&self);
}

// 动态分发:接受任何实现了 Draw trait 的类型
fn process(x: &dyn Draw) {
    x.draw();
}

这种写法非常优雅,能极大提升代码的灵活性,但灵活性的代价是性能损耗。动态分发的核心是运行时查找方法,这会带来以下开销:

  • vtable 查找:每个 dyn Trait 实例都会携带一个 vtable(虚函数表)指针,调用方法时,需要通过指针查找 vtable 中的函数地址,增加一次内存访问;
  • 无法内联(inline)优化:因为方法的具体实现是在运行时确定的,编译器无法提前知道要调用哪个函数,所以无法进行内联优化,增加函数调用开销;
  • 分支预测失败:vtable 查找的结果是不确定的,CPU 无法有效预测分支,会导致分支预测失败,降低执行效率。

和动态分发相对的是静态分发(static dispatch),通过泛型实现,编译器会在编译时确定方法的具体实现,没有运行时开销:

// 静态分发:编译时确定 T 的具体类型,无运行时开销
fn process<T: Draw>(x: T) {
    x.draw();
}

静态分发性能更好,但是会导致代码膨胀,因为编译器为每个具体类型生成一份代码。而动态分发更灵活,但有运行时开销。

至此,我们可以得出结论:如果在热路径中,优先使用静态分发(泛型);如果灵活性更重要的场景,比如需要存储不同类型的实例到同一个集合,使用动态分发(dyn Trait)。

错误的内存布局

Rust 结构体默认使用 repr(Rust) 布局,虽然编译器的默认优化通常很好,但在以下场景中,你必须手动控制内存布局:

与 C 语言 FFI 交互

这是最常见的场景。当你需要将 Rust 结构体传递给 C 函数或从 C 函数接收结构体时,必须使用 #[repr(C)] 属性,它强制 Rust 使用与 C 语言完全相同的布局规则:

  • 字段按声明顺序排列
  • 对齐规则与 C 编译器一致
  • 第一个字段的偏移量始终为 0
#[repr(C)]
struct CCompatibleStruct {
    id: u32,
    active: bool,
    value: f64,
}

需要稳定的内存布局

当你需要对结构体进行原始内存操作时需要稳定的、可预测的内存布局,比如序列化/反序列化、网络协议解析、内存映射文件等场景。

缓存行对齐

为了防止多线程环境中的伪共享(false sharing)问题,你可能需要强制结构体按缓存行大小(通常是 64 字节)对齐:

#[repr(align(64))]
struct CacheAlignedCounter {
    value: AtomicU64,
}

极致内存节省

当内存是极其宝贵的资源时(如嵌入式系统、网络协议头),可以使用 #[repr(packed)] 来消除所有填充字节:

#[repr(packed)]
struct NetworkHeader {
    version: u8,
    flags: u8,
    length: u16,
    checksum: u32,
}

忽视 profiling

以上所有陷阱,都有一个共同的“天敌”,那就是凭感觉优化。读完之前的内容,有些 Rust 开发者会陷入这样的误区:

  • 我觉得 iterator 链很慢,所以我要把所有 iterator 都改成 for 循环
  • 我觉得 clone 没事,反正数据量不大
  • 我觉得 HashMap 不够快,所以我要换成 FxHashMap

这种凭感觉优化,不仅可能无效,还可能引入新的 bug,甚至让代码变得更难维护。

那么正确的方法是什么?性能优化的前提是找到性能瓶颈,而找到瓶颈的唯一方式,是使用工具进行 profiling(性能分析),用数据说话,而不是凭感觉。

Rust 生态中有很多优秀的 profiling 工具,推荐使用:

  • criterion:开源的基准测试工具,社区活跃,被 Tokio、Serde 等主流项目广泛使用,criterion 凭借其强大的统计分析能力以及丰富的功能,已经成为 Rust 基准测试的事实标准。相比于 cargo bench 这个 Rust 标准库提供的基准测试工具,我更推荐你直接使用 criterion,一步到位;
  • perf:Linux 下的性能分析工具,能查看程序的 CPU 使用率、函数调用耗时、cache miss 等详细信息;
  • flamegraph:生成火焰图,直观地展示程序的执行流程和耗时分布,能快速定位到耗时最长的函数(热路径)。不过,需要知道的是 flamegraph 在 macOS 下兼容性太差,有不少 bug,更推荐使用 samply

总结

很多人学习 Rust 时,会误以为Rust 天生性能好,随便写都快。但这是错的,Rust 给了你接近底层的性能控制权,也给了你优雅的抽象能力,但这两者往往是矛盾的。优雅的抽象往往会隐藏底层的性能开销,而高性能的代码往往需要放弃一些优雅,直面底层细节。