C# 读写锁

作者: LH_晴 | 来源:发表于2018-04-04 21:31 被阅读58次

    在多线程编程时,开发人员经常会遭遇多个线程读写某个资源的情况。这就需要进行【线程同步】来保证线程安全。一般情况下,我们的同步措施是使用锁机制。但是,假如线程只对资源进行读取操作,那么根本不需要使用锁;反之,假如线程只对资源进行写入操作,则应当使用互斥锁(比如使用 Monitor 类等)。还有一种情况,就是存在多个线程对资源进行读取操作,同时每次只有一个线程对资源进行独占写入操作(多用户读/单用户写) 。

    对一个对象的读取次数远远大于修改次数,如果只是简单的用 lock 方式加锁,则会影响读取的效率。而如果采用读写锁,则多个线程可以同时读取该对象,只有等到对象被写入锁占用的时候,才会阻塞。

    简单的说,当某个线程进入读取模式时,此时其他线程依然能进入读取模式;假设此时一个线程要进入写入模式,那么他不得不被阻塞,直到读取模式退出为止。

    同样的,如果某个线程进入了写入模式,那么其他线程无论是要写入还是读取,都是会被阻塞的。

    ReaderWriterLock 类

    .NET Framework BCL 在 1.1 版本时,给我们提供了一个 ReaderWriterLock 类来面对此种情景。但是很遗憾,Microsoft 官方不推荐使用该类。Jeffrey Richter 也在他的《CLR via C#》一书中对它进行了严厉的批判。下面是该类不受欢迎的主要原因:

    性能。这个类实在是太慢了。比如它的 AcquireReaderLock 方法比 Monitor 类的 Enter 方法要慢5倍左右,而等待争夺写锁甚至比Monitor 类慢6倍。

    策略。假如某个线程完成写入操作后,同时面临读线程和写线程等待处理。ReaderWriterLock 会优先释放读线程,而让写线程继续等待。但我们使用读写锁是因为存在大量的读线程和非常少的写线程,这样写线程很可能必须长时间地等待,造成写线程饥饿,不能及时更新数据。更槽糕的情况是,假如写线程一直等待,就会造成活锁。反之,我们让 ReaderWriterLock 采取写线程优先的策略。如果存在多个写线程,而读线程数量稀少,也会造成读线程饥饿。幸运的是,现实实践中,这种情况很少出现。一旦发生这种情况,我们可以采取互斥锁的办法。

    递归。ReaderWriterLock 类支持锁递归。这就意味着该锁清楚的知道目前哪个线程拥有它。假如拥有该锁的线程递归尝试获得该读写锁,递归算法允许该线程获得该读写锁,并且增加获得该锁的计数。然而该线程必须释放该锁相同的次数以便线程不再拥有该锁。尽管这看起来是个很好的特性,但是实现这个“特性”代价太高。首先,因为多个读线程可以同时拥有该读写锁,这必须让该锁为每个线程保持计数。此外,还需要额外的内存空间和时间来更新计数。这个特性对 ReaderWriterLock 类可怜的性能贡献极大。其次,有些良好的设计需要一个线程在此处获得该锁,然后在别处释放该锁(比如 .NET 的异步编程架构)。因为这个递归特性,ReaderWriterLock 不支持这种编程架构。

    资源泄漏。在 .NET 2.0 之前的版本中, ReaderWriterLock 类会造成内核对象泄露。这些对象只有在进程终止后才能再次回收。幸运的是,.NET 2.0 修正了这个 Bug 。

    此外,ReaderWriterLock 还有个令人担心的危险:非原子性操作。它就是 UpgradeToWriteLock 方法。这个方法实际上在更新到写锁前先释放了读锁。这就让其他线程有机会在此期间乘虚而入,从而获得读写锁且改变状态。如果先更新到写锁,然后释放读锁,假如两个线程同时更新将会导致另外一个线程死锁。

    所以 Microsoft 决定构建一个新类来一次性解决上述所有问题,这就是 ReaderWriterLockSlim 类。本来可以在原有的 ReaderWriterLock 类上修正错误,但是考虑到兼容性和已存在的 API ,Microsoft 放弃了这种做法。当然也可以标记 ReaderWriterLock 类为 Obsolete,但是由于某些原因,这个类还有存在的必要。

    ReaderWriterLockSlim 类

    表示用于管理资源访问的锁定状态,可实现多线程读取或进行独占式写入访问。

    使用 ReaderWriterLockSlim 来保护由多个线程读取但每次只采用一个线程写入的资源。 ReaderWriterLockSlim 允许多个线程均处于读取模式,允许一个线程处于写入模式并独占锁定状态,同时还允许一个具有读取权限的线程处于可升级的读取模式,在此模式下线程无需放弃对资源的读取权限即可升级为写入模式。

    这个新的读写锁类性能跟 Monitor 类大致相当,大概在 Monitor 类的 2 倍之内。而且新锁优先让写线程获得锁,因为写操作的频率远小于读操作。通常这会导致更好的可伸缩性。起初,ReaderWriterLockSlim 类在设计时考虑到相当多的情况。比如在早期 CTP 的代码还提供了PrefersReaders, PrefersWritersAndUpgrades 和 Fifo 等竞争策略。但是这些策略虽然添加起来非常简单,但是会导致情况非常的复杂。所以 Microsoft 最后决定提供一个能够在大多数情况下良好工作的简单模型。

    注意 ReaderWriterLockSlim 类似于 ReaderWriterLock,只是简化了递归、升级和降级锁定状态的规则。 ReaderWriterLockSlim 可避免多种潜在的死锁情况。 此外,ReaderWriterLockSlim 的性能明显优于 ReaderWriterLock。 建议在所有新的开发工作中使用 ReaderWriterLockSlim。

    默认情况下 ReaderWriterLockSlim 的新实例使用 LockRecursionPolicy.NoRecursion 标志创建,并不允许递归。 对于所有新开发,建议使用此默认策略,因为递归带来不必要的复杂情况,从而使您的代码更容易出现死锁。 若要简化从现有的项目使用 Monitor 或 ReaderWriterLock,您可以使用 LockRecursionPolicy.SupportsRecursion 标志来创建 ReaderWriterLockSlim 的实例 ,允许使用递归。

    一个线程可以进入锁定状态的三种模式︰ 读取模式、 写入模式和可升级模式(可升级的读取模式 )。

    ReaderWriterLockSlim 类提供了可升级模式,这种模式通常适用于在其中一个线程读取受保护资源的情况下,如果满足某个条件,可能需要对其进行写入。 这种方式和读取模式的区别在于它可以通过调用 EnterWriteLock 或 TryEnterWriteLock 方法升级为写入模式。 因为每次只能有一个线程处于可升级模式。进入可升级模式的线程,不会影响读取模式的线程,即当一个线程进入了可升级模式,任意数量的线程可以同时进入读取模式,不会阻塞。如果有多个线程已经在等待获取写入锁,那么运行 EnterUpgradeableReadLock 将会阻塞,直到那些线程超时或者退出写入锁。

    ReaderWriterLockSlim 具有托管线程关联;也就是说,每个 Thread 对象必须使用自己的方法调用进入和退出锁模式。 任何线程都不可以更改另一个线程的模式。

    ReaderWriterLockSlim 的更新锁

    现在让我们更加深入的讨论一下更新模型。UpgradeableRead 锁定模式允许安全的降级到 Read 模式或升级到 Write 模式。还记得先前 ReaderWriterLock 的更新是非原子性,危险的操作吗(尤其是大多数人根本没有意识到这点)?现在提供的新读写锁既不会破坏原子性,也不会导致死锁。新锁一次只允许一个线程处在 UpgradeableRead 模式下。

    一旦该读写锁处在 UpgradeableRead 模式下,线程就能读取某些状态值来决定是否降级到 Read 模式或升级到 Write 模式。遗憾的是,CLR 团队移除了 DowngradeToRead 和 UpgradeToWrite 两个方法。如果要降级到读锁,只要简单调用 EnterReadLock 方法,然后再调用 ExitUpgradeableReadLock 方法即可。如果要升级到写锁,只要简单调用 EnterWriteLock 方法即可:这可能要等待,直到不再有任何线程在 Read 模式下持有锁。

    ReaderWriterLockSlim 的递归策略

    新的读写锁还有一个有意思的特性就是它的递归策略。默认情况下,除已提及的降级到读锁和升级到写锁之外,所有的递归请求都不允许。这意味着你不能连续两次调用 EnterReadLock,其他模式下也类似。如果你这么做,CLR 将会抛出 LockRecursionException 异常。当然,你可以使用 LockRecursionPolicy.SupportsRecursion 的构造函数参数让该读写锁支持递归锁定。但不建议对新的开发使用递归,因为递归会带来不必要的复杂情况,从而使你的代码更容易出现死锁现象。

    有一种特殊的情况永远也不被允许,无论你采取什么样的递归策略。这就是当线程持有读锁时请求写锁。Microsoft 曾经考虑提供这样的支持,但是这种情况太容易导致死锁。所以 Microsoft 最终放弃了这个方案。

    此外,这个新的读写锁还提供了很多对应的属性来确定线程是否在指定模型下持有该锁。比如 IsReadLockHeld, IsWriteLockHeld 和 IsUpgradeableReadLockHeld 。你也可以通过 WaitingReadCount,WaitingWriteCount 和 WaitingUpgradeCount 等属性来查看有多少线程正在等待持有特定模式下的锁。CurrentReadCount 属性则告知目前有多少并发读线程。RecursiveReadCount, RecursiveWriteCount 和 RecursiveUpgradeCount 则告知目前线程进入特定模式锁定状态下的次数。

    小结

    这篇文章分析了 .NET 中提供的两个读写锁类。然而 .NET 3.5 提供的新读写锁 ReaderWriterLockSlim 类消除了 ReaderWriterLock 类存在的主要问题。与 ReaderWriterLock 相比,性能有了极大提高。更新具有原子性,也可以极大避免死锁。更有清晰的递归策略。在任何情况下,我们都应该使用 ReaderWriterLockSlim 类来代替 ReaderWriterLock 类。

    如果应用场景要求性能十分苛刻,可以考虑采用 lock-free 方案。但是 lock-free 有着固有缺陷:极难编码,极难证明其正确性。读写锁方案的应用范围更加广泛一些。

    读写锁有个很常用的场景就是在缓存设计中。因为缓存中经常有些很稳定,不太长更新的内容。MSDN 的代码示例就很经典,我原版拷贝一下,呵呵。代码示例如下:

    相关文章

      网友评论

        本文标题:C# 读写锁

        本文链接:https://www.haomeiwen.com/subject/fmplhftx.html