Nouvelles Chroniques d'Amethyste

Penser au Sens, pas au Verbe

Les nouveaux chemins vers l’asynchronisme – IV

Poster un commentaire

La suite (mais pas la fin) de notre 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/

 

Vous remarquerez que dans les blogs, sur des sujets un peu techniques comme les Tasks on lit souvent des trucs du style: « un de mes collègues n’arrivait pas à… », « on m’a un jour demandé si…. » et hop je suis arrivé tel Zorro et j’ai sauvé la planète.

Ok, pourquoi pas. Personnellement je n’ai pas honte d’avouer qu’un jour ne savais pas faire, que j’ai parfois eu un peu de mal à comprendre et que j’ai fais toutes les erreurs possibles, même les plus stupides. Bref, le collègue et bien c’était moi!

C’est pour ça que je vais essayer de bien souligner les trucs que j’ai testé et qui ne marchent pas. Disons le, les Tasks ainsi que async/await, c’est plein de subtilités et ne marche pas toujours du premier coup.

Lorsque j’ai commencé à travailler avec les Task il y a quelque temps déjà, puis plus tard avec async/await, le problème récurrent que j’ai rencontré est de savoir intégrer ces outils dans du code de la vie réelle. Au début je n’y arrivais pas et mes méthodes asynchrones n’étaient rien de plus que des méthodes synchrones déguisées.

Alors abordons ensemble cette question pour ne pas faire les même erreurs que moi en écrivant notre première méthode asynchrone. Il serait par contre utile de lire (ou relire) au moins l’article précédent.

Première méthode asynchone

Nous allons tester dans une application Console dont voici l’IHM que nous essayerons de garder responsive:

Console.WriteLine("");
Console.WriteLine("---------------------------------------------------------------------------");
Console.WriteLine("Super IHM à votre service");
while (true)
{
    Console.ReadLine();
    Console.WriteLine("Bîîîîîîîîîip");
}

Mon premier essai:

using (WebClient webClient = new WebClient())
{
    string result = webClient.DownloadString("http://www.google.fr");
    Console.WriteLine(result);
}

Pas terrible, DownloadString est trop rapide pour avoir le temps de tester quelque chose. Je vais donc créer un petit serveur Web Api.

Regardez ce prodige de technologie:

// GET /Api/FakeLoad
public string Get(int duration)
{
    Thread.Sleep(duration);
    return "Hello Amethyste";
}

On a plus qu’à lancer une url du genre:

http://localhost/DemoWebapi/api/Fakeload?duration=10000

Vous constatez que pendant 10 secondes l’application est en attente et l’IHM est inaccessible.
Comment améliorer les choses?
DownloadString a justement une version asynchrone, c’est précisément ce que l’on cherche.

Alors testons ceci:

using (WebClient webClient = new WebClient())
{
    Task<string> result = webClient
       .DownloadStringTaskAsync("http://localhost/DemoWebapi/api/Fakeload?duration=10000");
    Console.WriteLine("C'est partit!");
    Console.WriteLine(result.Result);
}

Lancer ce code et faites 2 ou 3 ENTER pour activer l’IHM. On devrait voir un affichage similaire à celui-ci:

2015-03-12_09-40-38

Ce résultat montre qu’effectivement l’appel au service est asynchrone, mais ce n’est pas pour autant que l’on récupère le contrôle de notre application. Ce code est au final le même que le précédent, simplement au lieu d’attendre sur l’appel au service, on attend deux lignes plus bas. On est toujours en mode synchrone, on a juste consommé des ressources supplémentaires (les Tasks).

 

Pour que cette application puisse véritablement être qualifiée d’asynchrone il ne suffit pas que la requête au service soit asynchrone, il faudrait aussi que le code qui attend son retour le soit également. On a donc besoin de créer une nouvelle Task et d’y placer le dit code.

C’est ici qu’intervient la continuation avec ContinueWith. Modifions notre code ainsi:

using (WebClient webClient = new WebClient())
{
    Task result = webClient
       .DownloadStringTaskAsync("http://localhost/DemoWebapi/api/Fakeload?duration=10000")
    .ContinueWith(task =>
    {
        Console.WriteLine("C'est partit!");
        Console.WriteLine(task.Result);
    });
}

Dans ce contexte, La méthode ContinueWith attend une Action<Task<String>>. Le Task<String> est le Task renvoyé par DownLoadStringTaskAsync.

Même expérience que précédemment:

2015-03-12_09-50-50

Les choses n’ont rien à voir. Je lance toujours ma requête, mais je récupère immédiatement la main sur mon interface. S’il s’agissait d’une vraie IHM je pourrai même lancer d’autres commandes.

Cette fois on a bien écrit une application asynchrone!!!!

Pour bien clarifier les choses, il aurait même été possible de découpler complètement les deux Tasks:

Task<string> downloadTask = webClient
       .DownloadStringTaskAsync("http://localhost/DemoWebapi/api/Fakeload?duration=10000");

Console.WriteLine("On continue sans toi...");

Task continueTask = downloadTask.ContinueWith(task =>
{
    Console.WriteLine("C'est partit!");
    Console.WriteLine(task.Result);
});

2015-03-15_10-19-29

Normalement ce genre de code est poussé dans une méthode. Cela peut ressembler à:

public static Task<string> LoadStringAsync()
{
    WebClient webClient = new WebClient();

    Task<string> downloadTask = webClient
    .DownloadStringTaskAsync("http://localhost/DemoWebapi/api/Fakeload?duration=10000");
    Console.WriteLine("Travail en cours...");

    return downloadTask.ContinueWith(t =>
    {
        webClient.Dispose();
        return t.Result;
    });
}

Remarquez bien le return sur l’appel de ContinueWith. c’est ce que l’on oublie souvent et du coup le code ne compile pas. La clause return à l’intérieur de ContinueWith ne sert qu’à revenir de la continuation, pas de la méthode LoadStringAsync.

 

On pourrait appeler cette méthode par un des moyens suivants:

Task<string> taskRetour = LoadStringAsync();
taskRetour.ContinueWith(t =>
{
    string message = t.Result;
    Console.WriteLine(message);
});

Ou bien si on a pas d’IHM à garder réactive:

Task<string> taskRetour = LoadStringAsync();

// d'autres tâches ici...

Console.WriteLine(taskRetour.Result);

On aura une attente sur l’appel à Result. Dans le cas où on lance plusieurs méthodes asynchrones on utilisera plutôt un Task.WaitAll pour l’attente.

Pourrait t’on demander à LoadStringAsync de remonter directement une String plutôt qu’une Task<String>?

Oui, pourquoi pas, mais il faut bien réaliser que la méthode deviendrait alors synchrone. On devrait la renvelopper dans une Task avec par exemple Task.Run si on a besoin de garder la réactivité de l’IHM.

 

Vous avez peut être remarqué que l’on a du déconstruire le bloc using et appeler explicitement Dispose dans la continuation. Appeler Dispose est indispensable dans du code de production, mais pourquoi abandonner using?

Le schéma suivant montre la cinématique du code:

2015-03-24_10-03-33

On démarre en 1 par l’appel à LoadStringAsync. L’exécution se produit normalement jusqu’en 3. Une Task est alors planifiée de façon asynchrone. Le travail continue donc à la ligne suivante et arrive en 4 où une continuation est rencontrée.

L’exécution de LoadStringAsync s’interrompt donc jusqu’à ce que la tâche de fond termine ce qu’elle a à faire. La suite dépend bien entendu de la vitesse d’exécution de la tâche asynchrone. Elle peut très bien avoir terminé son travail une fois arrivée en 4, mais on va se placer dans le cas intéressant où elle est assez longue.

Le code reprend alors à la ligne qui suit 1. Il s’agit là aussi d’une continuation. Si la Task n’a pas terminée avant on continue jusqu’en 6. Il s’agit de l’IHM matérialisée par le while(true). L’IHM reste réactive, dans une vraie application on pourrait par exemple lancer d’autres commandes.

Au bout d’un moment la Task termine ce qu’elle a à faire. L’exécution se poursuit là où elle a été interrompue, c’est à dire en 4 et on exécute le code en 7. Il est rapide, on passe donc immédiatement à la suite qui est le code 8 de la deuxième continuation. Celui-ci n’a qu’une chose à faire, afficher le message.

Tout est conforme à ce qui est observé sur la console.

Ce petit mécanisme qui est très différent du fonctionnement synchrone doit absolument être compris. Vous constaterez d’ailleurs que c’est plutôt subtil et plein de pièges. Nous verrons dans un autre article comment  async/await rend les choses plus intuitives.

On peut alors répondre à la question. Le bloc using (les foreach également) est un des trucs délicats à gérer avec les Task car de part la nature du fonctionnement asynchrone, on va à un certain moment quitter le bloc using le temps que la continuation soit appelée. On risque donc d’avoir des ressources essentielles libérées et voir ainsi la tâche asynchrone échouer. C’est la raison pour laquelle on appelle Dispose explicitement dans le continuation.

De la même façon avec Task on aurait besoin de déconstruire une boucle foreach en gérant à la main le pattern Enumerator par exemple.

Les exceptions

Notez tout de suite que laisser des exceptions se déclencher de façon silencieuse est rarement une bonne idée. Ce faisant on se prive de la possibilité de loguer, faire du nettoyage, des sauvegardes, annuler d’autres actions, restaurer l’application dans un état standard… avec les risques de voir des fuites mémoire apparaître en raison d’objet mal libérés.

La librairie TPL apporte une prise en charge des exceptions. Faisons un essai avec le code précédent en utilisant une url qui n’existe pas afin de lever une exception. On constante deux choses:

  1. l’exception ne se produit pas à la ligne d’appel de DownloadStringAsync, mais ici:
    Console.WriteLine(task.Result);
  2. On récupère une AggregateException
    Cette nouvelle exception expose une nouvelle propriété InnerExceptions qui est une collection de Exception

2015-03-15_10-31-33

Note: oui, en anglais aggregate prend deux g alors qu’en français agréger n’en a qu’un seul.

La découverte d’une exception lors de l’exécution d’une Task est elle aussi asynchrone. On peut le faire de différentes façons.

1) En lisant une des propriétés:

  • IsFaulted  = true
  • Status = TaskStatus.Faulted
  • Exception => Une collection d’exceptions
  • Result

2) En appelant une des méthodes Wait.

L’appel à Result et aux méthodes en Wait lève effectivement l’exception qui peut être capturée par un catch. Ce n’est pas l’exception originelle. Celles-ci est encapsulée dans une AggregateException. On doit lire InnerExceptions pour récupérer la ou les exceptions qui ont été levées lors des appels asynchrones.

Cela peut paraître compliqué, mais il y a des raisons.

Une première raison qui justifie AgregateException est que l’on peut avoir besoin de créer une Task qui sert simplement à encapsuler une série d’autres Task. Chacune de ces Task secondaires pouvant bien entendu lever des exceptions. On aura alors un schéma de code comme celui-ci:

// collection pour enregistrer les exceptions des actions secondaires
List<Exception> exceptions = new List<Exception>();

// un peu de code ici

// on récupère une exception, alimentons donc exceptions en attendant les autres tâches secondaires
exceptions.Add(...);

// encore un peu de code .....

// une autre exception à ajouter à la liste
exceptions.Add(...);

// c'est terminé, on renvoie le résultat
AggregateException aggregateException = new AggregateException(exceptions);
throw aggregateException;

Qui permet de notifier le client de la Task principale que des exceptions se sont déclenchées sans se limiter à la première venue.

 

Il y a ensuite une raison liée à l’histoire de .Net.

Relancer une exception a pour effet de remplacer la pile des appels de l’exception par celle de l’emplacement où a lieu la relance. Par exemple un code comme celui-ci:

2015-03-15_11-12-33

En (1) on capture une exception dont la pile des appels indique:

à ConsoleApplication2.Program.BigComputingHere() dans c:\…\Program.cs:ligne 60
à ConsoleApplication2.Program.Demo() dans c:\…\Program.cs:ligne 45

On a toutes les informations utiles pour déboguer le code. On remarque que en (2) l’exception est relancée et donc sera interceptée en (3). Mais cette fois les choses sont différentes puisque la pile des appels indique:

à ConsoleApplication2.Program.Demo() dans c:\…\Program.cs:ligne 54
à ConsoleApplication2.Program.Main(String[] args) dans c:\…\Program.cs:ligne 41
à System.AppDomain._nExecuteAssembly(RuntimeAssembly assembly, String[] args)
à System.AppDomain.ExecuteAssembly(String assemblyFile, Evidence assemblySecurity, String[] args)
à Microsoft.VisualStudio.HostingProcess.HostProc.RunUsersAssembly()
à System.Threading.ThreadHelper.ThreadStart_Context(Object state)
à System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
à System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state, Boolean preserveSyncCtx)
à System.Threading.ExecutionContext.Run(ExecutionContext executionContext, ContextCallback callback, Object state)
à System.Threading.ThreadHelper.ThreadStart()

La ligne 41 est celle de l’appel à Demo() et la ligne 54 celle du throw.

Pas vraiment de quoi comprendre ce qui a pu se passer. C’est pourquoi la TPL a été obligée d’implémenter AgregateException pour résoudre ce problème. Au lieu de relancer une exception et perdre son contexte on lance AgregateException qui enregistre dans ses propriété l’exception initiale.

Depuis la version .Net 4.5 on dispose d’une alternative qui résout le problème:

ExceptionDispatchInfo.Capture(ex).Throw();

Revenons à nos exceptions. Normalement on lèvera toujours une exception avec les Task qui retournent un résultat puisque l’on fera en principe une lecture de Result.

Il en va différemment pour les Task de type void.

La seule façon de réaliser qu’il y a une exception est de lire son statut et appliquer un code de gestion des exceptions trouvées ou de faire un appel à une méthode Wait qui lèvera l’exception.

Notez que depuis .Net 4.5 Microsoft a modifié le comportement par défaut du processus d’escalade des exceptions non observées: l’application ne plante plus par défaut, mais continue de lever UnobservedTaskException . L’exception est simplement absorbée.

Pas certain que ce soit une bonne idée.

Il est tout de même possible de revenir au comportement de .Net 4.0 en complétant le fichier de configuration:

<runtime>
    <ThrowUnobservedTaskExceptions enabled="true"/>
</runtime>

Les exceptions qui ne sont pas explicitement observées ne sont pas totalement ignorées pour autant. En effet les exceptions non observées lèvent l’événement UnobservedTaskException que l’on peut parfaitement écouter.

Faisons quelques essais et tout d’abord le point sur les trucs sur lesquels j’ai galéré lorsque j’ai exploré cela la première fois:

  • L’événementUnobservedTaskException  n’est levé que lorsque le ramasse-miette finalise les Tasks
  • Il faut faire des essais en compilant en release, sinon ça ne marche pas

Testons tout d’abord ThrowUnobservedTaskException avec le code suivant:

using (WebClient webClient = new WebClient())
{
    Task<string> downloadTask = webClient
        .DownloadStringTaskAsync("http://localhost/DemoWebapi/api/Fakeload?duration=1000");
    Console.WriteLine("On continue sans toi...");

    Task continueTask = downloadTask.ContinueWith(task =>
    {
        Console.WriteLine("C'est partit!");
        throw new ArgumentNullException();
    });
}

Et on complète la boucle while avec:

GC.Collect();
GC.WaitForPendingFinalizers();

Si on a pas de boucle ajoutez une petit Thread.Sleep pour laisser un peu de temps à l’exception pour être levée.

En mode .Net 4.0:

2015-03-15_21-57-02

Et l’application se termine.

Vous constaterez qu’en en mode .Net 4.5, rien de visible se produit. Je le redis, ce n’est sûrement pas une bonne idée.

On peut s’abonner à l’événement UnobservedTaskException:

TaskScheduler.UnobservedTaskException += (object sender, UnobservedTaskExceptionEventArgs e)
=>
{
    Console.WriteLine("Je t'ai vu: {0}", e.Exception.Message);

    // actions appropriées ...

    e.SetObserved();
};

using (WebClient webClient = new WebClient())
{
    Task<string> downloadTask = webClient
    .DownloadStringTaskAsync("http://localhost/DemoWebapi/api/Fakeload?duration=1000");
    Console.WriteLine("On continue sans toi...");

    Task continueTask = downloadTask.ContinueWith(task =>
    {
        Console.WriteLine("C'est partit!");
        throw new ArgumentNullException();
    });
}

Et on complète la boucle while avec:

GC.Collect();
GC.WaitForPendingFinalizers();

Lancez l’exécutable et:

2015-03-15_21-48-28

L’application devrait continuer à fonctionner, mais cette fois un traitement devient possible. Notez que cette fois on n’a pas besoin de faire de modifications dans le fichier de configuration.

Le problème est que ce mécanisme repose sur l’hypothèse que le GC fasse une collecte. C’est loin d’être certain et lancer à la main un GC.Collect() fait partie de ces idées stupides qui servent essentiellement à plomber les performances de l’application.

 

Quel est le comportement des méthodes de continuation avec les exceptions?

Il existe des surcharges qui attendent une valeur de l’énumération TaskContinuationOptions. qui va donner des indices sur les conditions sous lesquelles la continuation sera effectuée. Voici les valeurs qui vont plus particulièrement nous intéresser:

  • OnlyOnFaulted
    On continue uniquement en cas d’exception
  • OnlyOnRanToCompletion
    on continue uniquement en cas de succès de la Task
  • NotOnFaulted
    Inverse de OnlyOnFaulted
  • NotOnRanToCompletion
    Inverse de OnlyOnRanToCompletion
using (WebClient webClient = new WebClient())
{
    Task<string> downloadTask = webClient.DownloadStringTaskAsync("http://localhost/test/test");
    Console.WriteLine("On continue sans toi...");

    Task continueTask = downloadTask.ContinueWith(task =>
    {
        Console.WriteLine("C'est partit!");
        Console.WriteLine(task.Result);
    });

    continueTask.ContinueWith(task =>
    {
         Console.WriteLine("Ca s'est mal passé là haut");
    }, TaskContinuationOptions.OnlyOnFaulted);
    continueTask.ContinueWith(task =>
    {
        Console.WriteLine("Tout va bien");
    });
}

 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