Async Rust与Tokio:async/await、task与channel
1. 概览
Rust内置了async/await语法,但没有内置runtime来运行Future。Tokio是最流行的runtime。它提供了调度器、定时器、异步I/O和同步工具。
本文从第一个async函数开始,到常见操作:spawn task、使用join!并行执行、使用select!设置超时、在task间共享状态、通过channel发送数据和处理blocking代码。最后讨论如何编写测试和常见错误。
2. 安装
在Cargo.toml中添加Tokio:
[dependencies]
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
tokio = { version = "1", features = ["full", "test-util"] }
full特性启用所有模块:多线程runtime、time、sync、net、macros等。这种方式在学习时很方便。在实际项目中,你应该只启用实际使用的特性以减少编译时间,例如["rt-multi-thread", "macros", "time", "sync"]。test-util特性允许在测试中控制时间。我们将在第8节使用它。
3. 第一个async函数
3.1. #[tokio::main]与.await
use std::time::Duration;
use tokio::time::sleep;
async fn fetch_order(id: u32) -> String {
sleep(Duration::from_millis(100)).await; // 模拟访问数据库
format!("Order #{id}")
}
#[tokio::main]
async fn main() {
let order = fetch_order(42).await;
println!("{order}");
}
输出:
Order #42
解释:
async fn fetch_order不会立即返回String。它返回一个impl Future<Output = String>。tokio::time::sleep是sleep的async版本。在等待时,thread被返回给runtime来处理其他任务。#[tokio::main]是一个宏。它创建一个多线程runtime,然后使用block_on在该runtime中运行main。
3.2. Future是lazy的
这是与JavaScript中Promise的重大区别:Rust中的future什么都不做,直到被.await或轮询。
async fn load_customer(id: u32) -> String {
println!("正在加载customer {id}");
format!("Customer {id}")
}
#[tokio::main]
async fn main() {
let fut = load_customer(7); // 还没有运行函数内的任何代码
println!("在await之前");
let customer = fut.await;
println!("{customer}");
}
输出:
在await之前
正在加载customer 7
Customer 7
“正在加载customer 7”这一行只在调用.await后才出现。如果你忘记.await,函数将永远不会运行。编译器会警告unused implementer of Future that must be used,所以不要忽视这个警告。
4. Spawn task
.await顺序运行futures。要让它们并发运行,使用tokio::spawn创建task。Task是由Tokio管理的轻量级“绿色线程”。
use std::time::{Duration, Instant};
use tokio::time::sleep;
async fn fetch_order(id: u32) -> String {
sleep(Duration::from_millis(100 * id as u64)).await;
format!("Order #{id}")
}
#[tokio::main]
async fn main() {
let start = Instant::now();
let mut handles = Vec::new();
for id in 1..=3 {
handles.push(tokio::spawn(async move { fetch_order(id).await }));
}
for handle in handles {
let order = handle.await.expect("task被panic了");
println!("{order}");
}
println!("总耗时: ~{} ms", start.elapsed().as_millis());
}
输出:
Order #1
Order #2
Order #3
总耗时: ~300 ms
如果顺序执行,总时间会是100 + 200 + 300 = 600 ms。spawn时,三个task并发运行,总时间约为300 ms。
一些需要掌握的要点:
tokio::spawn返回JoinHandle<T>。当.awaithandle时,你会得到Result<T, JoinError>。如果task发生panic或被取消,结果就是Err。- 传入的future必须满足
Send + 'static,因为task可能在其他线程上运行并且生存期超过当前作用域。这就是为什么需要async move。 - Task在spawn时立即开始运行,不需要等待
.awaithandle。
注意: Drop一个
JoinHandle不会取消task。Task仍然会以detached模式继续运行。要取消它,调用handle.abort()。
当task数量不固定时,可以使用tokio::task::JoinSet。它允许按task完成的顺序获取结果,使用join_set.join_next().await。
5. 在同一task中并发执行:join!、select!、timeout
5.1. join!和try_join!
不一定总是需要spawn。tokio::join!在同一task中同时轮询多个futures并等待它们全部完成:
use std::time::{Duration, Instant};
use tokio::time::sleep;
async fn fetch_order(id: u32) -> String {
sleep(Duration::from_millis(200)).await;
format!("Order #{id}")
}
async fn fetch_customer(id: u32) -> String {
sleep(Duration::from_millis(150)).await;
format!("Customer {id}")
}
#[tokio::main]
async fn main() {
let start = Instant::now();
let (order, customer) = tokio::join!(fetch_order(1), fetch_customer(7));
println!("{order} - {customer} ({} ms)", start.elapsed().as_millis());
}
输出:
Order #1 - Customer 7 (200 ms)
总时间等于最长的future,约200 ms。futures不需要'static,所以它们可以借用本地变量。
当函数返回Result时,使用tokio::try_join!。这个宏在第一个错误处停止并drop剩余的futures:
let (order, customer) = tokio::try_join!(fetch_order_checked(1), fetch_customer_checked(7))?;
这里fetch_order_checked和fetch_customer_checked是返回Result<String, AppError>的async函数。
5.2. timeout
use tokio::time::{timeout, Duration};
match timeout(Duration::from_millis(50), fetch_order(1)).await {
Ok(order) => println!("{order}"),
Err(_) => println!("超过时间限制"),
}
输出:
超过时间限制
fetch_order需要200 ms,超过50 ms的限制。此时timeout返回Err(Elapsed),内部的future被drop,即被取消。
5.3. select!
select!等待第一个分支完成,然后取消其他分支:
use tokio::sync::oneshot;
#[tokio::main]
async fn main() {
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
sleep(Duration::from_millis(100)).await;
let _ = shutdown_tx.send(());
});
tokio::select! {
order = fetch_order(1) => println!("处理完成 {order}"),
_ = shutdown_rx => println!("收到shutdown信号,停止处理"),
}
}
输出:
收到shutdown信号,停止处理
shutdown信号在100 ms后到达,早于fetch_order(200 ms),所以第二个分支获胜。
注意: 在Rust中取消意味着future在最近的
.await点被drop。如果被取消的分支正在进行一个非原子操作,例如已经从socket读了一半的message,数据可能会丢失。在将函数放入select!循环之前,应该检查每个函数文档中的“cancel safety”部分。
6. 在task间共享数据
6.1. Arc<Mutex<T>>
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[tokio::main]
async fn main() {
let inventory = Arc::new(Mutex::new(HashMap::from([("laptop", 10u32)])));
let mut handles = Vec::new();
for _ in 0..5 {
let inventory = Arc::clone(&inventory);
handles.push(tokio::spawn(async move {
let mut stock = inventory.lock().unwrap();
*stock.get_mut("laptop").unwrap() -= 1;
}));
}
for h in handles {
h.await.unwrap();
}
println!("剩余: {}", inventory.lock().unwrap()["laptop"]);
}
输出:
剩余: 5
这里使用std::sync::Mutex而不是tokio::sync::Mutex。当critical section短且**内部没有.await**时推荐此选择,因为标准mutex更快。
只有当必须在.await点保持lock时才需要tokio::sync::Mutex,例如在等待I/O时持有共享连接。
6.2. Channel mpsc
与其共享内存,不如在task间通过channel传递message:
use tokio::sync::mpsc;
#[derive(Debug)]
struct Order {
id: u32,
amount: u64,
}
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<Order>(32);
for id in 1..=3 {
let tx = tx.clone();
tokio::spawn(async move {
tx.send(Order { id, amount: id as u64 * 100 }).await.unwrap();
});
}
drop(tx); // 关闭原始sender以便下面的循环能够结束
let mut total = 0;
while let Some(order) = rx.recv().await {
println!("接收 {order:?}");
total += order.amount;
}
println!("总计: {total}");
}
输出:
接收 Order { id: 1, amount: 100 }
接收 Order { id: 3, amount: 300 }
接收 Order { id: 2, amount: 200 }
总计: 600
接收的顺序可能在不同运行间变化。参数32是缓冲容量。当缓冲满时,send会等待,产生自然的back-pressure机制。当所有sender都被drop时,recv()返回None。如果忘记drop(tx),程序会在while循环中永远挂起。
除了mpsc,Tokio还有:
oneshot:发送单个值。broadcast:多个consumer,每个consumer接收所有message。watch:consumer只关心最新值。
7. 处理blocking代码
Tokio使用协作式调度。Task只在.await点让出线程。重型CPU操作或同步I/O会占用worker线程,导致其他task挨饿。
fn hash_password(password: &str) -> String {
// 模拟CPU密集操作,例如bcrypt/argon2
std::thread::sleep(std::time::Duration::from_millis(500));
format!("hashed:{password}")
}
#[tokio::main]
async fn main() {
let password = String::from("s3cret");
let hashed = tokio::task::spawn_blocking(move || hash_password(&password))
.await
.expect("blocking task被panic了");
println!("{hashed}");
}
输出:
hashed:s3cret
spawn_blocking将closure推送到专用于blocking的线程池。这样runtime的worker线程保持空闲以处理其他请求。
注意: 不要在
async函数中直接调用std::thread::sleep、std::fs::read或同步HTTP客户端。使用async版本(tokio::time::sleep、tokio::fs)或在spawn_blocking中包装它们。对于大规模并行计算,Rayon通常更合适。
8. 测试async代码
宏#[tokio::test]为每个测试创建一个runtime。默认情况下这是单线程runtime。
use std::time::Duration;
use tokio::time::{sleep, timeout};
pub async fn fetch_order(id: u32) -> String {
sleep(Duration::from_millis(200)).await;
format!("Order #{id}")
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn fetch_order_returns_formatted_id() {
assert_eq!(fetch_order(42).await, "Order #42");
}
#[tokio::test(start_paused = true)]
async fn timeout_fires_when_order_service_is_slow() {
let result = timeout(Duration::from_millis(50), fetch_order(1)).await;
assert!(result.is_err());
}
#[tokio::test(start_paused = true)]
async fn order_arrives_within_generous_timeout() {
let result = timeout(Duration::from_secs(1), fetch_order(1)).await;
assert_eq!(result.unwrap(), "Order #1");
}
}
运行:
cargo test
输出:
running 3 tests
test tests::fetch_order_returns_formatted_id ... ok
test tests::timeout_fires_when_order_service_is_slow ... ok
test tests::order_arrives_within_generous_timeout ... ok
start_paused = true(需要test-util特性)冻结Tokio的时钟。当所有task都在等待定时器时,runtime自动跳到下一个检查点。因此超时测试几乎立即运行并给出稳定结果,不依赖CI机器速度。
如果需要检查多线程行为,使用#[tokio::test(flavor = "multi_thread", worker_threads = 2)]。
9. 常见错误
在.await点保持std::sync::MutexGuard。 Guard不是Send,因此future也不再是Send。此时tokio::spawn会报编译错误future cannot be sent between threads safely。修复方法是通过{ ... }块在.await之前缩小guard的作用域,或切换到tokio::sync::Mutex。
创建嵌套runtime。 在已在Tokio上运行的代码中调用Runtime::new().block_on(...)会引发panic,消息为Cannot start a runtime from within a runtime。在async上下文中,只需.await。
不必要时使用async。 读取几个文件的CLI工具或纯计算任务从Tokio中受益不多。你只需付出额外的编译时间和复杂性。当有许多并发I/O操作时async才有用:web服务器、爬虫、代理、调用多个服务的客户端。
无限spawn。 Task便宜但不免费。为百万元素列表中的每个项spawn一个task可能会耗尽下游资源,如数据库连接池。用tokio::sync::Semaphore限制并发级别或使用futures crate中的buffer_unordered。
忽视JoinError。 如果.await handle时使用let _ =,task中的panic会被吞掉。你应该记录错误或将其传播上去。
10. 结论
本文已覆盖async Rust与Tokio的核心部分:
- Future是lazy的,只在被
.await时才运行。 tokio::spawn创建并发运行的task。传入的future需要Send + 'static。join!和try_join!在同一task中运行多个futures。select!和timeout处理竞速和取消。- 共享数据可通过
Arc<Mutex<T>>共享,或通过mpsc和oneshot等channel传递。 - Blocking代码需要放在
spawn_blocking中。 #[tokio::test(start_paused = true)]让时间相关的测试快速且稳定。
掌握这些部分后,你可以继续学习通过tokio::net进行网络I/O,或使用Axum等框架在Tokio基础上构建web服务。