Future 的大小对性能的影响

Future 的大小对性能的影响

Rust 的 async 函数会被编译成保存执行状态的具体 Future。跨越 await 仍需存活的大对象可能扩大该状态;执行器又可能按自己的分配策略改变成本。本文把源码观察与作者基准分开,说明如何检查 Future 尺寸,以及哪些结论不能外推。

局部大数组在 await 后仍会被读取时,作为挂起状态的一部分保存在 Future 中;某些执行器会按自身阈值把较大的 Future 放到堆上。
示意图:需要跨挂起点继续使用的数据属于异步状态的一部分。图中的 2048 字节只对应原文检查的特定 async-task 源码路径,不是 Rust 语言规定。

Future 的尺寸由具体类型决定

Future 是一个 trait。泛型代码持有具体实现类型 Fut,而不是一个统一尺寸的“Future 基类”。因此可用 std::mem::size_of::<Fut>() 查看该类型的静态尺寸;对于值,也可用 size_of_val。这个数字不等于执行一个任务的全部内存:任务头、调度器数据、队列节点、堆分配器开销和 Future 引用的外部堆对象都可能另计。

原文检查的 async-executor 1.13.1 源码中,任务分配路径会比较 mem::size_of::<Fut>() 与 2048。达到阈值时,该实现通过 Box::pin 把 Future 放到堆上;较小的值以内嵌方式存储。作者还在该分支打印包装后的尺寸:一个含 10,240 字节数组的 Future 报为 10,256 字节,差额来自任务实现附加的包装状态。这是特定版本与代码路径的观察。2048 不是 Rust 固定阈值,也不能据此断言所有执行器都采取相同策略。Pin 表达移动约束;引入堆分配的是 Box。

await 如何改变状态尺寸

编译器把 async fn 转换为实现 Future 的状态机。它需要保存恢复执行所必需的状态;局部变量若在挂起后仍被使用,就必须以某种形式留在 Future 中。下例的数组在 await 之后读取,所以异步状态必须保有相应数据:

async fn hold_large_value() -> u8 {
    let data = [0_u8; 102_400];
    some_async_operation().await;
    data[0]
}

这是状态机的概念说明,不是编译器生成代码的逐字复刻。实际字段布局、填充、状态标记、优化结果和尺寸依赖编译器版本与具体函数;数组也不一定以原样字段出现。

要把问题拆成两层:一是 Future 类型本身的尺寸,二是执行器怎样保存和调度该值。堆分配可能让外层任务节点更紧凑,但 Future 数据仍占内存,还会带来分配、间接访问和释放成本。大值也可能增加创建、移动及缓存压力。具体影响必须在目标负载测量,不能仅由类型尺寸推导快慢。

先测量具体 Future,再解释数字

在不启动网络服务或执行器的情况下,可以构造 Future 并只检查其类型尺寸:

use std::mem::size_of_val;

async fn inspect_me() -> usize {
    let bytes = [0_u8; 102_400];
    std::future::pending::<()>().await;
    bytes.len()
}

fn main() {
    let future = inspect_me();
    println!("Future bytes: {}", size_of_val(&future));
}

示例中的 pending 只是让 Future 在 await 点保持挂起;程序只创建并测量它,不轮询,也不需要异步运行时。结果只适用于当前编译器、目标平台、构建选项与函数定义。比较时要固定这些条件,避免把 Future 尺寸混同于进程总内存。

若大型临时值在挂起后不再需要,可以把它放进更窄作用域,让它在 await 前结束:

async fn do_work() -> usize {
    let length = {
        let temporary = [0_u8; 102_400];
        temporary.len()
    }; // temporary 的作用域在挂起前结束

    some_async_operation().await;
    length
}

作用域能表达“这个值不再需要”,但不要把某一次编译器对局部变量存活性分析的观察说成所有 Rust 版本都必然生成小 Future 的保证。需要时直接检查构建产物中的具体尺寸,并在真实应用中测量峰值内存和吞吐。

原文实验报告了什么

作者的基准为五种 async 函数分别创建 100,000 个任务:数组标称为 64 B、1 KiB、3 KiB、10 KiB 和 100 KiB;每个任务在 yield_now().await 后读取数组首尾。原文报告的单次总耗时依次为 30.863 ms、61.101 ms、105.185 ms、273.469 ms 和 5.896 s,并报告最大组约为小组的 191.44 倍。这些是原文作者报告的结果,本稿没有重跑。

这组数字没有隔离“Future 字节数”这一变量。基准在等待句柄前一次性创建每组全部 100,000 个任务;仅 100 KiB × 100,000 就约有 10.24 GB(约 9.54 GiB)的数组载荷,尚未计任务元数据。队列、分配、调度、缓存和系统内存压力都会影响总时间,极端组甚至可能耗尽内存。原文未固定优化级别、处理器、操作系统、完整锁文件和重复采样方法,因此耗时和倍数不能当作现代机器上的性能承诺,也不能归因于单一“装箱”因素。

复现实验应先在隔离且有内存上限的环境评估规模,从小批量开始;固定 Rust 工具链、依赖锁文件、执行器版本、构建模式和机器;限制同时存活的任务数;重复运行并报告分布;分别测量分配量、峰值内存和任务延迟。不要直接照搬 100,000 个 100 KiB 并发 Future 作为生产负载测试。

源码观察的版本边界

随稿保存的实验清单声明 async-executor 1.13.1、Rust 最低版本 1.63,但依赖中包含本地路径 async-task,其他若干依赖使用语义版本范围;没有可确认的完整锁文件与编译器版本。原文有关 locals_live_across_suspend_points 的解释来自作者所读的编译器源码路径和当时实验,不能替代目标 Rust 版本的检查。依赖升级、优化级别变化或编译器改进都可能改变尺寸与耗时。

较稳妥的结论是:跨 await 存活的大对象可能扩大 Future;特定执行器会按自身布局策略选择内联或堆存储。尺寸是值得检查的线索,性能结论仍须由版本固定、规模受控、可重复的目标场景基准支持。

来源与署名

原文:《Future 的大小对性能的影响》,作者 Yukang,CatCoding。保留原作者报告的数字并注明未复测;异步执行器和编译器片段经筛选、改写与安全说明,不将概念伪代码伪装为可直接编译的实现。中文译写与配图依据另行授权制作。原网页未显示可核验的独立文章许可证;Cargo 清单中的 Apache-2.0 OR MIT 是软件包许可证,不据此宣称博客文章也采用该许可。配图为本稿原创示意图。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容