如何诊断异步/等待死锁?


24

我正在使用大量使用异步/等待的新代码库。我团队中的大多数人对于异步/等待也是相当陌生的。我们通常倾向于遵循Microsoft指定的最佳实践,但是通常需要我们的上下文来传递异步调用并使用不支持的库ConfigureAwait(false)

结合所有这些内容,我们每周都会遇到文章中描述的异步死锁。它们不会在单元测试期间显示,因为我们的模拟数据源(通常通过Task.FromResult)不足以触发死锁。因此,在运行时或集成测试期间,某些服务调用只会吃到午餐,再也不会返回。那会杀死服务器,并且通常使事情变得一团糟。

问题在于,要跟踪错误的出处(通常只是一直不完全同步),通常涉及手动代码检查,这很耗时且无法自动化。

诊断死锁的更好方法是什么?


1
好问题; 我自己想知道这个。你读过这个家伙的async文章集吗?
罗伯特·哈维

@RobertHarvey-也许不是全部,但我已经读了一些。更多信息“确保在任何地方都执行这两项/三项操作,否则您的代码将在运行时死于可怕的死亡”。
Telastyn 2015年

您是否愿意放弃异步或将其使用率降低到最有利的程度?异步IO并非全部或全部。
usr

1
如果您可以重现死锁,您难道不可以仅查看堆栈跟踪以查看阻塞调用吗?
svick

2
如果问题不是“一路异步”,则意味着死锁的一半是传统死锁,并且应该在同步上下文线程的堆栈跟踪中可见。
svick

Answers:


4

好的-我不确定以下内容对您是否有帮助,因为我在制定解决方案时做出了一些假设,这些假设可能对您适用或不正确。也许我的“解决方案”过于理论化,仅适用于人工示例-除以下内容外,我没有做任何测试。
此外,我会看到以下解决方案比实际解决方案更有效,但考虑到缺乏答复,我认为这可能总比没有解决要好(我一直在观望您的问题,等待解决方案,但没有看到有人发布我开始玩问题)。

但是足够多了:假设我们有一个简单的数据服务,可用于检索整数:

public interface IDataService
{
    Task<int> LoadMagicInteger();
}

一个简单的实现使用异步代码:

public sealed class CustomDataService
    : IDataService
{
    public async Task<int> LoadMagicInteger()
    {
        Console.WriteLine("LoadMagicInteger - 1");
        await Task.Delay(100);
        Console.WriteLine("LoadMagicInteger - 2");
        var result = 42;
        Console.WriteLine("LoadMagicInteger - 3");
        await Task.Delay(100);
        Console.WriteLine("LoadMagicInteger - 4");
        return result;
    }
}

现在,如果我们使用此类错误说明的代码,就会出现问题。Foo错误地访问Task.Result而不是await像这样Bar做那样获取结果:

public sealed class ClassToTest
{
    private readonly IDataService _dataService;

    public ClassToTest(IDataService dataService)
    {
        this._dataService = dataService;
    }

    public async Task<int> Foo()
    {
        var result = this._dataService.LoadMagicInteger().Result;
        return result;
    }
    public async Task<int> Bar()
    {
        var result = await this._dataService.LoadMagicInteger();
        return result;
    }
}

我们(您)现在需要的是一种编写测试的方法,该测试在调用Bar时成功,但是在调用时失败Foo(至少在我正确理解问题的前提下;-))。

我让代码讲;这是我想出的(使用Visual Studio测试,但是也可以使用NUnit进行工作):

DataServiceMock利用TaskCompletionSource<T>。这使我们可以在测试运行的定义点设置结果,从而进行以下测试。请注意,我们使用委托将TaskCompletionSource传递回测试中。您也可以将其放入测试的Initialize方法并使用属性。

TaskCompletionSource<int> tcs = null;
this._dataService.LoadMagicIntegerMock = t => tcs = t;

Task<int> task = null;
TaskTestHelper.AssertDoesNotBlock(() => task = this._instance.Foo());

tcs.TrySetResult(42);

var result = task.Result;
Assert.AreEqual(42, result);

this._end = true;

这里发生的事情是,我们首先验证我们可以离开该方法而不会阻塞(如果有人访问该方法将不起作用Task.Result-在这种情况下,由于直到该方法返回后该任务的结果才可用,因此我们将遇到超时)。
然后,我们设置结果(现在该方法可以执行)并验证结果(在单元测试中,我们可以访问Task.Result,因为我们实际上希望发生阻塞)。

完整的测试课程- 根据需要BarTest成功或FooTest失败。

[TestClass]
public class UnitTest1
{
    private DataServiceMock _dataService;
    private ClassToTest _instance;
    private bool _end;

    [TestInitialize]
    public void Initialize()
    {
        this._dataService = new DataServiceMock();
        this._instance = new ClassToTest(this._dataService);

        this._end = false;
    }
    [TestCleanup]
    public void Cleanup()
    {
        Assert.IsTrue(this._end);
    }

    [TestMethod]
    public void FooTest()
    {
        TaskCompletionSource<int> tcs = null;
        this._dataService.LoadMagicIntegerMock = t => tcs = t;

        Task<int> task = null;
        TaskTestHelper.AssertDoesNotBlock(() => task = this._instance.Foo());

        tcs.TrySetResult(42);

        var result = task.Result;
        Assert.AreEqual(42, result);

        this._end = true;
    }
    [TestMethod]
    public void BarTest()
    {
        TaskCompletionSource<int> tcs = null;
        this._dataService.LoadMagicIntegerMock = t => tcs = t;

        Task<int> task = null;
        TaskTestHelper.AssertDoesNotBlock(() => task = this._instance.Bar());

        tcs.TrySetResult(42);

        var result = task.Result;
        Assert.AreEqual(42, result);

        this._end = true;
    }
}

还有一些帮助程序类来测试死锁/超时:

public static class TaskTestHelper
{
    public static void AssertDoesNotBlock(Action action, int timeout = 1000)
    {
        var timeoutTask = Task.Delay(timeout);
        var task = Task.Factory.StartNew(action);

        Task.WaitAny(timeoutTask, task);

        Assert.IsTrue(task.IsCompleted);
    }
}

好答案。我打算在有空的时候亲自尝试您的代码(我实际上不确定是否能奏效),但是为此付出了很多努力。
罗伯特·哈维

-2

这是我在庞大且非常多线程的应用程序中使用的策略:

首先,不幸的是,您需要一些围绕互斥锁的数据结构,并且不进行任何同步调用目录。在该数据结构中,有一个指向以前锁定的互斥锁的链接。每个互斥锁都有一个从0开始的“级别”,您在创建互斥锁时便会对其进行分配,并且该级别永远不会更改。

规则是:如果互斥锁已锁定,则只能将其他互斥锁锁定在较低级别。如果遵循该规则,那么就不会有僵局。当发现违规时,您的应用程序仍然可以正常运行。

当您发现违规时,有两种可能性:您可能为级别指定了错误。您先锁定了A,然后又锁定了B,因此B应该具有较低的级别。因此,您修复了该级别,然后重试。

另一种可能性:您无法修复它。您的某些代码将A锁定,然后锁定B,而其他一些代码B锁定,然后锁定A。无法分配级别来允许此操作。当然,这是一个潜在的死锁:如果两个代码同时在不同的线程上运行,则有死锁的可能。

引入此之后,有一个相当短的阶段需要调整级别,然后是一个较长的阶段,在那里发现了潜在的僵局。


4
抱歉,这对异步/等待行为有何影响?我无法现实地将自定义互斥锁管理结构注入“任务并行库”。
Telastyn

-3

您是否正在使用Async / Await,以便可以并行化对数据库的昂贵调用?根据数据库中的执行路径,这可能无法实现。

异步/等待的测试覆盖范围可能具有挑战性,没有什么比实际生产中发现错误更重要的了。您可能考虑的一种模式是传递相关ID并将其记录在堆栈中,然后级联超时以记录错误。这更像是一种SOA模式,但至少可以使您了解它的来源。我们将其与Splunk一起使用来查找死锁。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.