CodeGym /课程 /C# SELF /使用 ReaderWriterLockSlim

使用 ReaderWriterLockSlim 的优化

C# SELF
第 56 级 , 课程 4
可用

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

lock
ReaderWriterLockSlim
并发读取
并发写入
支持可提升的读取 是 (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 更简单也更快。

1
调查/小测验
线程同步第 56 级,课程 4
不可用
线程同步
共享资源问题
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION