Rust 2026 经验谈 - 异步常见陷阱与调试

2183 字
11 分钟
Rust 2026 经验谈 - 异步常见陷阱与调试

异步 Rust 是一片强大但充满陷阱的领地。编译器会阻止数据竞争,但它不会阻止逻辑错误——比如 cancel safety 违反、在 select! 中丢失数据、或用错 Mutex 导致性能崩溃。本文系统梳理 2026 年最常见的异步陷阱及其调试方法。

Send 约束不满足的根因分析#

tokio::spawn 要求 Future 满足 Send + 'static。当你看到这样的错误:

error: future cannot be sent between threads safely
--> src/main.rs:10:5
|
10 | tokio::spawn(async {
| ^^^^^^^^^^^ future returned by `async` is not `Send`
|
= help: within `impl Future<Output = ()>`, the trait `Send` is not implemented

根因一:Rc 跨 await 点#

use std::rc::Rc;
async fn bad_rc_across_await() {
let data = Rc::new(vec![1, 2, 3]); // Rc 不是 Send
some_async_work().await; // await 点:Future 可能被移到另一个线程
println!("{:?}", data); // data 跨越了 await 点
}
async fn some_async_work() {}

修复:用 Arc 替代 Rc

use std::sync::Arc;
async fn good_arc_across_await() {
let data = Arc::new(vec![1, 2, 3]); // Arc 是 Send
some_async_work().await;
println!("{:?}", data);
}

根因二:非 Send 状态被捕获到 Future 中#

即使没有跨 await 点,如果 Future 捕获了非 Send 变量,整个 Future 也不是 Send:

use std::cell::RefCell;
async fn captured_non_send() {
let local: RefCell<i32> = RefCell::new(0); // RefCell 不是 Send
// 即使不跨 await,local 被 Future 持有
*local.borrow_mut() = 42;
some_async_work().await;
}
// 修复:限制非 Send 变量的生命周期,使其不跨 await
async fn fixed_scope() {
{
let local: RefCell<i32> = RefCell::new(0);
*local.borrow_mut() = 42;
// local 在这里 drop,不持有到 await
}
some_async_work().await;
}

根因三:持有 std::sync::MutexGuard 跨 await#

use std::sync::Mutex;
async fn mutex_guard_across_await(data: &Mutex<Vec<i32>>) {
let mut guard = data.lock().unwrap(); // MutexGuard 不是 Send
guard.push(1);
some_async_work().await; // guard 还活着!
guard.push(2);
}
// 修复:缩小锁的生命周期
async fn mutex_guard_no_await(data: &Mutex<Vec<i32>>) {
{
let mut guard = data.lock().unwrap();
guard.push(1);
} // guard 在 await 前 drop
some_async_work().await;
{
let mut guard = data.lock().unwrap();
guard.push(2);
}
}

诊断技巧:编译器错误信息在 2024 edition 后已有大幅改进,会指出是哪个变量导致 Send 不满足。如果信息不够,可以用 static_assertions::assert_impl_all!(MyFuture: Send) 在编译期断言。

Lifetime 跨 await 点的限制与解决#

借用不能跨 await#

这是异步 Rust 最让人困惑的限制之一:

struct Processor {
buffer: Vec<u8>,
}
impl Processor {
async fn process(&mut self) {
// 错误:&mut self 跨越了 await 点
self.buffer.push(1);
some_async_work().await;
self.buffer.push(2);
}
}

错误原因:async fn&mut self 存入 Future 的状态机中,但 &mut self 不是 Send(因为它引用了调用者的栈),导致 spawn(processor.process()) 失败。即使不 spawn,borrow checker 也可能因为 Future 的自引用结构而拒绝。

解决方案一:Arc + 内部可变性#

use std::sync::Arc;
use tokio::sync::RwLock;
struct Processor {
buffer: Arc<RwLock<Vec<u8>>>,
}
impl Processor {
async fn process(&self) {
{
let mut buf = self.buffer.write().await;
buf.push(1);
}
some_async_work().await;
{
let mut buf = self.buffer.write().await;
buf.push(2);
}
}
}

解决方案二:分离操作#

impl Processor {
fn prepare(&mut self) -> u8 {
self.buffer.push(1);
self.buffer[0]
}
fn finalize(&mut self, val: u8) {
self.buffer.push(val);
}
async fn process(&mut self) {
let val = self.prepare();
some_async_work().await;
self.finalize(val);
}
}

解决方案三:结构化并发(scoped task)#

use tokio_util::task::TaskTracker;
async fn scoped_processing() {
let tracker = TaskTracker::new();
let data = vec![1, 2, 3];
for item in &data {
let item = *item;
tracker.spawn(async move {
process_item(item).await;
});
}
tracker.close();
tracker.wait().await;
}
async fn process_item(item: i32) {
some_async_work().await;
println!("处理: {item}");
}

TaskTracker 是 Tokio 1.35+ 引入的结构化并发工具,比 JoinSet 更灵活。但注意:它不解决借用跨 await 的问题——spawn 的闭包仍然需要 'static

Cancel Safety 问题#

Cancel safety 是异步 Rust 中最隐蔽、最危险的陷阱。tokio::select! 在某个分支完成时会 drop(取消)其他分支的 Future。如果被取消的 Future 已经部分完成了操作,数据可能丢失。

经典陷阱:select! + write#

use tokio::net::TcpStream;
use tokio::time::{self, Duration};
async fn dangerous_select(mut socket: TcpStream) {
let data = b"hello world";
loop {
tokio::select! {
// 危险!如果 timeout 分支先完成,write 被取消
// 但可能已经写了部分数据,不知道写了多少
result = socket.write(data) => {
if let Ok(n) = result {
println!("写了 {n} 字节");
}
}
_ = time::sleep(Duration::from_secs(1)) => {
println!("超时");
}
}
}
}

问题:TcpStream::write 可能在内部已经写了部分数据到内核缓冲区,但在 Future 被 drop 时不知道到底写了多少——剩余数据无法正确重发。

修复模式一:使用 cancel-safe 操作#

use tokio::sync::mpsc;
async fn cancel_safe_select() {
let (tx, mut rx) = mpsc::channel::<i32>(100);
tokio::spawn(async move {
tx.send(1).await.unwrap();
});
loop {
tokio::select! {
val = rx.recv() => {
// rx.recv() 是 cancel-safe 的!
// 取消时不会丢失消息
match val {
Some(v) => println!("收到: {v}"),
None => break,
}
}
_ = time::sleep(Duration::from_secs(1)) => {
println!("超时");
}
}
}
}

mpsc::Receiver::recv()TcpStream::read() 都是 cancel-safe 的——如果 Future 在完成前被 drop,不会产生数据丢失或部分副作用。

修复模式二:把不 cancel-safe 的操作隔离到独立任务#

对于不 cancel-safe 的操作(如 writeread_exact),用 spawn 隔离:

async fn isolated_write(mut socket: TcpStream, data: &[u8]) {
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let result = socket.write(data).await;
let _ = tx.send(result);
});
tokio::select! {
result = rx => {
// 写入完成
}
_ = time::sleep(Duration::from_secs(5)) => {
// 超时,但写入任务仍在运行(不被取消)
}
}
}

Tokio 文档中的 Cancel Safety 标注#

从 Tokio 1.21 开始,文档中标注了每个操作是否 cancel-safe:

  • Cancel-saferecv(), accept(), read() (on TcpStream)
  • 非 Cancel-safewrite() (可能部分写入), read_exact() (缓冲区可能被部分填充), send() (可能部分发送)

经验法则:如果你不确定一个操作是否 cancel-safe,假设它不是。在 select! 中使用非 cancel-safe 操作时,必须有补偿逻辑。

异步代码中的 Mutex 选择#

tokio::sync::Mutex vs std::sync::Mutex#

这是 Rust 社区争论最久的话题之一。简短回答:大多数情况下用 std::sync::Mutex

use std::sync::Mutex;
use std::sync::Arc;
// 推荐:std::sync::Mutex + 短临界区
async fn with_std_mutex() {
let data = Arc::new(Mutex::new(vec![]));
let data_clone = data.clone();
tokio::spawn(async move {
// 锁住 -> 做事 -> 解锁,不跨 await
let mut guard = data_clone.lock().unwrap();
guard.push(1);
// guard 自动 drop,不持有到 await
drop(guard);
some_async_work().await;
});
}

何时用 tokio::sync::Mutex#

use tokio::sync::Mutex;
// 必须用 tokio::sync::Mutex 的场景:锁内含 await
async fn with_tokio_mutex() {
let data = Arc::new(Mutex::new(vec![]));
let data_clone = data.clone();
tokio::spawn(async move {
let mut guard = data_clone.lock().await;
guard.push(1);
// 必须在锁内 await(如数据库操作)
some_async_work_with_guard(&mut guard).await;
guard.push(2);
});
}

对比表#

维度std::sync::Mutextokio::sync::Mutex
加锁方式lock().unwrap() 同步lock().await 异步
跨 await不可以可以
性能快(无 async 开销)略慢
死锁风险有(同一线程重复加锁)有(同 task 重复加锁)
锁粒度建议细粒度粗粒度
Send 约束Guard 不是 SendGuard 是 Send

最佳实践

  1. 优先 std::sync::Mutex,锁的临界区不含 await
  2. 如果临界区必须含 await,用 tokio::sync::Mutex
  3. 无论如何,缩小锁的粒度——锁住 -> 做最少的事 -> 立即释放
  4. 如果性能是瓶颈,考虑将 Mutex<Vec<T>> 改为 DashMapRwLock

异步调试工具#

tokio-console#

tokio-console 是异步 Rust 的性能诊断利器,它可以实时观察每个 task 的状态、等待时间、poll 次数。

服务端配置

Cargo.toml
[dependencies]
console-subscriber = "0.4"
tokio = { version = "1", features = ["full", "tracing"] }
use console_subscriber::ConsoleLayer;
use tokio::runtime::Builder;
fn main() {
// 启用 tokio-console 支持
console_subscriber::init();
let rt = Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
// 你的异步代码
my_app().await;
});
}

客户端连接

Terminal window
# 安装 tokio-console CLI
cargo install tokio-console
# 运行你的应用后,在另一个终端
tokio-console

tokio-console 显示的关键指标:

  • Task 状态:Idle / Running / Done
  • Total time:task 的总存活时间
  • Busy time:task 在 poll 中的总时间
  • Idle time:task 等待唤醒的总时间
  • Polls:task 被 poll 的次数

如果某个 task 的 polls 数极高但 busy time 很短,说明它在频繁被唤醒但没有实际工作——典型的”惊群”或无效唤醒问题。

console-subscriber 的进阶用法#

use console_subscriber::ConsoleLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
fn setup_console() {
let console_layer = ConsoleLayer::builder()
.server_addr(([127, 0, 0, 1], 6669)) // 自定义端口
.record_duration(std::time::Duration::from_secs(60)) // 保留 60s 数据
.build();
tracing_subscriber::registry()
.with(console_layer)
.init();
}

其他调试手段#

  1. tracing + tracing-subscriber:给每个 async 操作加 span,输出时序日志
use tracing::{info, instrument};
#[instrument(skip_all)]
async fn fetch_user(id: u32) -> Result<User, Error> {
info!("开始获取用户");
let resp = http_client.get(&format!("/users/{id}")).await?;
info!("获取完成");
Ok(resp.json().await?)
}
  1. JoinHandle::abort() 用于测试取消行为——手动取消 task 观察是否清理正确

  2. tokio::task::yield_now() 用于测试并发问题——在关键点主动让出,增加其他 task 交替执行的机会

实战经验总结#

1. “编译通过”不等于”逻辑正确”#

Rust 的类型系统防止了数据竞争,但不防止:

  • Cancel safety 问题(数据丢失)
  • 死锁(锁顺序、await 时持锁)
  • 活锁(task 间反复唤醒但无进展)

异步代码需要额外的审慎,尤其是涉及 select! 和锁的代码。

2. select! 的每个分支都要考虑 cancel safety#

在 code review 中,把 select! 作为重点审查对象。问自己:如果这个分支被取消,中间状态是否安全?是否有数据丢失?

3. 用 tokio-console 做异步性能分析#

不要凭猜测优化异步代码。先跑 tokio-console,找到 polls 数异常或 busy time 过高的 task,再针对性优化。

4. 锁的选型遵循”不用就不用”原则#

很多异步代码中的 Mutex 可以用 message passing(channel)替代:

// 不要:共享状态 + 锁
let state = Arc::new(Mutex::new(State::new()));
// 优先:单 task + channel
let (cmd_tx, mut cmd_rx) = mpsc::channel(32);
tokio::spawn(async move {
let mut state = State::new();
while let Some(cmd) = cmd_rx.recv().await {
state.handle(cmd);
}
});

Actor 模式(单 task 持有状态,通过 channel 接收命令)是异步 Rust 中最健壮的状态管理模式。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
Rust 2026 经验谈 - 异步常见陷阱与调试
https://tinyzzh.github.io/posts/rust-2026/2026-06-18-rust_2026_018_async_pitfalls/
作者
TinyZ Zzh
发布于
2026-06-18
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
TinyZ Zzh
专注于高并发服务器、网络游戏相关(Java、PHP、Unity3D、Unreal Engine等)技术,热爱游戏事业, 正在努力实现自我价值当中。
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
232
分类
38
标签
255
总字数
384,983
运行时长
0
最后活动
0 天前

文章目录