1. 为什么普通的 lock 可能不够?
想一想:我们有一个共享资源——比如一个客户列表,保存在多线程应用里。大部分时间我们只是读取这个列表——比如在界面里显示数据、生成报表、筛选和排序信息。只有偶尔有人添加或删除客户。
如果我们用常规的 lock,那么任何想读数据的线程都得等其他线程完成读或更糟的是写。如果读取操作占到大约 99% 的话,会发现我们白白拖慢了所有读者,明明它们可以并行工作。
这时就有一种特殊的锁派上用场—— Reader-Writer Lock。它的关键思想是:多个线程可以同时读,但如果有人要改数据,就拿到独占访问,阻塞读者。
2. 理论:什么是 ReaderWriterLockSlim
关于类的简述
- ReaderWriterLock — 较旧、慢,可能导致 deadlock。只有在需要支持 .NET Framework 2.0 时才用它。
- ReaderWriterLockSlim — 快速且现代的替代(Slim: “瘦身的”、“精简的”)。这个类针对频繁的并发读/写操作做了优化,推荐用于线程多且读远多于写的场景。
工作原理
ReaderWriterLockSlim 实现了三种锁定模式:
- Read Lock(读取锁):多个线程可以同时读取。
- Write Lock(写入锁):只有一个线程可以修改数据,且其他人不能读取。
- Upgradeable Read Lock(可提升的读取锁):一次只有一个线程可以持有此锁;它可以读取,但在需要时能“提升”为 write lock。
锁定兼容表
| 读线程 | 写线程 | 持有 Upgradeable Read Lock 的线程 | |
|---|---|---|---|
| 读 | ✅ | ⛔ | ✅/⛔ |
| 写 | ⛔ | ⛔ | ⛔ |
| Upgradeable Read | ✅* | ⛔ | ⛔ |
✅ — 允许
⛔ — 被阻塞
— 只有当 upgradeable read lock 没有被“提升”为 write 时
什么时候该(或不该)用 ReaderWriterLockSlim
推荐使用场景:
- 很多并发线程,主要是读取共享数据(参考表、缓存、配置)。
- 写操作很少(例如每秒/每分钟/每小时一次)。
- 需要最大化读取性能,不想阻塞读线程。
不建议:
- 读写比例相当(这种情况下普通的 lock 更合适)。
- 数据频繁变更(收益有限)。
3. 在实践中使用 ReaderWriterLockSlim
把我们的教学应用往复杂方向扩展一下!之前我们做了一个简单的客户目录,尝试了多线程访问集合。现在任务更实际:假设有一个客户列表,多个线程会访问(比如一个服务通知所有客户新活动,另一个线程添加新客户)。
先描述下我们的客户集合:
// 客户类 — 来自之前讲座的熟悉角色
public class Client
{
public int Id { get; set; }
public string Name { get; set; }
}
现在创建一个通过 ReaderWriterLockSlim 实现的线程安全包装:
using System;
using System.Collections.Generic;
using System.Threading;
public class ClientDirectory
{
private readonly List<Client> _clients = new List<Client>();
// Slim-lock 用于读/写
private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
// 添加客户(write lock)
public void AddClient(Client client)
{
_lock.EnterWriteLock(); // 只有一个线程可以添加客户
try
{
_clients.Add(client);
Console.WriteLine($"[线程 {Thread.CurrentThread.ManagedThreadId}] 已添加客户 {client.Name}");
}
finally
{
_lock.ExitWriteLock();
}
}
// 获取客户列表的副本(read lock)
public List<Client> GetClients()
{
_lock.EnterReadLock(); // 多个线程可以同时读取
try
{
// 返回副本,防止别人意外修改原始集合
return new List<Client>(_clients);
}
finally
{
_lock.ExitReadLock();
}
}
}
4. 多线程读取与周期性写入
假设有 5 个线程周期性读取所有客户(“生成报表”或只是查看有哪些客户),另有一个线程偶尔添加新客户。
using System.Threading;
class Program
{
static void Main()
{
var directory = new ClientDirectory();
// 用于添加客户的线程
var writerThread = new Thread(() =>
{
for (int i = 1; i <= 5; i++)
{
directory.AddClient(new Client { Id = i, Name = $"客户 {i}" });
Thread.Sleep(700); // 模拟长时间操作
}
});
// 几个读者线程
for (int j = 0; j < 5; j++)
{
int readerId = j + 1;
new Thread(() =>
{
for (int k = 0; k < 10; k++)
{
var clients = directory.GetClients();
Console.WriteLine($"[读者 {readerId}][线程 {Thread.CurrentThread.ManagedThreadId}] 客户总数: {clients.Count}");
Thread.Sleep(200); // 读得比写得频繁
}
}).Start();
}
writerThread.Start();
writerThread.Join();
// 给读者一些时间完成:
Thread.Sleep(3000);
}
}
几乎总是多个线程可以同时读取客户列表(互不等待),但当有线程添加客户时,其他线程必须等到写操作完成。这能在不额外阻塞读者的情况下服务更多请求。
5. 可提升的读取锁:UpgradeableReadLock
有时候线程先读数据,只有在满足某个条件时才想改它。看起来可以先拿 read lock 检查条件,然后释放 read lock 再去拿 write lock。但在“中间”别的线程可能已经改了数据!这时可提升锁派上用场。
用法很简单:
public bool AddClientIfNotExists(string name)
{
_lock.EnterUpgradeableReadLock(); // 一次只有一个线程能持有这种锁
try
{
bool exists = _clients.Exists(c => c.Name == name);
if (!exists)
{
_lock.EnterWriteLock(); // 提升为 write lock
try
{
_clients.Add(new Client { Name = name });
return true;
}
finally
{
_lock.ExitWriteLock();
}
}
return false;
}
finally
{
_lock.ExitUpgradeableReadLock();
}
}
线程先以读方式访问,如果需要修改就提升锁级别,期间不会把集合让给其他线程。
6. 有用的细节
对比:ReaderWriterLockSlim vs lock
|
|
|
|---|---|---|
| 并发读取 | 否 | 是 |
| 并发写入 | 否 | 否 |
| 支持可提升的读取 | 否 | 是 (EnterUpgradeableReadLock) |
| 速度 | 非常快 | 在频繁读取时更快 |
| 内存 | 最小 | 略多一些 |
| 简单性 | 容易 | 更复杂,有细节需要注意 |
在真实项目里的样子
在大型应用里,有些大表或缓存会被成百上千个线程读取,但很少修改,这时使用 ReaderWriterLockSlim 能避免读取被“卡住”。例如:
- 微服务共享的配置缓存。
- 用于金融计算的参考数据存储。
- 消息路由的内部缓存。
强调一下——大多数情况下,特别是你刚开始学多线程时,普通的 lock 更简单也更安全。ReaderWriterLockSlim 是为那些确实存在大量并发读取的场景准备的解决方案。
可视化:访问示意图
flowchart TD
A[读者线程 1] --Read--> D[ReaderWriterLockSlim]
B[读者线程 2] --Read--> D
C[写者线程] --Write--> D
D --Read 允许--> E[读取公共集合]
D --Write 阻塞 读取/写入--> F[写入公共集合]
只有读取时——对所有人开放;尝试写入时——所有人都要等写完成。
7. ReaderWriterLockSlim:细节和常见错误
Lock Recursion(锁递归): 默认情况下 ReaderWriterLockSlim 不允许同一线程重复获取同一类型的锁。也就是说,如果线程已经持有 read lock 再试图第二次获取,会抛异常。write lock 同理。不过,UpgradeableReadLock 可以从内部提升为 write lock。
不要搞错顺序: 先获取 EnterUpgradeableReadLock() → 在它内部再调用 EnterWriteLock()。不要反过来!
异常情况: 如果你不释放锁(比如没调用 ExitWriteLock()),其他线程会一直等待。所以总是用 try { ... } finally { ... }。
别长时间持有: 不要在锁内执行长时间的 IO 操作。线程持锁越久,其他线程的延迟越大。尽量快速退出锁区。
评估复杂性: 不要用 ReaderWriterLockSlim 来保护那些很小的操作,那种场景下普通的 lock 更简单也更快。
GO TO FULL VERSION