Wie kann ich Async diagnostizieren / Deadlocks abwarten?

24

Ich arbeite mit einer neuen Codebasis, die stark von async / await Gebrauch macht. Die meisten Leute in meinem Team sind auch noch ziemlich neu im Bereich Async / Warten. Wir halten uns in der Regel an die von Microsoft festgelegten Best Practices , benötigen jedoch im Allgemeinen unseren Kontext, um den asynchronen Aufruf zu verarbeiten, und arbeiten mit Bibliotheken, die dies nicht tun ConfigureAwait(false).

Wenn Sie all diese Dinge kombinieren, stoßen wir auf asynchrone Deadlocks, die im Artikel beschrieben werden ... wöchentlich. Sie werden beim Unit-Test nicht angezeigt, da unsere verspotteten Datenquellen (normalerweise über Task.FromResult) nicht ausreichen, um den Deadlock auszulösen. Während der Laufzeit- oder Integrationstests geht ein Service-Aufruf nur zum Mittagessen aus und kehrt nie zurück. Das bringt die Server zum Erliegen und bringt die Dinge im Allgemeinen durcheinander.

Das Problem besteht darin, dass das Auffinden, wo der Fehler gemacht wurde (normalerweise nicht vollständig asynchron), im Allgemeinen eine manuelle Codeüberprüfung umfasst, die zeitaufwendig und nicht automatisierbar ist.

Wie lässt sich besser diagnostizieren, was den Deadlock verursacht hat?

Telastyn
quelle
1
Gute Frage; Ich habe mich das selbst gefragt. Haben Sie die Artikelsammlung dieses Mannesasync gelesen ?
Robert Harvey
@RobertHarvey - vielleicht nicht alle, aber ich habe einige gelesen. Mehr "Stellen Sie sicher, dass Sie diese zwei / drei Dinge überall tun, sonst stirbt Ihr Code zur Laufzeit eines schrecklichen Todes."
Telastyn,
Sind Sie bereit, Async zu löschen oder die Nutzung auf die vorteilhaftesten Punkte zu reduzieren? Async IO ist nicht alles oder nichts.
USR
1
Wenn Sie den Deadlock reproduzieren können, können Sie dann nicht einfach den Stack-Trace betrachten, um den blockierenden Aufruf zu sehen?
SVICK
2
Wenn das Problem "nicht vollständig asynchron" ist, bedeutet dies, dass eine Hälfte des Deadlocks ein traditioneller Deadlock ist und im Stack-Trace des Synchronisationskontext-Threads sichtbar sein sollte.
SVICK

Antworten:

4

Ok - Ich bin mir nicht sicher, ob das Folgende für Sie hilfreich sein wird, da ich bei der Entwicklung einer Lösung einige Annahmen getroffen habe, die in Ihrem Fall möglicherweise zutreffen oder nicht. Vielleicht ist meine "Lösung" zu theoretisch und funktioniert nur für künstliche Beispiele - ich habe keine Tests durchgeführt, die über die folgenden Punkte hinausgehen.
Darüber hinaus würde ich das Folgende eher als Problemumgehung als als echte Lösung ansehen, aber angesichts des Mangels an Antworten denke ich, dass es immer noch besser als nichts ist (Ich habe Ihre Frage beobachtet und auf eine Lösung gewartet, aber nicht gesehen, dass eine veröffentlicht wurde. Ich habe angefangen zu spielen herum mit der Ausgabe).

Aber genug gesagt: Nehmen wir an, wir haben einen einfachen Datendienst, mit dem eine Ganzzahl abgerufen werden kann:

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

Eine einfache Implementierung verwendet asynchronen Code:

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

Jetzt tritt ein Problem auf, wenn wir den Code "falsch" verwenden, wie in dieser Klasse dargestellt. Foogreift Task.Resultnicht awaitwie Barfolgt auf das Ergebnis zu :

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

Was wir (Sie) jetzt brauchen, ist eine Möglichkeit, einen Test zu schreiben, der beim Anrufen erfolgreich ist, beim Anrufen Barjedoch fehlschlägt Foo(zumindest, wenn ich die Frage richtig verstanden habe ;-)).

Ich werde den Code sprechen lassen; Folgendes habe ich mir ausgedacht (mit Visual Studio-Tests, aber es sollte auch mit NUnit funktionieren):

DataServiceMocknutzt TaskCompletionSource<T>. Auf diese Weise können wir das Ergebnis an einem definierten Punkt im Testlauf einstellen, der zum folgenden Test führt. Beachten Sie, dass wir einen Delegaten verwenden, um die TaskCompletionSource an den Test zurückzugeben. Sie können dies auch in die Initialize-Methode des Tests einfügen und Eigenschaften verwenden.

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;

Was hier passiert, ist, dass wir zuerst überprüfen, ob wir die Methode ohne Blockierung verlassen können (dies würde nicht funktionieren, wenn jemand darauf zugreift Task.Result- in diesem Fall würden wir in eine Zeitüberschreitung geraten, da das Ergebnis der Aufgabe erst verfügbar gemacht wird, nachdem die Methode zurückgekehrt ist ).
Dann setzten wir das Ergebnis (jetzt kann das Verfahren durchführt) und wir das Ergebnis (in einem Gerät zu testen wir Task.Result zugreifen können , wie wir tatsächlich überprüfen wollen die Blockierung auftreten).

Vollständige Testklasse - BarTesterfolgreich und FooTestnicht wie gewünscht.

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

Und eine kleine Helferklasse zum Testen auf Deadlocks / Timeouts:

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);
    }
}
Matthias
quelle
Gute Antwort. Ich plane, Ihren Code selbst zu testen, wenn ich etwas Zeit habe (ich weiß nicht genau, ob er funktioniert oder nicht), aber ein Lob und eine Aufwertung für die Mühe.
Robert Harvey
-2

Hier ist eine Strategie, die ich in einer riesigen und sehr, sehr vielschichtigen Anwendung angewendet habe:

Zunächst benötigen Sie eine Datenstruktur um einen Mutex (leider) und führen keine Synchronisierung des Anrufverzeichnisses durch. In dieser Datenstruktur gibt es eine Verknüpfung zu einem zuvor gesperrten Mutex. Jeder Mutex hat eine "Ebene" beginnend mit 0, die Sie beim Erstellen des Mutex zuweisen und die sich niemals ändern kann.

Und die Regel lautet: Wenn ein Mutex gesperrt ist, dürfen Sie andere Mutexe nur auf einer niedrigeren Ebene sperren. Wenn Sie diese Regel befolgen, können Sie keine Deadlocks haben. Wenn Sie einen Verstoß feststellen, ist Ihre Anwendung weiterhin funktionsfähig.

Wenn Sie einen Verstoß feststellen, gibt es zwei Möglichkeiten: Möglicherweise haben Sie die Ebenen falsch zugewiesen. Sie haben A und anschließend B gesperrt, sodass B eine niedrigere Stufe haben sollte. Also korrigieren Sie den Pegel und versuchen es erneut.

Die andere Möglichkeit: Sie können es nicht beheben. Ein Code von Ihnen sperrt A, gefolgt von Sperren von B, während ein anderer Code B, gefolgt von Sperren von A, sperrt. Es gibt keine Möglichkeit, die Ebenen zuzuweisen, um dies zuzulassen. Und dies ist natürlich ein möglicher Deadlock: Wenn beide Codes gleichzeitig auf verschiedenen Threads ausgeführt werden, besteht die Möglichkeit eines Deadlocks.

Nach dieser Einführung gab es eine relativ kurze Phase, in der die Pegel angepasst werden mussten, gefolgt von einer längeren Phase, in der potenzielle Deadlocks festgestellt wurden.

gnasher729
quelle
4
Es tut mir leid, wie gilt das für asynchrone / Warte-Verhalten? Ich kann eine benutzerdefinierte Mutex-Verwaltungsstruktur nicht realistisch in die Task Parallel Library einfügen.
Telastyn
-3

Verwenden Sie Async / Await, um teure Anrufe wie in eine Datenbank zu parallelisieren? Abhängig vom Ausführungspfad in der DB ist dies möglicherweise nicht möglich.

Die Testabdeckung mit Async / Warten kann eine Herausforderung sein, und es gibt nichts Besseres als eine echte Produktionsauslastung, um Fehler zu finden. Ein Muster, das Sie in Betracht ziehen könnten, besteht darin, eine Korrelations-ID zu übergeben und im Stapel zu protokollieren. Anschließend gibt es ein kaskadierendes Zeitlimit, das den Fehler protokolliert. Dies ist eher ein SOA-Muster, aber es gibt Ihnen zumindest einen Eindruck davon, woher es kommt. Wir haben dies mit Splunk verwendet, um Deadlocks zu finden.

Robert-Ryan.
quelle