说句可能会让你有点不舒服的话:如果你的 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> 低约 40%。关键不是单个操作更快,而是没有竞争导致的吞吐量下降和尾部延迟尖刺。

什么时候 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> 都要替换掉吗? A: 不用。如果代码跑得好,性能没瓶颈,没必要动。重点是理解它可能成为瓶颈的场景,在设计新代码时有这个意识。

Q: tokio::sync::Mutex 比 std::sync::Mutex 好用吗? A: 在异步代码里应该用 tokio::sync::Mutex,因为它不阻塞线程。但问题依然存在:访问是串行的,高并发时会成为瓶颈。消息传递往往能更好地利用并发。