访问StackExchange.Redis时出现死锁

76

当调用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.

我们的设置

  • 代码作为控制台应用程序或 Azure 工作角色运行。
  • 它使用 HttpMessageHandler 暴露了一个 REST api,因此入口点是异步的。
  • 代码的某些部分具有线程亲和性(由单个线程拥有和运行)。
  • 代码的某些部分仅支持异步。
  • 我们正在使用 同步 - 异步异步 - 同步 反模式。(混合使用 awaitWait()/Result)。
  • 只有在访问 Redis 时我们才使用异步方法。
  • 我们使用 .NET 4.5 的 StackExchange.Redis 1.0.450。

死锁

当应用程序/服务启动后,它会正常运行一段时间,然后突然(几乎)所有传入请求停止工作,它们从未产生响应。所有这些请求都因等待 Redis 的调用而死锁。

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

我们也从低优先级后台线程中调用 Redis,这些调用即使在死锁发生后也继续正常工作。

似乎只有在线程池线程上调用 Redis 时才会发生死锁。 我不再认为这是因为这些调用是在线程池线程上进行的。相反,似乎任何没有延续或具有同步安全延续的异步 Redis 调用都将继续工作,即使在死锁情况发生后。(请参阅下面的 我认为会发生什么

相关内容

  • StackExchange.Redis死锁

    由于混合使用awaitTask.Result(同步-over-异步,就像我们所做的那样)而导致死锁。但是我们的代码在没有同步上下文的情况下运行,因此这里不适用,对吧?

  • 如何安全地混合使用同步和异步代码?

    是的,我们不应该这样做。但我们确实这样做了,并且我们将不得不继续这样做一段时间。很多需要迁移到异步世界的代码。

    同样,我们没有同步上下文,因此这不应该引起死锁,对吧?

    在任何await之前设置ConfigureAwait(false)对此没有影响。

  • StackExchange.Redis中异步命令和Task.WhenAny等待后超时异常

    这是线程劫持问题。目前的情况如何?这可能是问题所在吗?

  • StackExchange.Redis异步调用挂起

    来自Marc的回答:

    ...混合使用Wait和await不是一个好主意。除了死锁之外,这是“同步-over-异步”——一种反模式。

    但他也说:

    SE.Redis在内部绕过同步上下文(对于库代码而言很正常),因此它不应该有死锁问题

    因此,据我所知,StackExchange.Redis应该对我们是否使用同步-over-异步反模式是无关紧要的。只是不建议这样做,因为它可能是其他代码中死锁的原因。

    然而,在这种情况下,据我所知,死锁实际上是在StackExchange.Redis内部发生的。如果我错了,请纠正我。

调试结果

我发现死锁似乎源于CompletionManager.cs第124行的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 调用完成的线程之一。(our thread = 运行 our code 的线程池线程)。因此,上面的循环被认为会无限继续。

不知道细节情况下,这肯定感觉不对;StackExchange.Redis 正在等待一个它认为是 active async worker thread 的线程,而实际上它却相反。

我想知道这是否由于 thread hijacking problem(我并不完全理解)引起的?

该怎么办?

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

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

  2. 我们是否遇到了 StackExchange.Redis 中的错误/限制?

一个可能的修复方法?

根据我的调试结果,似乎问题是:

next.TryComplete(true);

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

不了解细节,只考虑这个“事实”,那么在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能够阅读此文并提供反馈 :-)

没有同步上下文=默认同步上下文

我之前写过,我们的代码不使用同步上下文。这只是部分正确的:代码作为控制台应用程序或Azure Worker Role运行。在这些环境中,SynchronizationContext.Currentnull,这就是为什么我说我们正在没有同步上下文的情况下运行。

然而,在阅读It's All About the SynchronizationContext之后,我了解到这并不是真的:

按照惯例,如果线程的当前同步上下文为null,则它隐式具有默认同步上下文。

默认的同步上下文不应该是死锁的原因,因为基于UI的(WinForms、WPF)同步上下文可能会死锁——因为它不意味着线程亲和性。

我认为发生了什么

当消息完成时,它的完成源会被检查是否被认为是同步安全的。如果是,则执行完成操作,并且一切都很好。

如果不是,那么想法是在新分配的线程池线程上执行完成操作。当ConnectionMultiplexer.PreserveAsyncOrderfalse时,这也能正常工作。

然而,当ConnectionMultiplexer.PreserveAsyncOrdertrue(默认值)时,那些线程池线程将使用完成队列序列化其工作,并确保最多只有一个线程是活动异步工作线程

当线程成为活动异步工作线程时,它将继续保持状态,直到排空完成队列

问题在于完成操作是不同步安全的(来自上面),但它在一个不能被阻塞的线程上执行,因为这将防止其他不同步安全的消息被完成。

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

我的建议“修复”(如上所述)不会以这种方式导致死锁,但是会干扰保留异步完成顺序的概念。

因此,也许可以得出结论:即使我们在没有同步上下文的情况下运行,当PreserveAsyncOrdertrue时,混合使用awaitResult/Wait()是不安全的?

(至少直到我们能够使用.NET 4.6及新的TaskCreationOptions.RunContinuationsAsynchronously,我想)


这里很难形成一个观点,因为你没有展示任何实际调用SE.Redis或等待/等待的代码 - 这是关键的代码... 你能展示一下你是如何调用它的吗? - Marc Gravell
@MarcGravell:我可以向您展示任何代码,但不是全部。然而,问题在于我不知道哪些代码是有趣的部分。请查看我的最近编辑(在结尾处),我认为问题是通用的,并且由“非同步安全”完成操作被“活动异步工作线程”执行引起的,当被阻塞时会导致死锁。 - Mårten Wikström
2
虽然不是答案,但这是一个写得很好的问题。 - Nico
我在某些特定情况下看到了相同的情况,在我的本地开发环境中可以重现。不确定是什么触发了这个问题,但它是完全相同的死锁症状 - qs 表示已发送的内容,in 表示已接收的内容,但它卡住了。这是完全同步调用 SE Redis,没有异步调用。设置 PreserveAsyncOrder 可以解决这个问题,但这似乎有点神奇。@MarcGravell 你对此有什么想法吗? - Chris Hynes
“PreserveAsyncOrder”已不再受支持(已过时),我想知道核心库是否已经解决了这个问题? - Matt Roberts
显示剩余3条评论
2个回答

25

以下是我发现的解决这个死锁问题的方法:

解决方案 #1

默认情况下,StackExchange.Redis会确保命令按接收到的结果消息的顺序完成。这可能会导致死锁,就像这个问题描述的那样。

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

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

这将避免死锁,还可能提高性能
我鼓励任何遇到死锁问题的人尝试这个解决方法,因为它非常干净和简单。
您将失去异步连续性按照底层Redis操作完成的顺序调用的保证。但是,我不认为这是您可以依赖的原因。

解决方法 #2

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

可以通过使用自定义的TaskScheduler并确保TryExecuteTaskInline返回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库NuGet软件包)中存在现有的实现,您可以使用或借鉴这些实现。
如果您的任务调度器将使用自己的线程(而不是来自线程池),那么允许内联可能是一个好主意,除非当前线程来自线程池。这将起作用,因为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);
}

另一个想法是将调度程序附加到所有线程上,使用线程本地存储

private static ThreadLocal<TaskScheduler> __attachedScheduler 
                   = new ThreadLocal<TaskScheduler>();

确保在线程开始运行时分配此字段,并在完成时清除:

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;
    }
}

只要在自定义调度程序所“拥有”的线程上完成,就可以允许任务内联:

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

3
注意:从 Release 2.0.495 开始,PreserveAsyncOrder 已被弃用。 - tehmas
1
@tehmas,PreserveAsyncOrder被弃用后,在ConnectionMultiplexer中新标志位在哪里?或者在StackExchange.Redis的其他地方是否有标志位? - chy600

0
根据上面的详细信息和不知道你现有的源代码,我猜测了很多。听起来你可能会遇到一些在.Net中内部且可配置的限制。你不应该遇到这些问题,所以我的猜测是你没有处理好对象的生命周期,因为它们在线程之间漂移,这将不允许你使用using语句来清理地处理它们的对象生命周期。
这详细说明了HTTP请求的限制。类似于旧版WCF问题,当你没有处理好连接并且所有WCF连接都会失败时。 最大并发HttpWebRequests数量 这更像是一个调试辅助工具,因为我怀疑你真的在使用所有TCP端口,但是它提供了如何查找你有多少个打开的端口以及它们的位置的好信息。

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


谢谢。但是这个问题不是由于TCP端口或HTTP连接耗尽引起的。 - Mårten Wikström

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