在 Rust 异步编程中,有一种观点认为:Future 的大小显著影响性能。你是否怀疑过这个说法的真实性?如果是真的,这种性能差异的根源又是什么?今天,我翻阅了一些源码,并编写实验代码来一探究竟。
Future 的大小如何计算?
为了验证“Future 大小影响性能”这一说法是否成立,我们先从一些简单代码入手。首要任务是弄清楚一个 Future 的大小是如何确定的。毕竟,在编译器眼里,Future 只是一个 trait:
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
那么,其大小取决于实现这个 trait 的具体结构体吗?我翻阅了 smol 的源码,发现在 spawn 一个 Future 时,相关代码是这样处理的:
pub unsafe fn spawn_unchecked<'a, F, Fut, S>(
self,
future: F,
schedule: S,
) -> (Runnable<M>, Task<Fut::Output, M>)
where
F: FnOnce(&'a M) -> Fut,
Fut: Future + 'a,
S: Schedule<M>,
M: 'a,
{
// Allocate large futures on the heap.
let ptr = if mem::size_of::<Fut>() >= 2048 {
let future = |meta| {
let future = future(meta);
Box::pin(future)
};
RawTask::<_, Fut::Output, S, M>::allocate(future, schedule, self)
} else {
RawTask::<Fut, Fut::Output, S, M>::allocate(future, schedule, self)
};
let runnable = Runnable::from_raw(ptr);
let task = Task {
ptr,
_marker: PhantomData,
};
(runnable, task)
}
这里可以看到 mem::size_of::<Fut>()
是在计算这个 Future 的大小,我来写个简单的 Future 验证:
use async_executor::Executor;
use futures_lite::future;
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
pub struct LargeFuture {
pub data: [u8; 10240],
}
impl Future for LargeFuture {
type Output = usize;
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
let value = self.data[0];
println!("First byte: {}", value);
Poll::Ready(self.data.len())
}
}
fn main() {
let ex = Executor::new();
let large_future = LargeFuture { data: [0u8; 10240] };
let res = future::block_on(ex.run(async { ex.spawn(large_future).await }));
println!("Result: {}", res);
}
在上面那个 async-task 的 spawn_unchecked
函数加上日志,打印出来的大小为 10256
,刚好比这个 struct 的大小大 16,顺着代码往上可以看到这里在原始的 Future 上做了一个封装,这里的意思是如果这个 Future 以后执行完,需要从 runtime 里面删掉:
let future = AsyncCallOnDrop::new(future, move || drop(state.active().try_remove(index)));
这解释了尺寸略有增加的原因。对于结构体的尺寸,我们不难理解,但对于 async 函数,其大小又是如何计算的呢?这就涉及 Rust 编译器对 async 的转换机制。
异步状态机:冰山之下的庞然大物
当你写下一个简单的 async fn
函数时,Rust 编译器在幕后悄然完成了一场复杂的转换:
async fn function() -> usize {
let data = [0u8; 102400];
future::yield_now().await;
data[0] as usize
}
这段代码会被编译器转化为一个庞大的状态机,负责追踪执行进度并保存所有跨越 .await
点的变量。转换后的结构体封装了状态切换的逻辑:
enum FunctionState {
// 初始状态
Initial,
// yield_now 挂起后的状态,必须包含所有跨 await 点的变量
Suspended {
data: [u8; 102400], // 整个大数组必须保存!
},
// 完成状态
Completed,
}
// 2. 定义状态机结构体
struct FunctionFuture {
// 当前状态
state: FunctionState,
// yield_now future
yield_fut: Option<YieldNow>,
}
impl Future for FunctionFuture {
// 3. 为状态机实现 Future traitimpl Future for FunctionFuture {
type Output = usize;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<usize> {
// 安全地获取可变引用
let this = unsafe { self.get_unchecked_mut() };
match &mut this.state {
FunctionState::Initial => {
// 创建大数组及其长度
let data = [0u8; 102400];
// 创建 yield future 并保存
this.yield_fut = Some(future::yield_now());
// 状态转换,保存所有需要跨越 await 的数据
this.state = FunctionState::Suspended { data };
// 立即轮询 yield
match Pin::new(&mut this.yield_fut.as_mut().unwrap()).poll(cx) {
Poll::Ready(_) => {
// 如果立即完成,返回结果
if let FunctionState::Suspended { data } = &this.state {
let result = data[0] as usize;
this.state = FunctionState::Completed;
Poll::Ready(result)
} else {
unreachable!()
}
}
Poll::Pending => Poll::Pending,
}
}
FunctionState::Suspended { data } => {
// 继续轮询 yield
match Pin::new(&mut this.yield_fut.as_mut().unwrap()).poll(cx) {
Poll::Ready(_) => {
// yield 完成,读取数组首元素并返回
let result = data[0] as usize;
this.state = FunctionState::Completed;
Poll::Ready(result)
}
Poll::Pending => Poll::Pending,
}
}
FunctionState::Completed => {
panic!("Future polled after completion")
}
}
}
}
可以看到,Suspended
状态中包含了那个大数组。当状态从 Initial
切换到 Suspended
时,data
会被完整保留。
由此可知,对于一个 async 函数,若临时变量需跨越 await 存活,就会被纳入状态机,导致编译时生成的 Future 大小显著增加。
尺寸对性能的影响
明确了 Future 大小的定义后,我们接着通过代码验证其对性能的影响。在之前的 mem::size_of::<Fut>() >= 2048
条件中可以看到,如果 Future 的大小过大,Box::pin(future)
会从堆上分配内存,理论上会带来额外开销。这种设计可能基于几点考量:小型 Future 直接嵌入任务结构体中,能提升缓存命中率;而大型 Future 若嵌入,会让任务结构体过于臃肿,占用过多栈空间,反而不利于性能。
我通过实验验证,若 async 函数中包含较大的结构体,确实会导致 Future 执行变慢(即便计算逻辑相同):
RESULTS:
--------
Small Future (64B): 100000 iterations in 30.863125ms (avg: 308ns per iteration)
Medium Future (1KB): 100000 iterations in 61.100916ms (avg: 611ns per iteration)
Large Future (3KB): 100000 iterations in 105.185292ms (avg: 1.051µs per iteration)
Very Large Future (10KB): 100000 iterations in 273.469167ms (avg: 2.734µs per iteration)
Huge Large Future (100KB): 100000 iterations in 5.896455959s (avg: 58.964µs per iteration)
PERFORMANCE RATIOS (compared to Small Future):
-------------------------------------------
Medium Future (1KB): 1.98x slower
Large Future (3KB): 3.41x slower
Very Large Future (10KB): 8.88x slower
Huge Large Future (100KB): 191.44x slower
在微调这个 async 函数时,我发现了一些微妙的现象。为了让 data
跨越 await 存活,我特意在最后引用了它,以防编译器优化掉:
async fn huge_large_future() -> u64 {
let data = [1u8; 102400]; // 10KB * 10
let len = data.len();
future::yield_now().await;
(data[0] + data[len - 1]) as u64
}
理论上,若改成下面这样,由于 len
在 await 前已计算完成,后面又没用引用到,生成的 Future 大小应该很小:
async fn huge_large_future() -> u64 {
let data = [1u8; 102400]; // 10KB * 10
let len = data.len();
future::yield_now().await;
0
}
fn main() {
let ex = Executor::new();
let task = ex.spawn(huge_large_future());
let res = future::block_on(ex.run(task));
eprintln!("Result: {}", res);
}
然而,我发现 data
仍被保留在状态机中,即便 len
未被后续使用。这涉及到编译器如何判断变量是否跨越 await 存活的问题。当然,若显式限定 data
的生命周期在 await 之前,它就不会被纳入状态机:
async fn huge_large_future() -> u64 {
{
let data = [1u8; 102400]; // 10KB * 10
let len = data.len();
}
future::yield_now().await;
0
}
编译器如何判断哪些变量应该保存
我查阅了 Rust 编译器的源码,发现变量是否跨越 await 存活由 locals_live_across_suspend_points 函数 决定:
/// The basic idea is as follows:
/// - a local is live until we encounter a `StorageDead` statement. In
/// case none exist, the local is considered to be always live.
/// - a local has to be stored if it is either directly used after the
/// the suspend point, or if it is live and has been previously borrowed.
在我们的代码中,let len = data.len()
构成了对 data
的借用,因此 data
被保留在状态机中。或许这里仍有优化的空间?我去社区问问看。
结语
所有实验代码均可在以下链接找到:async-executor-examples。
在 Rust 异步编程中,代码的细微调整可能引发性能的显著波动。深入理解状态机生成的内在机制,能助你打造更高效的异步代码。下次编写 async fn
时,不妨自问:这个状态机究竟有多大?
