当作者试图进入写锁时如何避免阻塞 ReaderWriterLockSlim 读者

Posted

技术标签:

【中文标题】当作者试图进入写锁时如何避免阻塞 ReaderWriterLockSlim 读者【英文标题】:How to avoid blocking ReaderWriterLockSlim readers when writer is attempting to enter write lock 【发布时间】:2015-09-18 14:38:02 【问题描述】:

我正在使用ReaderWriterLockSlim 来保护一些操作。我想偏爱读者而不是作者,这样当读者长时间持有锁并且作者试图获取写锁时,进一步的读者不会被作者的尝试阻塞(如果作者在lock.EnterWriteLock()被屏蔽)。

为此,我认为作者可以在循环中使用TryEnterWriteLock 并具有短暂的超时,以便后续读者仍然能够获得读锁而作者不能。然而,令我惊讶的是,我发现对TryEnterWriteLock 的不成功调用改变了锁的状态,无论如何都会阻塞未来的读者。概念验证代码:

System.Threading.ReaderWriterLockSlim myLock = new System.Threading.ReaderWriterLockSlim(System.Threading.LockRecursionPolicy.NoRecursion);

System.Threading.Thread t1 = new System.Threading.Thread(() =>

    Console.WriteLine("T1:0: entering read lock...", DateTime.Now);
    myLock.EnterReadLock();
    Console.WriteLine("T1:0: ...entered read lock.", DateTime.Now);

    System.Threading.Thread.Sleep(10000);
);

System.Threading.Thread t2 = new System.Threading.Thread(() =>

    System.Threading.Thread.Sleep(1000);

    while (true)
    
        Console.WriteLine("T2:0: try-entering write lock...", DateTime.Now);
        bool result = myLock.TryEnterWriteLock(TimeSpan.FromMilliseconds(1500));
        Console.WriteLine("T2:0: ...try-entered write lock, result=1.", DateTime.Now, result);

        if (result)
        
            // Got it!
            break;
        

        System.Threading.Thread.Yield();
    

    System.Threading.Thread.Sleep(9000);
);

System.Threading.Thread t3 = new System.Threading.Thread(() =>

    System.Threading.Thread.Sleep(2000);

    Console.WriteLine("T3:0: entering read lock...", DateTime.Now);
    myLock.EnterReadLock();
    Console.WriteLine("T3:0: ...entered read lock!!!!!!!!!!!!!!!!!!!", DateTime.Now);

    System.Threading.Thread.Sleep(8000);
);

这段代码的输出是:

T1:18-09-2015 16:29:49: entering read lock...
T1:18-09-2015 16:29:49: ...entered read lock.
T2:18-09-2015 16:29:50: try-entering write lock...
T3:18-09-2015 16:29:51: entering read lock...
T2:18-09-2015 16:29:51: ...try-entered write lock, result=False.
T2:18-09-2015 16:29:51: try-entering write lock...
T2:18-09-2015 16:29:53: ...try-entered write lock, result=False.
T2:18-09-2015 16:29:53: try-entering write lock...
T2:18-09-2015 16:29:54: ...try-entered write lock, result=False.
T2:18-09-2015 16:29:54: try-entering write lock...
T2:18-09-2015 16:29:56: ...try-entered write lock, result=False.
T2:18-09-2015 16:29:56: try-entering write lock...
T2:18-09-2015 16:29:57: ...try-entered write lock, result=False.
T2:18-09-2015 16:29:57: try-entering write lock...
T2:18-09-2015 16:29:59: ...try-entered write lock, result=False.
T2:18-09-2015 16:29:59: try-entering write lock...

如您所见,即使线程 2(“写入器”)尚未获得写入器锁且不在 EnterWriteLock 调用中,线程 3 也会被永久阻塞。我可以看到ReaderWriterLock 的类似行为。

我做错了吗?如果不是,当作家排队时,我必须有哪些选择来支持读者?

【问题讨论】:

有趣。看来,如果您改用TryEnterWriteLock(0)(并将超时移动到下面某处的Sleep),它可以正常工作(至少在这里)。但是,根据我对documentation 的阅读,它的行为不应该是这样的:“尝试进入读取模式或可升级模式的其他线程会阻塞,直到所有等待进入写入模式的线程都已超时或进入写入模式,然后退出”. 将循环添加到线程t3,您将获得成功。 t3 尝试在 t2 尝试发生超时写入时发生读取锁定。 @Mormegil 它的行为如文档中所述。当t2 超时等待时,t3 尝试发生读锁。 @Hamlet, t2 超时,因此根据文档,“尝试进入读取模式的其他线程”应该被解除阻塞,但事实并非如此。 将 t3 的 Thread.Sleep 更改为 3000 将不起作用,因为您试图在循环中发生 writelook。但是使用 TryEnterReadLock(int) 会解决你的问题。 【参考方案1】:

我忍不住,但我相信这是一个 .NET Framework 错误(更新:我有 reported the bug)。以下简单的程序(上面的简化版本)说明了这一点:

var myLock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion);

var t1 = new Thread(() =>

    Console.WriteLine("T1:0: entering read lock...", DateTime.Now);
    myLock.EnterReadLock();
    Console.WriteLine("T1:0: ...entered read lock.", DateTime.Now);

    Thread.Sleep(50000);

    Console.WriteLine("T1:0: exiting", DateTime.Now);
    myLock.ExitReadLock();
);

var t2 = new Thread(() =>

    Thread.Sleep(1000);

    Console.WriteLine("T2:0: try-entering write lock...", DateTime.Now);
    bool result = myLock.TryEnterWriteLock(3000);
    Console.WriteLine("T2:0: ...try-entered write lock, result=1.", DateTime.Now, result);

    Thread.Sleep(50000);

    if (result)
    
        myLock.ExitWriteLock();
    
    Console.WriteLine("T2:0: exiting", DateTime.Now);
);

var t3 = new Thread(() =>

    Thread.Sleep(2000);

    Console.WriteLine("T3:0: entering read lock...", DateTime.Now);
    myLock.EnterReadLock();
    Console.WriteLine("T3:0: ...entered read lock!!!!!!!!!!!!!!!!!!!", DateTime.Now);

    Thread.Sleep(50000);

    myLock.ExitReadLock();
    Console.WriteLine("T3:0: exiting", DateTime.Now);
);

t1.Start();
t2.Start();
t3.Start();

t1.Join();
t2.Join();
t3.Join();

以下发生的顺序很简单,没有锁车队、没有比赛、没有循环或任何事情。

    T1 获得读锁。 T2 尝试获取写锁并阻塞,等待超时(因为T1 持有锁)。 T3 尝试获取读锁并阻塞(因为 T2 被阻塞等待写锁,并且根据文档,这意味着所有进一步的读取器都被阻塞,直到超时)。 T2 的超时到期。根据文档,T3 现在应该唤醒并获取读锁。但是,这不会发生,T3 会被永远阻塞(或者,在本例中,在 T1 离开读锁之前的 50 秒内)。

AFAICT,ReaderWriterLockSlim’s WaitOnEvent 中的 ExitMyLock 应该是 ExitAndWakeUpAppropriateWaiters

【讨论】:

您能说明行为不符合文档的地方吗? 好吧,你能解释一下为什么这样的程序(它的T3 线程)会无限期地阻塞吗?它在等待什么,为什么?另外,我引用了上面的文档:“阻塞,直到所有等待进入写入模式的线程都超时或进入写入模式然后退出”。他们已经超时,但线程仍然被阻塞。 不太确定“bug”是否合适。 “不是最优的”更像是它,有一些并发机会损失。它只会在一个从不释放读锁的程序中真正出错,这是一个有问题的程序。否则,没人注意到这一点的原因。顺便说一句,ReaderWriterLock(不是苗条的)做同样的事情。 @Hans:如果它不是错误,那么文档就会产生误导。它的编写方式听起来就像是超时请求写锁的线程与获取写锁然后退出的线程之间没有区别。在这两种情况下,您都希望任何排队的读取请求都会立即解除阻塞。这确实发生在一种情况下,但不是另一种情况。我同意 Mormegil 的观点,可能错过了对 ExitAndWakeUpAppropriateWaiters 的呼叫。 在 corefx Github 问题跟踪器上报告此问题。【参考方案2】:

我同意 Mormegil 的回答,即您观察到的行为似乎不符合 ReaderWriterLockSlim.TryEnterWriteLock Method (Int32) 的文档所述:

当线程被阻塞等待进入写入模式时,其他尝试进入读取模式或可升级模式的线程会阻塞,直到所有等待进入写入模式的线程都超时或进入写入模式然后退出。

请注意,有 2 种记录方式可以让其他等待进入读取模式的线程停止等待:

TryEnterWriteLock 超时。 TryEnterWriteLock成功,然后调用ExitWriteLock释放锁。

第二种情况按预期工作。但是第一个(超时场景)没有,正如您的代码示例清楚地表明的那样。

为什么不起作用?

为什么尝试进入读取模式的线程会一直等待,即使尝试进入写入模式超时?

因为,为了让等待线程进入读取模式,需要发生两件事。等待写锁超时的线程需要:

重置一个标志,表明它不再等待进入写入模式。这是通过调用ClearWritersWaiting(), if you look at the source code 来完成的。 发出信号等待线程,它们应该重试获取所需的锁。此信号通过调用 ExitAndWakeUpAppropriateWaiters() 来执行。

以上是应该发生的事情。但实际上,当TryEnterWriteLock 超时时,只执行第一步(重置标志),而不执行第二步(通知等待线程重试获取锁)。结果,在您的情况下,尝试获取读取锁的线程会无限期地等待,因为它永远不会被“告知”它应该唤醒并再次检查标志。

正如Mormegil 所指出的,将this line of code 中的呼叫从ExitMyLock() 更改为ExitAndWakeUpAppropriateWaiters() 似乎是解决问题所需的全部内容。因为修复看起来很简单,所以我也倾向于认为这只是一个被忽略的错误。

这些信息有何用处?

了解原因让我们意识到“错误”行为的影响范围有限。只有当线程某些线程调用TryEnterWriteLock,但之前尝试获取锁时,它才会导致线程“无限期”阻塞 TryEnterWriteLock 调用超时。而且它并没有真正阻止无限期。它最终会在其他线程正常释放其锁时立即解除阻塞,这将是本例中的预期场景。

这也意味着任何尝试进入 READ 模式的线程 TryEnterWriteLock 超时后都可以毫无问题地这样做。

为了说明这一点,运行以下代码 sn -p:

private static ReaderWriterLockSlim rwLock = new ReaderWriterLockSlim();
private static Stopwatch stopwatch = new Stopwatch();

static void Log(string logString)

    Console.WriteLine($"(long)stopwatch.Elapsed.TotalMilliseconds:D5: logString");


static void Main(string[] args)

    stopwatch.Start();

    // T1 - Initial reader
    new Thread(() =>
    
        Log("T1 trying to enter READ mode...");
        rwLock.EnterReadLock();
        Log("T1 entered READ mode successfully!");
        Thread.Sleep(10000);
        rwLock.ExitReadLock();
        Log("T1 exited READ mode.");
    ).Start();

    // T2 - Writer times out.
    new Thread(() =>
    
        Thread.Sleep(1000);
        Log("T2 trying to enter WRITE mode...");
        if (rwLock.TryEnterWriteLock(2000))
        
            Log("T2 entered WRITE mode successfully!");
            rwLock.ExitWriteLock();
            Log("T2 exited WRITE mode.");
        
        else
        
            Log("T2 timed out! Unable to enter WRITE mode.");
        
    ).Start();

    // T3 - Reader blocks longer than it should, BUT...
    //      is eventually unblocked by T4's ExitReadLock().
    new Thread(() =>
    
        Thread.Sleep(2000);
        Log("T3 trying to enter READ mode...");
        rwLock.EnterReadLock();
        Log("T3 entered READ mode after all!  T4's ExitReadLock() unblocked me.");
        rwLock.ExitReadLock();
        Log("T3 exited READ mode.");
    ).Start();

    // T4 - Because the read attempt happens AFTER T2's timeout, it doesn't block.
    //      Also, once it exits READ mode, it unblocks T3!
    new Thread(() =>
    
        Thread.Sleep(4000);
        Log("T4 trying to enter READ mode...");
        rwLock.EnterReadLock();
        Log("T4 entered READ mode successfully! Was not affected by T2's timeout \"bug\"");
        rwLock.ExitReadLock();
        Log("T4 exited READ mode. (Side effect: wakes up any other waiter threads)");
    ).Start();

输出:

00000:T1 试图进入 READ 模式... 00001:T1成功进入READ模式! 01011:T2 试图进入 WRITE 模式... 02010:T3 试图进入 READ 模式... 03010:T2 超时!无法进入 WRITE 模式。 04013: T4 试图进入 READ 模式... 04013:T4成功进入READ模式!不受 T2 超时“bug”的影响 04013: T4 退出读取模式。 (副作用:唤醒任何其他服务员线程) 04013:T3 毕竟进入了 READ 模式! T4 的 ExitReadLock() 解锁了我。 04013: T3 退出读取模式。 10005:T1 退出读取模式。

另请注意,T4 读取器线程并非严格要求以解除对T3 的阻塞。 T1 的最终 ExitReadLock 也将解锁 T3

最后的想法

虽然当前的行为不太理想,并且确实感觉像是 .NET 库中的错误,但在现实生活场景中,首先导致写入器线程超时的读取器线程最终会退出 READ 模式。反过来,这将解除阻塞可能由于“错误”而被卡住的任何等待阅读器线程。所以“bug”对现实生活的影响应该是最小的。

不过,正如 cmets 中所建议的那样,为了使您的代码更加可靠,将您的 EnterReadLock() 调用更改为具有合理超时值的 TryEnterReadLock 并在循环内调用并不是一个坏主意。这样,您就可以对阅读器线程“卡住”的最长时间进行某种程度的控制。

【讨论】:

以上是关于当作者试图进入写锁时如何避免阻塞 ReaderWriterLockSlim 读者的主要内容,如果未能解决你的问题,请参考以下文章

直击春招00---多线程

读写锁

多线程加锁,文件锁,进程锁.

多线程——重入锁

Java 死锁以及如何避免?

Java并发程序设计(15)并发锁之读写锁(续二)写锁降级