The Cost of Indirection in Rust

原文在这里The Cost of Indirection in Rust 依旧来自This Week in Rust

为什么因为“间接(函数调用)有开销”而选择内联通常是错误的,真正的开销到底在哪?

开销不在哪

对于一个这样的函数:

async fn handle_event(&self, event: Event) -> Result<()> {
    match event.kind {
        EventKind::Suspend => {
            // … > 20 lines of app behavior … 
            }
        // … other arms …
    }
}

相比于写成这样:

async fn handle_event(&self, event: Event) -> Result<()> {
    match event.kind {
        EventKind::Suspend => self.handle_suspend(event).await,
        // … other arms …
    }
}

async fn handle_suspend(&self, event: Event) -> Result<()> {
    // … > 20 lines of app behavior …
}

肉眼可见的多了一次异步函数调用,具体而言:

  • 传递参数
  • Future状态机初始化
  • pin住self引用
  • 压栈和出栈
  • 调度队列、唤醒器和事件循环的额外开销

然而实践中不是这样的

在这个场景里,match本身就是一次分支,对应着一连串的比较或者一个跳转表,而增加一次函数调用不过是增加一次间接跳转和栈帧建立,相比于suspend分支做的实际工作,这完全就是噪音

当你await一个异步函数时,不要把它想象成传统的“进入新函数-压栈-出栈”。在 Rust 的底层,异步函数会被编译成枚举(Enum)状态机。

当父函数调用子函数时,编译器直接将子 Future 的状态塞进父 Future 的状态机中。因为这种嵌套结构在编译期大小完全确定,系统只需在栈上分配一块固定内存,根本不需要付出动态分配的代价。额外的 .await 节点最终会被编译器“内联”和“拍扁”,所谓的函数跳转开销,实际上早已被编译器优化成了零成本。

如果在Suspend分支里执行了一次I/O、等锁或者其它什么分配操作,这就是用纳秒级别的噪声去碰瓷毫秒级的信号,根本不是一个层面的事

另外,在release模式下的编译,编译器通常会内联小函数,是否要内联的分歧可能一开始就不存在

更好的办法是用工具测量,是去跑benchmark,是valgrind --tool=callgrid,perf record,perf report或火焰图,dtrace等等

什么时候内联确实需要

对于每秒百万次的紧循环,聪明的缓存策略和分支预测变得重要,纳秒的开销就会变成毫秒级的、可感的延迟

对于dyn特征,编译器不知道最后会传进来个什么东西,需要去查虚函数表,就会造成硬性开销

性能关键路径上的显式间接调用,比如函数指针或者试图调用另一个语言的模块,和dyn特征一样,编译器不知道最后会调用一个什么东西,所以开销也不可避免

“非运行时”开销

人的认知成本很多时候比运行时的开销更重要

更长的代码会使每个阅读它的人支付一笔认知税,会更容易出bug,且随着代码量的增加会变得更加难以阅读

rust的设计哲学对此的态度是明确的:做清晰的抽象,相信优化,只在确认真的是个问题且没有替代办法时再用#[inline]或#[inline(always)]

所以真正的开销是移除这一层函数调用后失去的清晰性、可测试性和开发效率

另外,把函数内联进去了要怎么做单元测试

总结

为了可维护性和可理解性,在没有坚实的证据证明前,多一次函数调用而非内联再好不过