为何Rust中tokio::join!宏无需使用await关键字?
join!宏不需要也不允许后续使用await? 在Rust和其他编程语言中,await关键字用于标记异步函数的挂起点。但和Python的gather、Swift的withTaskGroup、JavaScript的Promise.all不同,Rust Tokio库的tokio::join!宏既不需要、也不允许在后面加await。比如这段示例代码:
use tokio::{io::Result, time::{sleep, Duration}, join}; async fn work() -> String { sleep(Duration::from_secs(2)).await; String::from("Work done") } #[tokio::main] async fn main() -> Result<()> { println!("Awaiting…"); let (o1, o2) = join!( work(), work(), ); // 这里不需要加.await println!("{}, {}", o1, o2); Ok(()) }
这里有两个疑问:
- 这种设计是否存在技术层面的原因?
- 这种隐式的挂起容易让人困惑——乍看这个异步函数里没有显式的
await,似乎没必要声明为async,这是不是违背了async-await模式要明确标记挂起点的初衷?
技术层面的原因
tokio::join!的设计是由它作为宏的特性和Tokio的性能目标共同决定的:
宏的内联展开逻辑
join!本质是语法扩展宏,它会把代码直接展开成一段在当前异步上下文中轮询所有传入Future的逻辑。展开后的代码会逐个检查每个Future的状态:如果某个Future还没完成,就会让当前任务挂起,等调度器唤醒后继续轮询,直到所有Future都完成。这个过程本身就包含了await的核心逻辑,所以不需要额外调用await。避免不必要的内存分配
如果join!返回一个聚合Future,这个Future需要在堆上分配空间保存所有子Future的状态。而join!直接在当前async函数的栈上管理这些状态,完全避免了这次堆分配,符合Tokio追求低开销异步的设计目标。静态类型与直接结果返回
join!可以直接返回所有子Future结果组成的元组,类型在编译期就完全确定,不需要额外的包装类型。这让你能直接处理每个任务的结果,错误处理也更直观——比如如果某个子Future返回Result,你可以直接在元组里解构后处理,不需要额外的嵌套unwrap。
关于"隐式挂起点"的困惑
确实,刚接触Tokio的开发者容易对join!没有显式await感到困惑,但实际上它并没有违背async-await的设计初衷:
join!本身就是明确的挂起点标记:它的语义就是"等待这些异步任务全部完成",和await的核心作用一致,只是把多个await的逻辑合并成了更简洁的语法。- Rust编译器会严格检查async函数是否存在挂起点:如果你的async函数里没有任何
await或者类似join!这种包含挂起逻辑的宏,编译器会直接报错,所以不用担心出现"没必要声明为async"的情况——能编译通过的async函数,必然包含挂起逻辑,只是join!把它封装起来了。
内容的提问来源于stack exchange,提问作者Louis Lac

