Nouvelles Chroniques d'Amethyste

Penser au Sens, pas au Verbe

Les nouveaux chemins vers l’asynchronisme – V

Poster un commentaire

Continuons la série:

  1. https://amethyste16.wordpress.com/2014/10/02/les-nouveaux-chemins-vers-lasynchronisme-i/
  2. https://amethyste16.wordpress.com/2014/10/03/les-nouveaux-chemins-vers-lasynchronisme-ii/
  3. https://amethyste16.wordpress.com/2015/03/10/les-nouveaux-chemins-vers-lasynchronisme-iii/
  4. https://amethyste16.wordpress.com/2015/03/15/les-nouveaux-chemins-vers-lasynchronisme-iv/

Je ne vais pas encore aborder la question de async/await, mais parler de petits points techniques importants à connaître:

  • TaskStatus
  • Expériences mystérieuses avec Task.Run
  • TaskCompletionSource<TResult>

TaskStatus

TaskStatus est une énumération destinée à marquer le statut d’une Task. La liste de ses valeurs peut être trouvée ici:

https://msdn.microsoft.com/fr-fr/library/system.threading.tasks.taskstatus(v=vs.110).aspx

 

Il existe deux types de Task:

  1. Les Task déléguées sont celles qui exécutent du code.
  2. Les TaskCompletionSource qui vont être analysées au chapitre suivant.

Les statuts son organisés comme un workflow représenté par le diagramme ci-dessous pour les tâches déléguées:

2015-03-17_22-46-45

Ce diagramme a été récupéré ici:

http://blog.stephencleary.com/2014/06/a-tour-of-task-part-3-status.html

Les Task déléguées sont créées avec Task.Run ou Task.Factory.StartNew. Elle sont initialisées avec le statut WaitingToRun. Ce statut signifie que la tâche est associé à un planificateur de tâche et qu’elle est prête à démarrer.

On peut aussi créer la Task depuis un constructeur. Elle débute alors dans la vie avec le statut Created. Elle passe à l’état WaitingToRun  lors de appel à la méthode Start() ou RunSynchronously().

Si la tâche est la continuation d’une autre Task, alors elle démarre à WaitingForActivation et bascule automatiquement à WaitingToRun lorsque la Task mère se termine. WaitingForActivation signifie que la Task n’a pas encore été planifiée.

Le planificateur finit par activer la tâche et le délégué de la Task s’exécute. Celle-ci passe à Running. Une fois terminé on passe à WaitingForChildrenToComplete jusqu’à ce que toutes les Task filles se terminent. Finalement la Task se termine dans l’un des états:

  • RanToCompletion => tout s’est bien passé
  • Faulted => une exception a été levée
  • Canceled => la Task a été annulée

Notez également que certains de ces états peuvent ne jamais être observée si par exemple la Task est annulée.

Le workflow des TaskCompletionSource est un peu plus simple:

2015-03-17_23-09-32

Je pense que l’interprétation de ce diagramme se déduit aisément du précédent.

Expériences mystérieuses avec Task.Run

Lorsque j’ai fait mes premiers essais pour comprendre la cinématique des statuts afin d’écrire cet article, je suis vite tombé sur un truc très surprenant. J’ai tout d’abord modifié mon IHM ainsi:

while (true)
{
    key = Console.ReadLine();
    Console.WriteLine("Bîîîîîîîîîip");
    Console.WriteLine(task.Status);
}

Et le code de test:

string key = null;
Task task = Task.Run(
    () =>
        {
            while (true)
            {
                if (key == "c")
                {
                    break;
                }
            }
});

On lance on fait: ENTER, c, c et ENTER

2015-03-27_21-56-57

Tant que la Task s’exécute son statut vaut Running et une fois la tâche achevée après passage par le break elle passe à RanToCompletion. C’est parfaitement conforme au diagramme du chapitre qui précède.

Essayons maintenant avec Task.Run:

Task task = Task.Run(
    () =>
    {
        while (true)
        {
            if (key == "c")
            {
                break;
            }
        }
    });

La même manœuvre donne:

2015-03-27_22-30-59

Rien à voir. La Task tourne pourtant, elle est donc forcément lancée, il est donc inexplicable que le statut soit WaitingForActivation et y reste. J’ai trouvé l’explication dans ce blog.

Tout d’abord on voit vite que si on supprime le while(true), le comportement est celui attendu.

Puisque la fonction déléguée ne retourne rien en raison de la boucle while (c’est à dire qu’il n’y a pas de return), parmi les différentes surcharges de Run les deux meilleurs candidats sont:

public static Task Run(Action action);
public static Task Run(Func<Task> function);

Et pour des raisons liées aux spécifications de .NET, la surcharge avec Func<> est privilégiée. Transformer une méthode sans return avec une méthode avec return peut paraître bizarre, mais en fait on rencontre ce cas très souvent. Regardez ces deux méthodes qui compilent sans problème:

int Meth1()
{
    while(true) {};
}

int Meth2()
{
    throw new Exception();
}

Et heureusement car il est impossible de placer un return sans avoir une alerte de compilation sur la présence de code inatteignable.

 

Donc le compilateur transforme une fonction lambda sans type de retour en fonction avec un retour qui est Task. On a donc deux Task dans les coulisses. Si on regarde le code source de la méthode Run on peut voir qu’au final on appelle ceci:

2015-03-27_23-16-27

Ce qui est renvoyé ce n’est pas la Task qui est exécutée (task1 dans le code), mais une autre Task proxy dont le statut ne change pas car elle n’a pas vocation à être planifiée. C’est cela que l’on observe.

Si on souhaite voir nos statuts il faudrait éviter la création de la Task proxy en écrivant:

Task task = Task.Run(
    (Action)(() =>
        {
            while (true)
            {
                if (key == "c")
                {
                    break;
                }
            }
       }));

On force la première surcharge en Action. Je vous laisse le plaisir de vérifier que tout fonctionne comme dans le premier exemple.

Gardez bien ceci en tête car le fonctionnement de votre code va en dépendre en particulier si vous devez gérer des exceptions, l’annulation ou des fonctions de continuation. Je reparlerait de ce problème dans de futurs articles.

Caster avec Action n’est pas la seule solution et n’est d’ailleurs pas toujours possible.

Une possibilité est la suivante:

Task.Run(
    () =>
        {
            while (true)
            {
                if (key == "c")
                {
                  break;
                }
            }
       })
   .ContinueWith(t=>
   {
      Console.WriteLine(t.Status);
   }
);

Et l’affichage est conforme à ce qui est attendu:

2015-03-28_11-35-48

On se désintéresse à la Task renvoyée par Run qui n’est pas celle qui nous intéresse et on ajoute une méthode de continuation pour surveiller le retour.

Si la fonction déléguée dispose d’un moyen indépendant de sortir de la boucle et que l’on a pas de problématique d’IHM on peut aussi attendre la Task:


Task.WaitAll(task);

Et on vérifie qu’une fois WaitAll effectué le statut est conforme aux prévisions.

Pour avoir fait le tour de la question remplaçons le délégué par un délégué avec un retour explicitement exprimé:

Task<int> task =Task.Run<int>(
    () =>
    {
        while (true)
        {
            if (key == "c")
            {
                return 10;
            }
        }
    });

Et cette fois tout va bien également:

2015-03-28_11-35-48

Moralité: dès que vous avez une méthode asynchrone void, testez bien, soyez prudent. C’est une source incalculable de bug.

TaskCompletionSource<TResult>

TaskCompletionSource<TResult> fait partie de ces outils dont on ne perçoit pas tout de suite l’intérêt.

Regardons cette méthode inspirée d’un article de Stephen Stoub:

public static Task StartNewDelayedAsync()
{
    Action action = () =>
    {
        Console.WriteLine("hello le monde");
    };
    Task t = new Task(action);

    Timer timer = new Timer(_ => t.Start(), null, 10000, Timeout.Infinite);
    t.ContinueWith(_ => timer.Dispose());
    return t;
}

La méthode créée une Task, mais ne la lance pas immédiatement. L’appel à Start est effectué un peu plus tard par le Timer au bout de 10 secondes.

Toutefois rien ne nous interdit de faire la chose suivante:

Task task = StartNewDelayedAsync();
task.Start();

On démarre la Task avant d’être consommée par le Timer. Ce n’est certainement pas ce qui était attendu et le deuxième Start va planter l’application au bout de 10 secondes car une Task ne peut être lancée qu’une seule fois.

On aurait donc besoin d’un mécanisme pour empêcher le code qui consomme la méthode de prendre le contrôle de la Task. C’est justement ce que propose de faire TaskCompletionSource<TResult>.

Note: pour la suite on écrira TCS

Notons tout d’abord les points à retenir:

  • SetResult() pour fournir une valeur à T
  •  Retour avec la propriété Task<T>
  •  Employer Task<Object> pour une Task non typée et passer SetResult(null)
  •  Une TCS est initialisé dans l’état WaitingForActivation lorsqu’elle est instanciée. Cela signifie que l’on pourra lancer un await sur la Task, mais pas un Start.

Réécrivons le code précédent de la façon suivante:

public static Task StartNewDelayedAsync()
{
   TaskCompletionSource<object> tcs = new TaskCompletionSource<object>();

   Action action = () =>
   {
      Console.WriteLine("hello le monde");
   };
   Task t = new Task(action);

   Timer timer = new Timer(_ =>
   {
      tcs.SetResult(null);
      t.Start();
   }, null, 1000, Timeout.Infinite);

   t.ContinueWith(_ => timer.Dispose());

   return tcs.Task;
}

Cette fois on constate que l’appel à Start lève un InvalidOperationException.

 

Regardons un peu plus en détail ce code. Dès la première ligne on instancie une TCS sur le type Object. Cela signifie que la Task n’est pas générique, c’est à dire n’a pas de type de retour.

Nous la retrouvons sur la dernière ligne pour retourner une Task dans un certain état. L’état de la Task est celui qui a été géré par le code. Dans cet exemple rien de très particulier. Notez la ligne:

tcs.SetResult(null);

Cette ligne est indispensable pour basculer la Task dans son état attendu: RanToCompletion. C’est important car autrement il n’est pas certain que d’éventuelles méthodes en ContinueWith fonctionnent correctement.

On pourrait modifier la méthode en ajoutant un paramètre d’entrée et ainsi avoir une occasion de modification supplémentaire de l’état de la Task:

public static Task StartNewDelayedAsync(int delay)
{
    TaskCompletionSource<object> tcs = new TaskCompletionSource<object>();

    if (delay <= 0)
    {
        ArgumentOutOfRangeException ex = new ArgumentOutOfRangeException("Le délai doit être > 0");
        tcs.SetException(ex);
        return tcs.Task;
    }

    Action action = () =>
    {
        Console.WriteLine("hello le monde");
    };
    Task t = new Task(action);

    Timer timer = new Timer(_ =>
    {
        tcs.SetResult(null);
        t.Start();
    }, null, delay, Timeout.Infinite);

    t.ContinueWith(_ => timer.Dispose());

    return tcs.Task;
}

Regardez le traitement du cas où le paramètre est incorrect. L’appel à SetException permet de placer la Task dans l’état Faulted. Côté code client on pourrait écrire:

Task task = StartNewDelayedAsync(-1);
if (task.IsFaulted)
{
    throw task.Exception;
}

Qui déclenchera le traitement approprié de l’exception.

Note: il est crucial de ne jamais laisser une exception se lever silencieusement dans une application bien conçue. Elle DOIT être observée et gérée.

Note: Si la Task avait un type de retour, le simple appel à Result lèverai l’exception

 

Note: Avec .Net 4.6 on disposera d’une petite simplification qui évite d’instancier explicitement une TCS. Les détails ici:

http://blogs.msdn.com/b/pfxteam/archive/2015/02/02/new-task-apis-in-net-4-6.aspx

 

Une autre utilisation importante de TCS dans la vie réelle est de faire rentrer dans le pattern TAP (Task, async/await) une interface qui ne le propose pas nativement et un bon exemple est justement WebClient. Nous avons utilisé WebClient dans l’article précédent. Nous allons reprndre cet exemple et le réécrire ainsi:

public static Task<string> LoadStringAsync()
{
    TaskCompletionSource<string> tcs = new TaskCompletionSource<string>();

    WebClient webClient = new WebClient();
    webClient.DownloadStringCompleted += (object sender, DownloadStringCompletedEventArgs e) =>
    {
        Console.WriteLine("Me revoilà!");

        if (e.Error != null)
        {
            tcs.SetException(e.Error);
        }
        else if (e.Cancelled)
        {
            tcs.SetCanceled();
        }
        else
        {
            tcs.SetResult(e.Result);
        }

        webClient.Dispose();
    };

    Console.WriteLine("On lance la requête");
    webClient.DownloadStringTaskAsync("http://localhost/DemoWebapi/api/Fakeload?duration=10000");

    return tcs.Task;
}

Le code n’est pas spécialement compliqué à comprendre, on tire juste partie de l’événement DownloadStringCompleted pour savoir à quel moment la chaîne a été chargée.

Voilà une utilisation très classique des TCS qu’il faut savoir faire.

Une façon de consommer ce code serait:

LoadStringAsync()
.ContinueWith(t =>
{
    Console.WriteLine(t.Result);
});

2015-03-17_23-49-16

Bibliographie

Publicités

Laisser un commentaire

Entrez vos coordonnées ci-dessous ou cliquez sur une icône pour vous connecter:

Logo WordPress.com

Vous commentez à l'aide de votre compte WordPress.com. Déconnexion / Changer )

Image Twitter

Vous commentez à l'aide de votre compte Twitter. Déconnexion / Changer )

Photo Facebook

Vous commentez à l'aide de votre compte Facebook. Déconnexion / Changer )

Photo Google+

Vous commentez à l'aide de votre compte Google+. Déconnexion / Changer )

Connexion à %s