Comment puis-je diagnostiquer les blocages asynchrones / en attente?

24

Je travaille avec une nouvelle base de code qui utilise massivement async / wait. La plupart des membres de mon équipe sont également relativement nouveaux dans async / wait. Nous avons généralement tendance à respecter les meilleures pratiques spécifiées par Microsoft , mais nous avons généralement besoin que notre contexte passe par l'appel asynchrone et travaillons avec des bibliothèques qui ne le font pas ConfigureAwait(false).

Combinez toutes ces choses et nous rencontrons des blocages asynchrones décrits dans l'article ... hebdomadaire. Ils n'apparaissent pas pendant les tests unitaires, car nos sources de données simulées (généralement via Task.FromResult) ne sont pas suffisantes pour déclencher l'impasse. Ainsi, lors des tests d'exécution ou d'intégration, certains appels de service sortent pour le déjeuner et ne reviennent jamais. Cela tue les serveurs et fait généralement un gâchis.

Le problème est que la recherche de l'endroit où l'erreur a été commise (généralement pas tout à fait asynchrone) implique généralement une inspection manuelle du code, ce qui prend du temps et n'est pas automatisable.

Quelle est la meilleure façon de diagnostiquer la cause de l'impasse?

Telastyn
la source
1
Bonne question; Je me suis posé cette question moi-même. Avez-vous lu la collection d' asyncarticles de ce type ?
Robert Harvey
@RobertHarvey - peut-être pas tous, mais j'en ai lu quelques-uns. Plus "Assurez-vous de faire ces deux / trois choses partout, sinon votre code mourra d'une mort horrible à l'exécution".
Telastyn du
Êtes-vous prêt à abandonner l'async ou à réduire son utilisation aux points les plus avantageux? Async IO n'est pas tout ou rien.
usr
1
Si vous pouvez reproduire le blocage, ne pouvez-vous pas simplement regarder la trace de la pile pour voir l'appel de blocage?
svick
2
Si le problème n'est pas "asynchrone à fond", cela signifie que la moitié du blocage est un blocage traditionnel et doit être visible dans la trace de pile du thread de contexte de synchronisation.
svick

Réponses:

4

Ok - Je ne sais pas si les éléments suivants vous seront utiles, car j'ai fait certaines hypothèses dans le développement d'une solution qui peut ou non être vraie dans votre cas. Peut-être que ma "solution" est trop théorique et ne fonctionne que pour des exemples artificiels - je n'ai pas fait de test au-delà des choses ci-dessous.
De plus, je verrais ce qui suit plus une solution de contournement qu'une vraie solution, mais étant donné le manque de réponses, je pense que cela pourrait être mieux que rien (j'ai continué à regarder votre question en attendant une solution, mais sans en voir une se publier, j'ai commencé à jouer autour de la question).

Mais assez dit: disons que nous avons un service de données simple qui peut être utilisé pour récupérer un entier:

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

Une implémentation simple utilise du code asynchrone:

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

Maintenant, un problème se pose, si nous utilisons le code "incorrectement" comme illustré par cette classe. Fooaccède de manière incorrecte Task.Resultau lieu de awaitgénérer le résultat comme le Barfait:

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

Ce dont nous (vous) avons maintenant besoin, c'est d'un moyen d'écrire un test qui réussit lors de l'appel Barmais échoue lors de l'appel Foo(du moins si j'ai bien compris la question ;-)).

Je vais laisser le code parler; voici ce que j'ai trouvé (en utilisant des tests Visual Studio, mais cela devrait aussi fonctionner en utilisant NUnit):

DataServiceMockutilise TaskCompletionSource<T>. Cela nous permet de définir le résultat à un point défini dans le test, ce qui conduit au test suivant. Notez que nous utilisons un délégué pour renvoyer le TaskCompletionSource dans le test. Vous pouvez également mettre cela dans la méthode Initialize des propriétés test et use.

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;

Ce qui se passe ici, c'est que nous vérifions d'abord que nous pouvons laisser la méthode sans bloquer (cela ne fonctionnerait pas si quelqu'un y accédait Task.Result- dans ce cas, nous courrions dans un délai d'attente car le résultat de la tâche n'est rendu disponible qu'après le retour de la méthode ).
Ensuite, nous définissons le résultat (maintenant la méthode peut s'exécuter) et nous vérifions le résultat (à l'intérieur d'un test unitaire, nous pouvons accéder à Task.Result car nous voulons réellement que le blocage se produise).

Classe de test complète - BarTestréussit et FooTestéchoue comme souhaité.

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

Et une petite classe d'aide pour tester les blocages / 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
la source
Bonne réponse. Je prévois d'essayer votre code moi-même quand j'aurai du temps (je ne sais pas vraiment si cela fonctionne ou non), mais bravo et une augmentation pour l'effort.
Robert Harvey
-2

Voici une stratégie que j'ai utilisée dans une application énorme et très, très multithread:

Tout d'abord, vous avez besoin d'une structure de données autour d'un mutex (malheureusement) et ne faites pas de répertoire d'appels de synchronisation. Dans cette structure de données, il existe un lien vers tout mutex précédemment verrouillé. Chaque mutex a un "niveau" commençant à 0, que vous attribuez lorsque le mutex est créé et ne peut jamais changer.

Et la règle est la suivante: si un mutex est verrouillé, vous ne devez verrouiller que d'autres mutex à un niveau inférieur. Si vous suivez cette règle, vous ne pouvez pas avoir de blocages. Lorsque vous constatez une violation, votre application est toujours en place et fonctionne très bien.

Lorsque vous constatez une violation, il y a deux possibilités: vous avez peut-être mal attribué les niveaux. Vous avez verrouillé A suivi de verrouiller B, donc B aurait dû avoir un niveau inférieur. Vous fixez donc le niveau et réessayez.

L'autre possibilité: vous ne pouvez pas le réparer. Certains de vos codes verrouillent A suivi du verrouillage B, tandis que d'autres codes verrouillent B suivis du verrouillage A. Il n'y a aucun moyen d'attribuer les niveaux pour permettre cela. Et bien sûr, c'est un blocage potentiel: si les deux codes s'exécutent simultanément sur des threads différents, il y a un risque de blocage.

Après avoir introduit cela, il y a eu une phase assez courte où les niveaux ont dû être ajustés, suivie d'une phase plus longue où des blocages potentiels ont été trouvés.

gnasher729
la source
4
Je suis désolé, comment cela s'applique-t-il au comportement asynchrone / attente? Je ne peux pas, de façon réaliste, injecter une structure de gestion mutex personnalisée dans la bibliothèque parallèle de tâches.
Telastyn
-3

Utilisez-vous Async / Await pour pouvoir paralléliser des appels coûteux comme vers une base de données? Selon le chemin d'exécution dans la base de données, cela peut ne pas être possible.

La couverture des tests avec async / wait peut être difficile et il n'y a rien de tel qu'une utilisation réelle de la production pour trouver des bogues. Un modèle que vous pouvez envisager est de passer un ID de corrélation et de le journaliser dans la pile, puis d'avoir un délai d'expiration en cascade qui enregistre l'erreur. Il s'agit plus d'un modèle SOA, mais au moins, cela vous donnerait une idée de son origine. Nous l'avons utilisé avec Splunk pour trouver des blocages.

Robert-Ryan.
la source