访问 StackExchange.Redis 时死锁

Deadlock when accessing StackExchange.Redis

我在调用 StackExchange.Redis 时 运行 陷入僵局。

我不知道到底发生了什么,这非常令人沮丧,如果有任何意见可以帮助解决或解决此问题,我将不胜感激。


In case you have this problem too and don't want to read all this; I suggest that you'll try setting PreserveAsyncOrder to false.

ConnectionMultiplexer connection = ...;
connection.PreserveAsyncOrder = false;

Doing so will probably resolve the kind of deadlock that this Q&A is about and could also improve performance.


我们的设置

死锁

当 application/service 启动时,它 运行 通常会持续一段时间,然后突然(几乎)所有传入的请求都停止运行,它们永远不会产生响应。所有这些请求都陷入僵局,等待对 Redis 的调用完成。

有趣的是,一旦发生死锁,对 Redis 的任何调用都会挂起,但前提是这些调用是从传入的 API 请求发出的,这些请求在线程池上 运行。

我们还从低优先级后台线程调用 Redis,这些调用即使在死锁发生后仍继续运行。

好像只有在线程池线程上调用Redis才会出现死锁。我不再认为这是由于这些调用是在线程池线程上进行的。相反,似乎任何异步 Redis 调用 没有继续,或者 sync safe 继续, 即使在发生死锁情况后也会继续工作. (请参阅下面的我认为会发生什么

相关

调试结果

我发现死锁似乎源于 line 124 of CompletionManager.cs 上的 ProcessAsyncCompletionQueue

该代码片段:

while (Interlocked.CompareExchange(ref activeAsyncWorkerThread, currentThread, 0) != 0)
{
    // if we don't win the lock, check whether there is still work; if there is we
    // need to retry to prevent a nasty race condition
    lock(asyncCompletionQueue)
    {
        if (asyncCompletionQueue.Count == 0) return; // another thread drained it; can exit
    }
    Thread.Sleep(1);
}

我发现在死锁期间; activeAsyncWorkerThread 是我们正在等待 Redis 调用完成的线程之一。 (我们的线程 = 一个线程池线程运行宁我们的代码)。所以上面的循环被认为永远持续下去。

在不知道细节的情况下,这肯定感觉不对; StackExchange.Redis 正在等待它认为是 活动异步工作线程 的线程,而实际上它是一个与此完全相反的线程。

请问是不是线程劫持问题(我不是很懂)?

怎么办?

我想弄清楚的主要两个问题:

  1. 混合使用 awaitWait()/Result 是否会导致死锁,即使 运行ning 没有同步上下文?

  2. 我们 运行 正在进入 StackExchange.Redis 中的 bug/limitation 吗?

可能的解决方法?

从我的调试结果来看,问题似乎在于:

next.TryComplete(true);

...on line 162 in CompletionManager.cs 在某些情况下可能会让当前线程(即 活动异步工作线程 )离开并开始处理其他代码,可能导致死锁。

不知道细节,只是考虑这个 "fact",那么在 TryComplete 期间暂时释放 活动异步工作线程 似乎是合乎逻辑的调用。

我想像这样的东西可以工作:

// release the "active thread lock" while invoking the completion action
Interlocked.CompareExchange(ref activeAsyncWorkerThread, 0, currentThread);

try
{
    next.TryComplete(true);
    Interlocked.Increment(ref completedAsync);
}
finally
{
    // try to re-take the "active thread lock" again
    if (Interlocked.CompareExchange(ref activeAsyncWorkerThread, currentThread, 0) != 0)
    {
        break; // someone else took over
    }
}

我想我最大的希望是 Marc Gravell 会阅读这篇文章并提供一些反馈:-)

无同步上下文 = 默认同步上下文

我在上面写过我们的代码不使用 synchronization context. This is only partially true: The code is run as either a Console application or as an Azure Worker Role. In these environments SynchronizationContext.Currentnull,这就是为什么我写我们 运行ning without 同步上下文。

然而,在阅读 It's All About the SynchronizationContext 之后,我了解到事实并非如此:

By convention, if a thread’s current SynchronizationContext is null, then it implicitly has a default SynchronizationContext.

默认同步上下文不应该是死锁的原因,因为基于 UI 的(WinForms、WPF)同步上下文可能是 - 因为它并不暗示线程关联。

我认为会发生什么

消息完成后,将检查其完成源是否被视为同步安全。如果是,则内联执行完成操作,一切正常。

如果不是,想法是在新分配的线程池线程上执行完成操作。当 ConnectionMultiplexer.PreserveAsyncOrderfalse.

时,这也很好用

但是,当 ConnectionMultiplexer.PreserveAsyncOrdertrue(默认值)时,这些线程池线程将使用 完成队列 序列化它们的工作,并通过确保其中至多一个是 active async worker thread 在任何时候。

当一个线程成为活动的异步工作线程时,它将继续保持这种状态,直到它耗尽完成队列

问题是完成操作不同步安全(从上面看),它仍然在不能被阻塞的线程上执行 因为这会阻止其他 非同步安全 消息完成。

请注意,其他通过 同步安全 完成操作完成的消息将继续正常工作,即使 活动异步工作线程 被屏蔽了。

我建议的"fix"(上文)不会以这种方式导致死锁,但是它会混淆保留异步完成顺序的概念。

所以这里可能要得出的结论是 PreserveAsyncOrdertrue,不管我们是不是运行没有同步上下文?

(至少在我们可以使用 .NET 4.6 和新的 TaskCreationOptions.RunContinuationsAsynchronously 之前,我想 )

我根据上面的详细信息进行了很多猜测,并且不知道您拥有的源代码。听起来您可能在 .Net 中遇到了一些内部的、可配置的限制。你不应该碰到那些,所以我的猜测是你没有处理对象,因为它们在线程之间浮动,这不允许你使用 using 语句来干净地处理它们的对象生命周期。

这详细说明了 HTTP 请求的限制。类似于旧的 WCF 问题,当您不处理连接时,所有 WCF 连接都会失败。

这更像是一种调试帮助,因为我怀疑您是否真的使用了所有 TCP 端口,但是关于如何找到您拥有多少个打开的端口以及到哪里的很好的信息。

https://msdn.microsoft.com/en-us/library/aa560610(v=bts.20).aspx

这些是我发现的解决此死锁问题的解决方法:

解决方法 #1

默认情况下 StackExchange.Redis 将确保命令的完成顺序与结果消息的接收顺序相同。如本问题中所述,这可能会导致死锁。

通过将 PreserveAsyncOrder 设置为 false 来禁用该行为。

ConnectionMultiplexer connection = ...;
connection.PreserveAsyncOrder = false;

这将避免死锁,也可以 improve performance

我鼓励 运行 遇到死锁问题的任何人尝试此解决方法,因为它非常干净和简单。

您将无法保证异步延续的调用顺序与底层 Redis 操作的完成顺序相同。但是,我真的不明白为什么你会依赖它。


解决方法 #2

当 StackExchange.Redis 中的 活动异步工作线程 完成命令并且内联执行完成任务时发生死锁。

可以通过使用自定义 TaskScheduler and ensure that TryExecuteTaskInline returns false.

来防止任务被内联执行
public class MyScheduler : TaskScheduler
{
    public override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
    {
        return false; // Never allow inlining.
    }

    // TODO: Rest of TaskScheduler implementation goes here...
}

实施一个好的任务调度程序可能是一项复杂的任务。但是,ParallelExtensionExtras library (NuGet package) 中的现有实现可供您使用或从中汲取灵感。

如果您的任务调度程序将使用自己的线程(而不是来自线程池),那么允许内联可能是个好主意,除非当前线程来自线程池。这将起作用,因为 StackExchange.Redis 中的 活动异步工作线程 始终是线程池线程。

public override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
{
    // Don't allow inlining on a thread pool thread.
    return !Thread.CurrentThread.IsThreadPoolThread && this.TryExecuteTask(task);
}

另一个想法是使用 thread-local storage.

将调度程序附加到它的所有线程
private static ThreadLocal<TaskScheduler> __attachedScheduler 
                   = new ThreadLocal<TaskScheduler>();

确保在线程启动时分配此字段 运行ning 并在其完成时清除:

private void ThreadProc()
{
    // Attach scheduler to thread
    __attachedScheduler.Value = this;

    try
    {
        // TODO: Actual thread proc goes here...
    }
    finally
    {
        // Detach scheduler from thread
        __attachedScheduler.Value = null;
    }
}

然后您可以允许内联任务,只要它由自定义调度程序在 "owned" 的线程上完成即可:

public override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
{
    // Allow inlining on our own threads.
    return __attachedScheduler.Value == this && this.TryExecuteTask(task);
}