简介
在 .NET 里做并发控制时,最常见的默认答案通常还是:
lock (_gate)
{
// 临界区
}
这没有问题。
但有一类场景,普通互斥锁会显得有点“太保守”:
- 读很多
- 写很少
- 读操作彼此其实并不冲突
比如:
- 本地缓存查询
- 配置快照读取
- 内存索引查找
- 大量读、少量写的共享字典
这时候如果所有读操作也都互相排队,吞吐量往往会被白白压住。
ReaderWriterLockSlim 就是为这种场景准备的。
一句话先说透:
ReaderWriterLockSlim是 .NET 里专门为“读多写少”场景设计的轻量级读写锁。它允许多个读线程并发进入,但写线程必须独占。
所以这篇文章重点不是只讲 API,而是讲清楚:
- 它到底解决什么问题;
- 为什么它不等于“比
lock更高级”; UpgradeableReadLock到底是干什么的;- 什么场景适合它,什么场景反而不该用它;
- 它和
System.Threading.Lock、Monitor、SemaphoreSlim、ConcurrentDictionary的边界是什么。
ReaderWriterLockSlim 到底是什么?
它位于:
System.Threading
顾名思义,它是一个“读写分离”的同步原语。
和普通互斥锁最大的区别在于:
- 普通互斥锁:同一时间通常只允许一个线程进入
ReaderWriterLockSlim:同一时间可以允许多个读线程进入,但写线程必须独占
可以先把它想成一个很简单的规则系统:
- 读读不冲突,可以并发
- 读写冲突,不能并发
- 写写冲突,不能并发
它的价值就建立在这个前提上:
如果读取真的远多于写入,让读线程彼此不互相阻塞,整体吞吐量就可能明显更高。
它为什么存在?
因为普通 lock / Monitor 的策略太统一了:
- 不管你是读还是写
- 只要进临界区
- 大家都要排队
这种模型简单、稳妥、默认可用。
但在“读多写少”的场景里,它会带来一个明显问题:
- 明明只是多个线程读同一份稳定数据
- 彼此并不会修改状态
- 却还是被迫串行执行
这就是 ReaderWriterLockSlim 的出发点:
- 尽量放开读并发
- 继续保证写独占
- 让共享数据在读多写少时有更高吞吐
它的三种模式一定要分清
这是理解 ReaderWriterLockSlim 的核心。
1. Read Lock
_lock.EnterReadLock();
特点:
- 多个线程可以同时持有
- 适合纯读取操作
- 不能修改共享状态
2. Write Lock
_lock.EnterWriteLock();
特点:
- 完全独占
- 不允许其他读锁和写锁并发存在
- 适合修改共享状态
3. Upgradeable Read Lock
_lock.EnterUpgradeableReadLock();
这是很多人第一次接触时最容易忽略,但又最关键的一种模式。
它适合这种场景:
- 先读
- 再判断
- 最后可能需要写
也就是典型的:
check -> maybe update
为什么需要 UpgradeableReadLock?
因为很多人第一反应会写出这种代码:
_lock.EnterReadLock();
try
{
if (needUpdate)
{
_lock.EnterWriteLock(); // 风险很大
try
{
// 更新
}
finally
{
_lock.ExitWriteLock();
}
}
}
finally
{
_lock.ExitReadLock();
}
这类写法的问题在于:
- 你已经持有读锁
- 写锁要求没有其他读者
- 直接从普通读锁升级到写锁,很容易把自己卡住,甚至引发死锁或递归异常
所以 ReaderWriterLockSlim 专门提供了:
EnterUpgradeableReadLock()
它解决的不是“更快”,而是“更安全地处理先读后写”。
正确的升级锁写法是什么?
标准模式一般是这样:
private readonly ReaderWriterLockSlim _lock = new();
private readonly Dictionary<string, string> _cache = new();
public string GetOrAdd(string key, Func<string> valueFactory)
{
_lock.EnterUpgradeableReadLock();
try
{
if (_cache.TryGetValue(key, out var value))
{
return value;
}
_lock.EnterWriteLock();
try
{
if (_cache.TryGetValue(key, out value))
{
return value;
}
value = valueFactory();
_cache[key] = value;
return value;
}
finally
{
_lock.ExitWriteLock();
}
}
finally
{
_lock.ExitUpgradeableReadLock();
}
}
这段代码最值得记住的不是模板本身,而是这两个判断:
- 先用升级读锁做“读 + 决策”
- 真需要修改时,再进入写锁
而且通常还要再检查一次条件,避免在等待写锁期间,别的线程已经把状态改好了。
一个非常关键的事实:同一时刻只能有一个升级锁
这是很多人第一次使用时没意识到的地方。
UpgradeableReadLock 并不是“特殊读锁,多来几个也行”。
恰恰相反:
- 同一时刻只能有一个线程持有升级锁
这背后的原因很直接:
- 如果允许多个线程同时处于“我先读着,等会可能升级写”的状态
- 那大家彼此升级时就会非常容易形成僵局
所以升级锁是为了解决“安全升级”问题,不是为了提供另一种高并发读模式。
它的基本用法长什么样?
读操作:
private readonly ReaderWriterLockSlim _lock = new();
private readonly Dictionary<int, string> _data = new();
public string? Get(int id)
{
_lock.EnterReadLock();
try
{
return _data.TryGetValue(id, out var value) ? value : null;
}
finally
{
_lock.ExitReadLock();
}
}
写操作:
public void Set(int id, string value)
{
_lock.EnterWriteLock();
try
{
_data[id] = value;
}
finally
{
_lock.ExitWriteLock();
}
}
这里最重要的纪律只有一条:
每一个
EnterXxxLock都必须严格对应一个ExitXxxLock,并且始终用try/finally包住。
从源码和运行时视角看,它在优化什么?
如果把它和普通互斥锁放在一起看,核心差异其实不复杂:
- 普通互斥锁优化的是“简单统一的互斥模型”
ReaderWriterLockSlim优化的是“读并发”
换句话说,它不是让“锁”本身 magically 更快,而是通过放开读读并发,减少不必要的串行化。
所以它能带来收益的前提不是:
- “这里有共享状态”
而是:
- “这里有大量真正独立的读操作”
如果这个前提不成立,它的复杂度和管理成本就未必值得。
从源码心智模型看,它内部大致在管什么?
如果从运行时思路去理解,ReaderWriterLockSlim 并不是“三把完全独立的锁”,而更像是一个统一的状态机。
你可以粗略把它理解成内部要同时管理这些信息:
- 当前有多少读者
- 当前是否有写者
- 当前是否存在升级锁持有者
- 哪些线程正在等待读、写、升级
它真正难的地方不在于“加一把锁”,而在于要维护一组进入规则:
- 有写者时,新的读者和写者都不能直接进入
- 有普通读者时,写者不能进入
- 有升级锁时,其他升级请求不能同时成功
- 升级锁持有者在满足条件后,才可以进一步进入写模式
所以它的复杂度,本质上来自状态协调,而不是来自某一个单独 API。
这也是为什么它虽然很有用,但绝不是默认锁的替代品。
为什么说它的收益来自“读并发”,不是“锁实现更神”?
这是很容易在面试里被追问的一点。
很多人会误以为:
ReaderWriterLockSlim是更高级的锁- 所以单次加锁解锁天然比
lock更快
这个理解是偏的。
它真正能赢的地方通常是:
- 原本 10 个读线程都得排队
- 现在这 10 个读线程可以一起读
也就是说,它优化的核心不是“单次锁操作的常数项”,而是:
- 少做不必要的串行化
所以如果你的共享状态:
- 读并不多
- 或者读本身很短
- 或者写很频繁
那它的收益就很可能被自己的复杂度吃掉。
公平性要怎么理解?为什么有时会感觉“读突然不让进了”?
这也是源码和运行时层面很值得讲清楚的一点。
很多人会以为读写锁的规则只是:
- 没写者时,读者都能进
但现实里如果一直这么放开读者,写线程就可能长期抢不到机会。
所以 ReaderWriterLockSlim 在设计上会考虑一个更现实的取舍:
- 当写线程已经在等待时,新的读者不一定还能继续无限制插队
这个策略的目的不是让读更快,而是:
- 避免写线程长期饥饿
- 在吞吐量和公平性之间做平衡
因此你在压测里有时会看到一种现象:
- 平时读并发很高
- 一旦写者开始排队,后续新读者的进入节奏会变化
这不是它“失效了”,而是它在避免系统彻底偏向读侧。
它什么时候真的有价值?
一般要同时满足这些条件:
- 共享状态确实存在
- 读远多于写
- 读操作本身足够频繁
- 读操作彼此独立,不需要互斥
- 写操作相对少,而且能尽量短
典型例子包括:
- 本地内存缓存
- 配置热更新后的读取
- 读多写少的索引结构
- 组件内部的元数据查询
它什么时候反而不值得?
下面这些情况,往往不适合优先考虑它:
- 写操作很多
- 读写比例并不悬殊
- 临界区很小,普通
lock已经足够 - 锁内有 I/O、网络、数据库访问
- 你只是想“换个更高级的锁试试”
一句话说透:
ReaderWriterLockSlim不是“通用锁升级版”,它只在读多写少时更有意义。
它和 System.Threading.Lock / Monitor 怎么选?
可以先这样理解:
System.Threading.Lock/Monitor:默认同步互斥工具ReaderWriterLockSlim:为读多写少额外付出复杂度,换读并发
所以如果你的场景只是:
- 普通共享状态保护
- 临界区不大
- 读写都挺常见
那默认答案通常仍然是:
.NET 9 + C# 13上优先System.Threading.Lock- 其他情况下看
lock/Monitor
只有在你能明确证明:
- 读远多于写
- 读并发是瓶颈
这时候 ReaderWriterLockSlim 才更值得上场。
它和 SemaphoreSlim 怎么选?
这两个解决的不是同一类问题。
ReaderWriterLockSlim解决的是同步代码里的读写互斥SemaphoreSlim解决的是计数型并发控制,并支持异步等待
如果场景里出现:
await- 异步限流
- 允许
N个并发而不是单写多读模型
通常就该优先想到 SemaphoreSlim,而不是 ReaderWriterLockSlim。
它和 ConcurrentDictionary 怎么看边界?
这是一个非常实用的问题。
很多时候你真正想解决的,不是“需要一个读写锁”,而是:
- 需要一个线程安全字典
这时候就应该先想:
ConcurrentDictionary<TKey, TValue>
因为:
- 你的需求也许只是安全地增删查改某个并发容器
- 而不是手动管理一整套读锁、写锁、升级锁协议
更务实地说:
- 如果并发对象本身已经有成熟的线程安全容器,优先考虑容器
- 如果你保护的是“一组状态的一致性”,或者跨多个字段/多个结构的复合读写,那才更像
ReaderWriterLockSlim的工作
递归策略要怎么理解?
ReaderWriterLockSlim 默认使用的是:
LockRecursionPolicy.NoRecursion
这也是推荐的默认值。
原因很简单:
- 递归锁更复杂
- 更容易让锁关系失控
- 更容易把 bug 藏深
当然,它也支持:
LockRecursionPolicy.SupportsRecursion
但这通常不该是第一选择。
工程上更稳的判断是:
- 如果你必须依赖递归策略,先反过来问一句:锁设计是不是已经过于复杂了?
再补一个面试里很容易追问的点:
- 升级锁线程无论递归策略如何,都可以升级成写锁或降级成读锁;
- 但一个最初只拿了普通读锁的线程,不允许再升级成升级锁或写锁,这正是为了避免高概率死锁。
所以默认的 NoRecursion,你可以理解成:
运行时在强迫你把锁关系写得更清楚,而不是帮你纵容复杂递归锁设计。
它常见的坑有哪些?
1. 从普通读锁里直接申请写锁
这是最经典的误用之一。
如果有“先读后可能写”的需求,优先考虑升级锁,而不是直接在读锁里抢写锁。
2. 把升级锁当成高并发读锁用
升级锁一次只能有一个线程持有。
如果你把大量纯读操作都放进升级锁,吞吐量反而会下降。
3. 锁里做慢操作
无论是读锁、写锁还是升级锁,只要里面放:
- I/O
Thread.Sleep- 网络请求
- 数据库访问
整体并发性能都会被拖垮。
4. 忘记成对释放
EnterReadLock / ExitReadLock
EnterWriteLock / ExitWriteLock
EnterUpgradeableReadLock / ExitUpgradeableReadLock
这些必须一一匹配。
5. 以为“用了读写锁就一定更快”
这是最常见的误判。
如果读写比例没有明显倾斜,或者锁粒度太粗,最后很可能是复杂度上去了,收益却不明显。
在 async/await 代码里能不能用?
实战上可以直接记成:
- 不要用。
原因和很多同步锁一样:
- 它是同步线程锁
- 不是异步协调原语
- 不能优雅跨过
await
如果你在异步代码里需要控制并发或互斥,通常应该看:
SemaphoreSlimAsyncLock
而不是 ReaderWriterLockSlim。
一个非常务实的选择顺序
如果你在做并发控制,可以先按这个顺序判断:
- 单个共享值能不能用
Interlocked? - 普通同步互斥是否用
System.Threading.Lock/lock就够了? - 只有在“读多写少”非常明确时,再考虑
ReaderWriterLockSlim - 如果是异步等待,改看
SemaphoreSlim/AsyncLock - 如果只是线程安全容器需求,先看
ConcurrentDictionary等现成并发集合
这个顺序很重要。
因为很多时候真正的优化,不是“上更复杂的锁”,而是“选更贴合问题形状的同步原语”。
面试里怎么答比较到位?
如果面试官问:
“ReaderWriterLockSlim 和 lock 的区别是什么?”
一个比较稳的回答可以是:
ReaderWriterLockSlim是为读多写少场景设计的读写锁。它允许多个读线程并发进入,但写线程必须独占;而普通lock/Monitor不区分读写,进入临界区就互斥。它的优势来自读并发,不是来自单次加锁动作本身更神奇。如果场景不是明显的读多写少,或者锁内有慢操作,那它未必比普通互斥锁更合适。
如果继续追问“升级锁是干什么的”,就答:
UpgradeableReadLock用来解决“先读后可能写”的场景。普通读锁不能安全地直接升级成写锁,所以需要一个专门的可升级模式来做条件判断,再在必要时进入写锁。
如果继续追问“它内部为什么这么复杂”,可以补一句:
因为它本质上是在维护一个读者数、写者状态、升级锁状态和等待队列共同组成的状态机。它的难点不在单次加锁,而在保证读并发、写独占、升级安全和公平性之间的平衡。
如果继续追问“为什么有时读线程也会被挡住”,可以答:
因为如果写线程已经开始等待,读写锁不能无条件让新读者一直插队,否则写线程可能长期饥饿。所以它会在吞吐和公平性之间做取舍,这也是实际运行时常见的行为。
如果追问“递归策略怎么答才像真的理解了”,就答:
默认是
NoRecursion,这是推荐值。因为读写锁本来就比普通互斥锁复杂,再加递归很容易把锁关系写乱。只有明确需要时才考虑SupportsRecursion,而且一个普通读锁线程不能再升级成升级锁或写锁,这是运行时故意限制的危险路径。
如果再追问“最大的坑是什么”,优先答这三个:
- 从普通读锁里直接抢写锁
- 把大量纯读流量错误地放进升级锁
- 以为它天然比
lock更高级、更快
总结
ReaderWriterLockSlim 的本质,不是“比普通锁更强”,而是:
用更复杂的锁协议,换取读多写少场景下更高的读并发吞吐。
最值得记住的其实只有这几条:
- 它适合读多写少,不适合把所有共享状态都往里塞;
- 读锁可以并发,写锁必须独占;
- “先读后可能写”要优先想到
UpgradeableReadLock; - 升级锁一次只能有一个,不要把它当普通读锁滥用;
- 如果没有明确的读并发收益,优先用更简单的
System.Threading.Lock/lock往往更稳。