什么情况下使用ReaderWriterLockSlim比简单锁更好?

87

我正在使用以下代码对ReaderWriterLock进行一个非常愚蠢的基准测试,其中读取的频率比写入高4倍:

class Program
{
    static void Main()
    {
        ISynchro[] test = { new Locked(), new RWLocked() };

        Stopwatch sw = new Stopwatch();

        foreach ( var isynchro in test )
        {
            sw.Reset();
            sw.Start();
            Thread w1 = new Thread( new ParameterizedThreadStart( WriteThread ) );
            w1.Start( isynchro );

            Thread w2 = new Thread( new ParameterizedThreadStart( WriteThread ) );
            w2.Start( isynchro );

            Thread r1 = new Thread( new ParameterizedThreadStart( ReadThread ) );
            r1.Start( isynchro );

            Thread r2 = new Thread( new ParameterizedThreadStart( ReadThread ) );
            r2.Start( isynchro );

            w1.Join();
            w2.Join();
            r1.Join();
            r2.Join();
            sw.Stop();

            Console.WriteLine( isynchro.ToString() + ": " + sw.ElapsedMilliseconds.ToString() + "ms." );
        }

        Console.WriteLine( "End" );
        Console.ReadKey( true );
    }

    static void ReadThread(Object o)
    {
        ISynchro synchro = (ISynchro)o;

        for ( int i = 0; i < 500; i++ )
        {
            Int32? value = synchro.Get( i );
            Thread.Sleep( 50 );
        }
    }

    static void WriteThread( Object o )
    {
        ISynchro synchro = (ISynchro)o;

        for ( int i = 0; i < 125; i++ )
        {
            synchro.Add( i );
            Thread.Sleep( 200 );
        }
    }

}

interface ISynchro
{
    void Add( Int32 value );
    Int32? Get( Int32 index );
}

class Locked:List<Int32>, ISynchro
{
    readonly Object locker = new object();

    #region ISynchro Members

    public new void Add( int value )
    {
        lock ( locker ) 
            base.Add( value );
    }

    public int? Get( int index )
    {
        lock ( locker )
        {
            if ( this.Count <= index )
                return null;
            return this[ index ];
        }
    }

    #endregion
    public override string ToString()
    {
        return "Locked";
    }
}

class RWLocked : List<Int32>, ISynchro
{
    ReaderWriterLockSlim locker = new ReaderWriterLockSlim();

    #region ISynchro Members

    public new void Add( int value )
    {
        try
        {
            locker.EnterWriteLock();
            base.Add( value );
        }
        finally
        {
            locker.ExitWriteLock();
        }
    }

    public int? Get( int index )
    {
        try
        {
            locker.EnterReadLock();
            if ( this.Count <= index )
                return null;
            return this[ index ];
        }
        finally
        {
            locker.ExitReadLock();
        }
    }

    #endregion

    public override string ToString()
    {
        return "RW Locked";
    }
}

但我认为它们的表现基本相同:

Locked: 25003ms.
RW Locked: 25002ms.
End

即使将读取的频率比写入的频率高出20倍,性能仍然(几乎)相同。

我在这里做错了什么吗?

顺祝商祺。


3
顺便说一句,如果我去掉睡眠时间:“Locked: 89ms. RW Locked: 32ms.” - 或者增加数字:“Locked: 1871ms. RW Locked: 2506ms.” - Marc Gravell
1
如果我去掉睡眠,锁定速度比读写锁更快。 - vtortola
1
这是因为您正在同步的代码执行速度太快,无法产生任何争用。请参见 Hans 的回答以及我的编辑。 - Dan Tao
11个回答

117
在你的例子中,睡眠意味着一般情况下没有争用。无争用锁非常快。要使其有所作用,您需要一个有争用的锁;如果在争用中有写入,它们应该是大致相同的(lock甚至可能更快)-但如果大部分是读取(很少写入争用),我预计ReaderWriterLockSlim锁将优于 lock
个人而言,在这里我更喜欢另一种策略,使用引用交换-因此读取可以始终在不检查/锁定/等待情况下进行。写操作对克隆的副本进行更改,然后使用Interlocked.CompareExchange交换引用(如果另一个线程在期间更改了引用,则重新应用其更改)。

1
复制并在写入时交换绝对是这里的最佳选择。 - LBushkin
26
非锁定的复制和写时复制完全避免了读者之间的资源争用或锁定;如果没有争用,对于写者而言非常快速,但是在存在争用的情况下可能会变得非常拥塞。建议在进行复制之前让写者获取锁(读者不需要关心锁),并在交换完成后释放锁。否则,如果100个线程每个同时尝试更新一个值,它们最坏情况下可能要执行平均约50次的复制、更新、尝试交换操作(总共执行5000次复制更新尝试交换操作以完成100次更新)。 - supercat
1
bool bDone=false; while(!bDone) { object origObj = theObj; object newObj = origObj.DeepCopy(); // 然后对newObj进行更改 if(Interlocked.CompareExchange(ref theObj, tempObj, newObj)==origObj) bDone=true; } - mcmillab
1
基本上是的,对于一些简单的情况(通常是在包装单个值或者后续写入的内容不会影响到之前的内容时),你甚至可以省略使用Interlocked,直接使用volatile字段并进行赋值。但是如果你需要确保你的更改没有被完全丢失,那么使用Interlocked是理想的选择。 - Marc Gravell
4
这个集合经常使用CompareExchange,但我认为它们并没有进行大量的复制。如果一个人使用CAS+Lock来允许不是所有的写入器都获取锁,那么CAS+Lock并不一定是多余的。例如,对于一个争用很少但偶尔可能很严重的资源,它可能具有一个锁和一个标志,指示写入器是否应该获取锁。如果标志清除,写入器只需执行CAS操作,如果成功,它们就可以继续执行。然而,一个失败CAS超过两次的写入器可以设置标志;标志随后可以保持设置状态... - supercat
显示剩余8条评论

27

根据我的测试结果,ReaderWriterLockSlim的开销大约是普通lock的5倍。这意味着为了使RWLS胜过普通的锁,通常需要满足以下条件:

  • 读者数量明显多于作者。
  • 锁定时间必须足够长,才能克服额外的开销。

在大多数实际应用程序中,这两个条件都不足以克服额外的开销。在您的代码中,锁定时间非常短,因此锁定开销可能是主导因素。如果将那些Thread.Sleep调用移动到锁内,则可能会得到不同的结果。


2
不再正确。在现代硬件上,即使具有相同数量的读者和写者(4个读者和4个写者),ReaderWriterLockSlim的速度也比锁定(它是Monitor的快捷方式)快约9%。使用BenchmarkDotNet进行基准测试并查看结果。 - tcwicks
1
这是真的。我的CPU在全球基准测试中处于96%的百分位数,而RW锁比锁慢240%。 - Boppity Bop

18

这个程序没有竞争情况。Get和Add方法在几纳秒内执行。多个线程同时访问这些方法的概率非常小。

在它们中间加入一个Thread.Sleep(1)调用并将线程中的Sleep移除,看看有什么区别。


12

编辑2:仅仅从ReadThreadWriteThread中删除Thread.Sleep调用,我看到Locked胜过了RWLocked。我认为Hans在这里说得很有道理;你的方法太快了,没有产生争用。当我将Thread.Sleep(1)添加到LockedRWLockedGetAdd方法中(并使用4个读线程对1个写线程),RWLocked击败了Locked


编辑:好吧,如果我在发布此答案时真正地思考一下,我会意识到至少为什么你要在其中放置Thread.Sleep调用:你试图再现比写更频繁的读取场景。这不是正确的做法。相反,我会在AddGet方法中引入额外开销以创建更大的竞争机会(如Hans建议的),创建更多的读线程比写线程(以确保更频繁的读取而非写入),并从ReadThreadWriteThread中删除Thread.Sleep调用(实际上降低了争用,达到了与您想要相反的效果)。


我喜欢你迄今为止所做的事情。但以下是我立刻发现的一些问题:

  1. 为什么使用Thread.Sleep调用?这只会将您的执行时间人为地增加一个常数,这将人为地使性能结果趋于收敛。
  • 在你使用Stopwatch进行测量的代码中,我也不会包括创建新的Thread对象。这不是一个轻松的对象创建。
  • 无论你是否在解决上述两个问题后看到了显着的差异,我都不知道。但我认为在讨论继续之前应该先解决这些问题。


    +1:关于第二点的观点很好 - 它确实会扭曲结果(每个时间之间的差异相同,但比例不同)。虽然如果你运行足够长时间,创建“Thread”对象所需的时间将开始变得微不足道。 - Nelson Rothermel

    10
    如果您需要锁定执行时间较长的代码部分,则使用 ReaderWriterLockSlim 可以获得更好的性能。在这种情况下,读取器可以并行工作。获取 ReaderWriterLockSlim 的时间比进入简单的 Monitor 更长。请查看我的 ReaderWriterLockTiny 实现,它是一个读写器锁,比简单的锁语句更快,并提供读写器功能: http://i255.wordpress.com/2013/10/05/fast-readerwriterlock-for-net/

    我喜欢这个。这篇文章让我想知道在每个平台上,Monitor是否比ReaderWriterLockTiny更快? - jnm2
    虽然ReaderWriterLockTiny比ReaderWriterLockSlim(以及Monitor)更快,但缺点是:如果有很多读者需要相当长的时间,它将永远不会给作者机会(他们几乎会无限期地等待)。另一方面,ReaderWriterLockSlim成功地平衡了对共享资源的读者/编写者访问。 - tigrou

    4
    无争用锁的获取时间大约为微秒级别,因此您的执行时间将被Sleep调用所淹没。

    3

    请查看这篇文章:链接

    你的睡眠时间可能很长,以至于它们使你的锁定/解锁在统计上不重要。


    此链接不再存在 - 我建议更新答案或将其删除。 - Aristos

    3

    除非您拥有多核硬件(或至少与计划生产环境相同),否则您在这里进行的测试将不具有实际意义。

    一种更明智的测试方法是通过在锁内部放置短暂延迟来延长锁定操作的寿命。这样,您应该真正能够对比使用ReaderWriterLockSlim添加的并行性和基本lock()所隐含的序列化。目前,由锁定的操作所花费的时间都被在锁外发生的Sleep调用所掩盖。在任一情况下,总时间大多与Sleep相关。

    您确定您的真实应用程序中读取和写入的数量相等吗?ReaderWriterLockSlim确实更适用于读者众多而写者相对较少的情况。1个写线程对3个读线程应该更好地展示ReaderWriterLockSlim的优势,但无论如何,您的测试都应该与预期的真实世界访问模式相匹配。


    2

    我猜这是由于您的读取和写入线程中的睡眠引起的。
    您的读取线程有一个500次50毫秒的睡眠,即25000毫秒大部分时间都在睡眠中


    0
    什么时候使用ReaderWriterLockSlim比简单锁更好?
    当您有大量读取操作而写入操作较少时。

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接