Rust 里到处都是 Arc<Mutex<T>>?你可能把 Java 的架构搬过来了

说句可能会让你有点不舒服的话:如果你的 Rust 代码里到处都是 Arc<Mutex<T>>,那你很可能把其他语言的架构直接搬过来了。
Rust 编译器没报错,程序也能跑,但性能可能正在悄悄地被吃掉。
Arc<Mutex> 像一个合租厕所
想象一下,你和十个室友住在一起,但这套房子只有一个厕所,而且厕所门只有一把钥匙。
每次有人要上厕所,得先拿到钥匙,进去,完事,把钥匙放回去。下一个人才能用。
这就是 Arc<Mutex<T>> 的本质:共享的可变状态,通过锁来保护访问。
在低负载的时候还好,毕竟大家不会都在同一时间上厕所。但一旦人多起来,问题就来了:
有人在里面待太久,外面的人排队等待。队伍越来越长,大家都干不了别的事,只能在那等。
这叫锁竞争。
更糟糕的是,在异步 Rust 里,std::sync::Mutex 会直接阻塞整个线程。你想想,一个负责处理一千个请求的线程,因为某个任务在等锁,结果其他九百九十九个请求也跟着陪葬。
这就像一个人占了厕所,其他人不仅进不去,连回自己房间休息都不行。
Rust 想让你玩的游戏规则
很多语言教我们:线程之间共享内存,用锁保护,搞定。
Rust 的设计哲学不太一样。它没让你去找一把更好的锁,而是想问:能不能别共享了?
Rust 的所有权系统核心就两条路:
要么只有一个可变的 owner 要么有多个不可变的引用
Arc<Mutex<T>> 本质上是在说:算了算了,我就绕一下这个规则吧。
但绕规则是有代价的。
消息传递:Rust 的更好选择
不想共享可变状态,那怎么办?答案是消息传递。
还是那个合租的例子,这次咱们换个玩法:
不再是一个公共厕所,而是每个人都待在自己的房间里。如果需要和室友沟通,就写张纸条,从门缝塞出去。室友收到纸条,处理完,再写张纸条塞回来。
每个人都有自己的空间,没人需要排队等谁。
在 Rust 里,这就是 Actor 风格的模式:把状态的 ownership 交给一个 task,其他 task 通过 channel 发送消息给它。
用代码说话:
use tokio::sync::mpsc;
// 定义状态
struct AppState {
counter: usize,
}
// 定义消息类型
enum Command {
Increment,
Get(tokio::sync::oneshot::Sender<usize>),
}
// 状态管理者:唯一的 owner
async fn state_manager(mut rx: mpsc::Receiver<Command>) {
let mut state = AppState { counter: 0 };
while let Some(cmd) = rx.recv().await {
match cmd {
Command::Increment => {
state.counter += 1;
}
Command::Get(reply) => {
let _ = reply.send(state.counter);
}
}
}
}
// 启动状态管理者
let (tx, rx) = mpsc::channel(32);
tokio::spawn(state_manager(rx));
// 使用:发送消息,不需要锁
tx.send(Command::Increment).await.unwrap();
// 获取状态
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
tx.send(Command::Get(resp_tx)).await.unwrap();
let value = resp_rx.await.unwrap();
看明白了吗?没有 Arc,没有 Mutex,没有 lock(),没有 unwrap(),没有等着拿锁的焦虑。
只有一个人拥有 state,只有一个人能修改它。其他人都通过发消息来请求操作。
性能对比:Channel vs Arc<Mutex>
你可能想,发消息不也有开销吗?
确实有,但咱们算笔账:
Arc<Mutex<T>> 的开销:
- 每次 clone 都要原子操作增加引用计数
- 每次 lock 都要获取锁
- 高并发时锁竞争会导致线程唤醒/睡眠
- tokio 的
sync::Mutex虽然不阻塞线程,但依然串行化访问
channel 的开销:
- 消息发送是 lock-free 的快速路径
- 批量唤醒,调度更高效
- 没有 refcount 的原子操作
在实际的高负载场景下,channel 模式往往表现更好。Tokio 官方文档推荐的 mpsc channel 经过深度优化:批量唤醒减少调度开销,lock-free 快速路径避免原子操作竞争。
根据 Tokio 1.x 的基准测试,在 8 核机器上跑 10 万次/秒的消息传递,P99 延迟比 Arc<Mutex
什么时候 Arc<Mutex> 是可以的
也不是说 Arc<Mutex<T>> 就是万恶之源。有些场景它完全没问题:
- 竞争非常低:大家几乎不会同时访问
- 数据很少被修改:读多写少,考虑用
RwLock - 不在热路径上:比如启动时加载配置用一次
举个例子:Arc<Mutex<HashMap<String, String>>> 用来存储启动配置,完全没问题。但如果在每个请求处理路径上都用它,在高并发下可能就会成为瓶颈。
生产环境的 Rust 并发架构:Sharding 与 Actor 系统
在更大的系统里,这个模式会演化成更复杂的东西:
- Actor 系统
- Command bus
- 事件驱动架构
- 状态分片(Sharding)
比如分片,就是把一个大 state 切成多份,每份有自己的 owner 和 channel。这样一来,你可以并行处理,而且依然没有锁。
Hash(key) % 4
↓
┌───────┬───────┬───────┬───────┐
│Shard 0│Shard 1│Shard 2│Shard 3│
└───────┴───────┴───────┴───────┘
每个 shard 独立运行,互不干扰。
从 Arc<Mutex> 迁移到 Channel 的实际体验
我第一次把 Arc<Mutex<T>> 从核心服务里移除的时候,有种奇怪的感觉。
代码变简单了,更容易理解了,死锁这种事基本不可能发生了。负载下的行为也变得可预测了。
为什么?因为状态不再到处都是,它只活在一个地方。你要找 bug,去那一个地方找就行,而不是满世界搜索谁在什么时候改了什么。
写在最后
Arc<Mutex<T>> 不是恶魔。但它常常是一个信号,说明你的架构还默认"线程间共享可变内存是正常的"。
在 Rust 里,这不太正常。
Rust 想让你设计的是:所有权、隔离、消息传递、显式的并发。
当你开始顺着这个思路走,你会发现自己不再和语言打架了,系统也开始自然地扩展。
与其说是语法或库的选择,不如说是思考方式的转变:从"大家一起抢着用"变成"每个人都有自己的,通过沟通协作"。
传统语言习惯了前一种,Rust 鼓励后一种。
常见问题:
Q: 用 channel 会不会让代码变复杂?
A: 一开始可能需要适应,但逻辑清晰了。你明确定义了"可以做什么操作"(消息类型),而不是到处都是 mutex.lock().unwrap()。
Q: 所有 Arc<Mutex
Q: tokio::sync::Mutex 比 std::sync::Mutex 好用吗?
A: 在异步代码里应该用 tokio::sync::Mutex,因为它不阻塞线程。但问题依然存在:访问是串行的,高并发时会成为瓶颈。消息传递往往能更好地利用并发。