Rust 读多写少还在用 Mutex?别让线程白白排队

各位 Rust 爱好者们,大家好。

对于任何编程语言而言,如果我们需要让多个线程访问同一份数据,互斥锁 Mutex 往往是我们的第一选择。

Rust 也不例外。

Mutex 提供了一个简单的保证:同一时间,只有一个线程可以持有这把锁,访问它保护的数据。

不过,这个保证也有代价。

如果大多数线程都只是在读取,会怎样?

这篇文章想聊聊独占锁的隐藏成本:为什么 Mutex 可能成为读多写少场景中的瓶颈,以及在 Rust 中换一种同步方式,如何让更多工作有机会并行执行,提高吞吐量。

先从问题说起。

一视同仁

Mutex 提供的保证很简单:

同一时间,只有一个线程能访问它保护的数据,至于这个线程是在读还是在写,Mutex 并不区分。

比如,有一份共享配置,多个线程都需要读取:

rust 复制代码
use std::sync::Mutex;
let config = Mutex::new(Config::new());
// Thread 1 的线程体(示意)
{
    let guard = config.lock().unwrap();
    read_config(&guard);
}
// Thread 2 的线程体(示意)
{
    let guard = config.lock().unwrap();
    read_config(&guard);
}
// 其他线程类似

这两个线程都只是在读取数据。但因为数据由 Mutex 保护,第二个线程必须等第一个线程释放锁后,才能继续。

该示例省略了跨线程共享锁的准备工作:在 Rust 中,要真正让多个线程共享一把锁,通常需要用 Arc 包装它,也可以使用作用域线程或 static。这里有意省略了这些准备代码,把重点放在锁的行为上。

举个例子,假设我有多个读线程。

假设读线程 1 先获取锁,它释放锁后,其他读线程才能继续获取锁。读线程仍需逐个进入临界区,但具体顺序不一定按线程编号排列。

数据没有变化,也没有写操作与读取冲突。但 Mutex 还是把原本可能并行执行的工作变成了串行执行------这就是它的隐藏成本。

这种场景的主要问题不在于 Mutex 本身很慢,而在于独占访问会阻止多个线程同时推进工作。

在读多写少的场景中,这一点尤其明显:

想想配置、路由表、缓存、功能开关,以及其他经常读取、偶尔修改的共享状态。

如果几十个线程都在尝试读取同一份数据,却非得依次获取独占锁,就可能产生不必要的锁竞争,让线程花更多时间等待,而不是执行工作。

于是,问题来了:

读线程真的需要等待其他读线程吗?

让读线程一起读取

如果多个线程都只是在读取共享数据,它们未必需要独占访问。

这时候就可以考虑读写锁。

Rust 提供的 RwLock 正是用来处理这类访问模式的。它允许多个线程同时持有读锁,而写锁仍然是独占的。

这里先限定一下范围:本文讨论的是 std::sync::RwLock 和操作系统线程。如果你写的是异步代码,对应的锁通常由运行时提供,比如 tokio::sync::RwLock,它的取舍会有些不同。

read() 和 write() 返回 Result,也是因为需要报告锁中毒的情况,示例因此调用了 .unwrap():如果线程在持有写锁时发生 panic,锁可能进入"中毒"(poisoned)状态,后续调用会报告这一情况。

rust 复制代码
use std::sync::RwLock;
let config = RwLock::new(Config::new());
// Multiple threads can read concurrently
let guard = config.read().unwrap();
read_config(&guard);

原本,读线程只能一个接一个地执行:

text 复制代码
READ → READ → READ → READ

现在,它们可以同时推进工作:

区别很简单:

Mutex 对每次操作都提供独占访问,而 RwLock 区分了读访问和写访问。

注意!这并不意味着 RwLock 一定更快。

它去掉的是读多写少场景中一个不必要的限制:多个读线程可以同时持有读锁,潜在的性能提升就来自这里。不过,锁的优先级策略取决于操作系统实现;等待中的写线程也可能阻止新的读线程获取锁。

那在实际使用中,差别到底能有多大?

提醒一下:RwLock 同一时间只允许一个写线程,或者多个读线程持有锁。

提速从哪里来

可能的提速,并不是因为 RwLock 是一把神奇的、更快的锁,而是因为原本可以并行的工作,终于能并行执行了。

假设一个读多写少的工作负载有 8 个线程:

text 复制代码
Mutex

Reader 1 ────────┐
Reader 2 ── wait ┤
Reader 3 ── wait ┤
Reader 4 ── wait ┤
Reader 5 ── wait ┤
Reader 6 ── wait ┤
Reader 7 ── wait ┤
Reader 8 ── wait ┘

尽管这 8 个线程都只需要读取同一份数据,Mutex 仍然只允许其中一个线程进入临界区。

换成 RwLock:

text 复制代码
Reader 1 ─────────────
Reader 2 ─────────────
Reader 3 ─────────────
Reader 4 ─────────────
Reader 5 ─────────────
Reader 6 ─────────────
Reader 7 ─────────────
Reader 8 ─────────────
         │
       RwLock
         │
    Shared Data

多个读线程就可以同时推进工作。

也就是说,原本是:

text 复制代码
R → R → R → R

现在可以是:

text 复制代码
R
R   } running concurrently
R
R

随着并发增加,这个区别就变得重要了。

使用 Mutex 时,读线程越多,锁竞争可能越严重,因为每个线程都得等待独占访问。使用 RwLock 时,读线程可以并发执行,这对读多写少的工作负载可能很有帮助。

不过,并发也有代价,并发更多,不代表性能一定更好。

RwLock 自身也有同步开销。如果写操作频繁,或者临界区非常短,它的优势就可能消失。

部分原因是:以标准库基于 futex 的实现为例,读线程虽然不会修改共享数据,但获取读锁时,仍然需要更新锁内部用于记录当前持锁读线程数量的计数。这个计数保存在锁的共享状态中,每个读线程获取和释放锁时都会更新它。多个核心同时做这件事,就需要付出同步这个计数器的成本。如果读取操作非常短,这种协调的开销最终可能比读取本身还大。

所以,目标不是把每个 Mutex 都换成 RwLock。

我们需要弄清楚:什么时候,并发读取带来的收益值得付出这些代价?

接下来就有意思了:有些情况下,选择 RwLock 反而不合适。

什么时候不该用 RwLock

大多数介绍 RwLock 的文章,都在说什么时候应该用它。

我们看看另一面:什么时候应该继续用 Mutex?

当读取次数远多于写入次数时,RwLock 更容易发挥优势。但离开这个适用范围,它的优势可能很快就消失了。

写操作频繁

如果共享数据经常被修改,读线程和写线程就会不断争夺访问权。

text 复制代码
READ → WRITE → READ → WRITE → READ → WRITE

这种情况下,允许多个读线程同时访问,并不能带来多少收益。Mutex 更简单,也可能更高效。

临界区非常短

如果被保护的操作很短,线程几乎不花什么时间持有锁,RwLock 就没那么有吸引力了。

比如,线程只做一次很小的读取,马上就释放锁,那么多个读线程并发执行,可能也很难带来明显收益。

此时,更简单的 Mutex 可能是更好的选择,尤其是在锁竞争本来就不严重的情况下。

临界区越短、锁竞争越少,RwLock 能优化的空间就越小。

锁竞争很少

如果只有少数线程偶尔访问数据,可能根本没有值得解决的锁竞争。

此时使用 RwLock,可能只是增加了复杂度,却没有带来可测量的收益。

提醒一下:选择这两种锁之前,可以先考虑能否重新设计共享状态,减少锁竞争,甚至避开锁。例如,使用原子操作、分片、不可变数据或消息传递。

这里不是想说 Mutex 比 RwLock 好,也不是说 RwLock 比 Mutex 好。选哪个,取决于你的工作负载。

一点想法

Mutex 并不差。可能对于大多数开发者来讲,很多时候它就是你需要的工具。

问题在于:有些工作负载并不需要独占访问,我们却仍然让它们独占访问。

如果多个线程一直在读取共享数据,却让每个读线程都等待其他读线程,就可能无谓地限制并发。

这时,RwLock 能帮上忙:多个读线程可以同时推进工作,而写操作仍然保持独占。

但 RwLock 并不是 Mutex 的通用替代品。

还是要看看你的工作负载:

这篇文章,并不是告诉你"用 RwLock 替代 Mutex"。

而是:

根据数据实际的访问方式,选择同步方式。

有时候,最大的性能提升,并不来自让锁本身变快。而是来自发现:这些工作原本就不需要串行执行。

哦,对了,如果能避开共享状态和锁竞争,还有可能更快,不过那就需要重新考虑数据的组织方式了。

相关推荐
EatFan2 小时前
2026 Rust 后端技术栈选型:Axum 0.8 + Tokio + SQLx 全链路怎么搭
开发语言·后端·rust·tokio·serde·axum·sqlx
Zoom12 小时前
【开源】7 天,我用 Rust 复刻了 FinalShell!Rhost v1.0.0 发布
rust·electron·shell
wflynn16 小时前
GitHub 今日推荐|ts-rust:把微软 TypeScript-Go 编译器逐行移植成 Rust 实现
rust·typescript·开源·github·compiler·tsc
miofly17 小时前
GitHub 今日推荐|wordcraft:用 Rust 重写 Word 内核并开放给 AI 调用
rust·开源·github
Amos_Web18 小时前
Rspack 源码解析(十八):Tree Shaking 如何用 SideEffects 重写模块连接
前端·rust·前端框架
Source.Liu19 小时前
【A11】Tauri v2 + 原生前端(无框架)项目笔记:从零到登录界面
rust
Kapaseker20 小时前
Rust 1.99.0 发布,来看看这次 Rust 更新了什么
rust
卷无止境1 天前
用Rust重写:三条清晰的收益曲线
后端·rust
geovindu1 天前
rust: Simple Factory Pattern(续)
后端·设计模式·rust·简单工厂模式·创建型模式