Dot.Blog

C#, XAML, WinUI, WPF, Android, MAUI, IoT, IA, ChatGPT, Prompt Engineering

De la bonne utilisation de Async/Await en C#

[new:30:06/2014]Async et Await simplifient l’écriture des applications qui doivent rester fluides et réactives. Mais cela suffit-il à rendre les applications multitâches ? Pas si simple… C’est ce que nous allons voir…

Async et Await

Pour ceux qui éventuellement auraient loupé un épisode important des aventures de C#, sa vie, son œuvre en 5 tomes, voici un bref résumé de ce que sont les mots clés Async et Await et ce à quoi ils servent.

C’est une question de méthode…

De nombreuses méthodes ne retournent pas immédiatement le trait, elles peuvent effectuer des tâches assez longues, comme accéder à des ressources extérieures (Web, réseau, appareil de mesure externe ou interne tel le compas, le GPS, l’accéléromètre…).

Toute méthode exécutée sur le thread principal bloque logiquement ce dernier or il est utilisé par l’OS pour gérer l’interface.

Toute méthode longue fige donc l’UI et confère à l’application une certaine rigidité d’un autre âge lui interdisant d’être réactive et fluide, deux points essentiels aujourd’hui.

Les threads ne sont pas des tâches

On pourrait se dire que toute méthode un peu longue à exécuter devrait être exécutée au sein d’un thread et que cette technique existe depuis longtemps. C’est tout à fait exact. Mais il faut convenir d’une part que cela rend l’écriture du code très lourde si toutes les méthodes un peu longues doivent ainsi être déplacées dans des threads – incluant les API de la plateforme – et que, d’autre part, il est assez difficile de contrôler avec précision plusieurs threads, leur début, leur fin, la gestion des erreurs, etc.

De fait, les threads ne sont pas des tasks

async et await

Pour permettre à une méthode longue de retourner le trait immédiatement (ou en tout cas au plus vite), elle peut exécuter son code en créant un tâche (Task). Le mot clé await permet d’attendre la fin d’une telle tâche. Async permet de marquer une méthode qui fera usage de await.

Fixons les choses

  • Une méthode async ne peut retourner que void ou une Task.
  • Une Task peut ne retourner aucune valeur (autre que son état).
  • Une Task<montype> retourne des données de type “montype”.

 

  • La méthode Main ne peut pas être async. La raison est simple, async permet avec await de retourner le trait immédiatement, or sortir de Main cela revient à mettre fin à l’application…
  • Elle ne peut donc pas utiliser await.
  • Si elle en a besoin elle peut lancer une méthode async en utilisant directement la classe Task
  • Elle peut aussi appeler une méthode qui elle sera async et pourra utiliser await.

 

On notera au passage un débat assez nourri sur l’utilisation de async avec une méthode retournant void. Le Web est plein de discussions savantes (ou simplement rasoirs, il y en a ! ) sur ce thème. En gros async void est de type “fire and forget”, expression militaire assez claire. De fait les seules méthodes pouvant être de type async void (donc qu’on ne peut pas attendre avec await) son des évènements (type Click par exemple). Dans tous les autres cas même si la méthode ne retourne rien elle sera de type async Task et non async void.

Une méthode marquée async sera exécutée de façon synchrone si elle ne contient pas le mot clé await au moins une fois. C’est finalement assez évident.

Techniquement async et await ne sont qu’un syntactic sugar, c’est à dire un moyen artificiel de simplifier l’écriture du code et l’utilisation des threads puisque, bien entendu, s’il y a exécutions parallèles, il a forcément des threads derrière pour le permettre. Mais l’asynchronisme ne veut pas dire multitâche L’asynchronisme est bien plus proche de la technique des callbacks que de celle des threads.

Le framework fournit de nouvelles API se terminant par le mot Async pour indiquer celles qui peuvent faire l’objet d’un await. Ce mécanisme remplace le précédent qui utilisait deux méthodes par API dont les noms se terminaient par BeginAsync et EndAsync pour démarrer l’appel asynchrone et récupérer son résultat (et clore l’appel, fermer les ressources, récupérer le code erreur éventuel…).

asynchronisme != multithreading

C’est là qu’on en revient au sujet principal de ce billet, l’asynchronisme n’est pas égal à du multithreading même si sa mise en œuvre “interne” fait souvent (mais pas toujours) usage de threads.

Par défaut un code écrit avec async/await est monotâche (un seul thread). Avec la méthode Task.Run() on peut rendre ce code multithreadé.

Un code asynchrone retourne simplement le trait avant d’avoir terminer son travail. D’autres méthodes peuvent être lancées dès lors qu’une méthode asynchrone retourne le trait.

L’asynchronisme se place dans ce possible retour rapide du trait alors que le travail n’est pas terminé, et non dans l’exécution concurrente de plusieurs codes différents.

L’asynchronisme n’est donc pas du multithreading, au moins par défaut et dans son sens premier.

Asynchronisme != parallélisme

Maintenant que nous avons poser le décor et que nous savons tous ce à quoi servent Async et Await, regardons par l’exemple pourquoi l’asynchronisme autorisé par ces mots clé n’a rien à voir avec du parallélisme et que leur simple emploi ne transforme pas ipso facto un code non parallèle en fusée…

Programme exemple – Squelette

Je vais utiliser un programme console pour cette démonstration.

Il se compose d’une méthode Main() qui exécutera trois tests différents avec une sélection du test à lancer. Le programme attendra un Return clavier avant de s’arrêter car sortir de Main fait sortir du programme tout court… Or nous allons lancer des opérations asynchrones, c’est à dire qui retournent le trait aussitôt (ou presque). De fait le Main continuera à exécuter son propre code alors que les opérations asynchrones ne seront pas terminées. Comme ces dernières seront longues, alors que le code de Main est très court (en quantité et temps d’exécution) nous ne verrions jamais les tests s’exécuter jusqu’au bout !

C’est d’ailleurs une erreur de “débutant” avec Async et Await. On oublie que le trait d’une méthode Async retourne assez vite et on ne le prend pas en compte dans le flux des méthodes appelantes ce qui peut mener à des désastres ou des bogues aléatoires.

Bref ici nous avons un Main classique et court :

void Main()
{
    var menu = SelectTest();
    Run(menu);
    Console.ReadLine();
    Util.ClearResults();
}

 

La variable créée au début sert à recevoir le choix du numéro de test (1, 2 ou 3). Ce choix est géré par SelectTest() une méthode traditionnelle utilisant la console pour la saisie du choix. Je ne vous la présenterai pas car gérer des saisies en mode console n’est vraiment pas mon propos.

La méthode Run() sert à lancer le test choisi, comme elle sera asynchrone elle retournera le trait assez vite. D’où la nécessité d’un ReadLine() qui attendra une frappe clavier validée avant de nettoyer l’écran et de laisser Main sortir, donc le programme s’arrêter. Le nettoyage de la console s’effectue par un Console.Clear(), toutefois ici j’utilise l’indispensable et fantastique LINQPAD pour faire joujou avec C#. Il ne s’agit donc pas de la vraie console puisque cette application gère sa propre fenêtre de sortie. Il faut ainsi utiliser une classe utilitaire (Util) fournie avec LINQPAD pour effacer la fenêtre servant de console.

Vous savez tout sur Main !

La méthode Run(int test)

Elle joue un rôle important mais ce n’est pas elle qui constitue la démonstration. Passons vite sur son code dont l’objet est d’exécuter le test dont le numéro (1, 2 ou 3) est passé en paramètre. Cette méthode est asynchrone et marquée par async. Elle peut donc utiliser await, ce qu’elle fait pour attendre la fin d’exécution de chaque test possible. Les trois tests étant écrits sous la forme de trois méthodes asynchrones retournant un Task.

static async void Run(int testId)
    {
        var start = DateTime.Now;
        Console.WriteLine("[{0}] DEBUT", start);
        string result=string.Empty;
        switch (testId) {
            case 1: result = await DoMyTasksV1("test1"); break;
            case 2: result = await DoMyTasksV2("test2"); break;
            case 3: result = await DoMyTasksV3("test2"); break;
        }
        var end = DateTime.Now;
        Console.WriteLine("[{0}] Sortie: {1}", end, result);
        Console.WriteLine("[{0}] TOUTES LES TACHES SONT TERMINEES - Temps global: {1}", end,end-start);
    }
 

La méthode Run mesure le temps passé dans le test exécuté. Elle indique l’heure de départ et de fin du test ainsi que le différentiel.

Les trois méthodes de test sont appelées DoMyTasksV1, V2 ou V3 comme on le voit dans le switch.

Regardons de plus près chacune de ces méthodes et leurs temps d’exécution…

Les tâches

Pas si vite  ! Avant de regarder le détail des méthodes de test il nous mettre en place les “méthodes longues” qui seront appelées dans les tests. Ici il s’agira de simuler trois tâches longues, un envoi de mail, l’obtention d’un nombre aléatoire et celle d’une chaîne de caractères.

On supposera que chacune de ces opérations est “longue” – elles dépassent les 50 ms, limite utilisée par Microsoft pour la conception de WinRT et le choix de passer ou non une méthode en asynchrone. En réalité nous irons plus loin pour que l’effet soit bien visible. Ainsi chaque tâche (qui ne fait rien, ce sont des fakes) est assortie d’un délai de 2 secondes.

Voici le code très sommaire de ces trois tâches qui sont des méthodes de la classe DummyDelayResource, en français “ressource fictive retardée” (enfin à peu près).

public class DummyDelayResource
    {
        public Task SendEmailAsync()
        {
            Console.WriteLine("[{0}] SendMail (fake)", DateTime.Now);
            return Task.Delay(2000);
        }
 
        public async Task<int> GetRandomNumberAsync()
        {
            Console.WriteLine("[{0}] GetRandomNumber", DateTime.Now);
            await Task.Delay(2000);
            return (new Random()).Next();
        }
 
        public async Task<string> GetSpecialStringAsync(string message)
        {
            Console.WriteLine("[{0}] GetSpecialString", DateTime.Now);
            await Task.Delay(2000);
            return string.IsNullOrEmpty(message) ? "<RIEN>" : message.ToUpper();
        }
    }

 

Nul besoin de s’appesantir sur ce bout de code : trois méthodes de test ne faisant absolument aucun travail autre que d’attendre 2 secondes. Elles ne font pas totalement rien, pour être précis elles écrivent leur nom sur la console, attendent – via un Task.Delay() – et pour les deux dernières elles retournent une valeur (nombre aléatoire ou chaîne de caractères passée en paramètre).

On remarquera que ces méthodes sont marquées async car elles utilisent await. Elles retournent des Task même celle qui ne retourne rien et qui serait codée par un void en mode synchrone.  Await  sert essentiellement à allonger la durée d’exécution de façon fictive en imposant une attente de 2 secondes. Attention, s’agissant de méthodes asynchrones le trait sera retourné dès que await sera rencontré. Await n’est finalement traduit que par un callback qui reviendra continuer le travail. C’est l’écriture fastidieuse de ce callback que await nous évite. Cela rend le code plus clair, plus lisible, plus séquentiel mais il ne l’est pas (séquentiel)! L’oublier c’est s’exposer à quelques soucis.

La première méthode de test retourne une instance de Task tout court, donc l’équivalent d’un void. Les deux suivantes retournent un Task<int> ou Task<string> ce qui seraient équivalents à des méthodes retournant directement int ou string. L’instance de Task (avec ou sans retour de valeur) sert à contrôler le code exécuté (notamment s’il est terminé ou non).

Premier test : résultat conforme mais temps d’exécution trop long

Pour tester le comportement de async/await le mieux est d’écrire maintenant notre première méthode de test qui en fera usage…

Voici ce code :

    static async Task<string> DoMyTasksV1(string message)
    {
        Console.WriteLine("[{0}] Entrée dans la méthode DoMyTasksV1...", DateTime.Now);
        var resource = new DummyDelayResource();
        await resource.SendEmailAsync();
        var number = await resource.GetRandomNumberAsync();
        var upper = await resource.GetSpecialStringAsync(message);
        Console.WriteLine("[{0}] Sortie de la méthode DoMyTasksV1.", DateTime.Now);
        return string.Format("{0}-{1}", number, upper);
    }

Il s’agit bien entendu d’une méthode marquée par async. Elle retourne un Task<string> et elle accepte une paramètre de type string qu’elle utilisera pour fabriquer sa valeur de retour,

Après avoir indiqué son nom avec l’heure, elle créée une instance de la classe de test. Ensuite elle exécute les trois méthodes de test l’une après l’autre avec un await pour garantir la “séquentialité “ du code. Chaque méthode de test est elle-même une méthode retournant un Task et étant marquée async. Il est donc important de faire un await si on veut s’assurer de l’ordre d’exécution de la méthode de test.

Cela semble très raisonnable et parfaitement conforme à l’utilisation qui peut être faite de async/await.

Regardez le GIF ci-dessous qui montre l’exécution de cette méthode :

001synch

En fin de travail nous obtenons la durée globale. Si on fait abstraction des millisecondes passées ici ou là dans notre code, le test complet dure 6 secondes.

Finalement cela est bien naturel : notre méthode de test exécute trois méthodes qui chacune simule une ressource lente avec un délai de 2 secondes. Même un neuneu non initié aux subtilités mathématiques de notre belle profession comprendra aisément que 3 fois 2 font 6 …

Et c’est bien ce que je veux vous montrer : Asynchronisme != Parallélisme !

En utilisant async/await on ne gagne absolument rien en temps d’exécution … notre application gagne en réactivité et en fluidité, son thread principal n’est plus bloqué par les méthodes longues qui sont exécutées dans des threads séparés, mais le simple fait de rendre séquentiel ce qui ne l’est pas forcément, par await, oblige notre application à attendre à chaque étape avant de passer à la suivante. Les temps se cumulent et chaque méthode de test prenant 2 secondes, l’ensemble des tests en prend 6.

C’est tout de même dommage d’avoir déployé autant de ruse et d’intelligence pour en arriver à une application, certes fluide, mais très lente !

L’asynchronisme ne devrait-il pas nous offrir un meilleur résultat ?

Non. Car l’asynchronisme s’occupe de régler un problème particulier qui n’a rien à voir avec le celui réglé par le parallélisme. Async/await permettent d’écrire un code lisible dont l’ordre d’exécution est maitrisable. Certes pour que les méthodes asynchrones s’exécutent il faut bien que des threads soient créés, mais c’est await qui casse le parallélisme, et c’est bien son rôle : attendre !

La véritable question qu’il faut se poser est “quand attendre et quand ne pas attendre”.

Attendre mais pas trop…

En réfléchissant à cette question cruciale nous nous apercevons qu’il est un peu idiot d’attendre la fin de SendMail pour passer aux deux autres tests. En effet, SendMail n’a aucune dépendance avec les méthodes suivantes, elle ne dépend pas des valeurs qu’elles retourneront. Nous pouvons donc retourner le trait immédiatement, nul besoin d’attendre la fin d’exécution de SendMail.

Cela nous amène à une version un peu plus optimisée de notre code :

static async Task<string> DoMyTasksV2(string message)
{
    Console.WriteLine("[{0}] Entrée dans la méthode DoMyTasksV2...", DateTime.Now);
    var resource = new DummyDelayResource();
    var emailTask = resource.SendEmailAsync();
    var number = await resource.GetRandomNumberAsync();
    var upper = await resource.GetSpecialStringAsync(message);
    await emailTask;
    Console.WriteLine("[{0}] Sortie de la méthode DoMyTasksV2.", DateTime.Now);
    return string.Format("{0}-{1}", number, upper);
}

 

Comme on le voit la tâche envoyant le mail n’est pas attendue, il n’y a plus de await devant son appel. De fait le trait passera immédiatement à la suite qui consiste à obtenir le nombre aléatoire puis la chaîne de caractères.

Mais nous avons besoin d’être sûr que l’email est bien parti avant de sortir définitivement de notre méthode de test (disons que c’est une contrainte fonctionnelle de notre application). Avant que la méthode ne se termine nous nous assurons que la tâche d’envoi de mail est bien terminée en utilisant un await sur la Task retournée plus avant…

Sommes-nous pour autant certain que toutes les autres tâches sont terminées aussi ? Oui puisqu’elles sont exécutées avec un await…

Attendre pour SendMail est donc suffisant ici pour nous assurer que toutes les méthodes de test auront bien été exécutées avant que la méthode de test elle-même ne retourne définitivement le trait…

Ce qui donne le GIF suivant (n’oubliez pas que les images de Dot.Blog sont généralement cliquables pour les voir dans leur résolution complète, les GIF de ce billet n’échappant pas à cette règle, ce qui les rendra plus lisible) :

002synch

Nous venons d’un seul coup d’optimiser notre application de 33,33 % !

Par rapport à la version précédente, la méthode de test ne prend plus que 4 secondes pour s’exécuter bien qu’elle nous garantisse toujours de ne se finir qu’une fois toutes les tâches terminées. C’est magique ?

Non, c’est juste que nous avons deux tâches en await soit 2 x 2 … oui, j’en vois un qui lève la main… Oui ! bravo cela fait 4 secondes ! Mais où sont passées les 2 secondes “manquantes” ?

La tâche d’envoi de mail s’est juste exécutée en parallèle des 2 autres. Alors bien entendu il n’y a pas de miracles, s’il n’y avait qu’un seul cœur à notre PC et si le code faisant réellement quelque chose au lieu de créer un délai vide, il n’y aurait aucun gain sauf la fluidité. Mais sur toute machine récente, même un smartphone, globalement de 6 secondes nous passons à 4 secondes d’exécution sans rien sacrifier à nos exigences fonctionnelles ni à la linéarité et la lisibilité de notre code.

Mais c’est ici qu’on s’aperçoit aussi très vite que ces histoire de await ce n’est pas aussi simple qu’on l’envisageait au départ… Surtout si on transpose ce tout petit exemple fictif à une application de plusieurs dizaine de milliers de lignes de code ou plus !

Bon. Réfléchir, progresser, c’est l’un des attraits de notre profession, garder l’œil vif et les neurones en mouvement… Donc on veut pousser les choses plus loin car plus on pense plus on devient exigeant…

N’y aurait-il donc pas un moyen d’améliorer encore notre méthode de test ? Le mieux serait de n’attendre que le plus long des processus. Ici tous prennent 2 secondes mais cela n’est pas représentatif de la réalité ou une stricte égalité de temps de traitement serait même totalement incroyable.

Attendre le minimum c’est encore mieux !

Voici le code de notre troisième méthode de test :

static async Task<string> DoMyTasksV3(string message)
    {
        Console.WriteLine("[{0}] Entrée dans la méthode DoMyTasksV3...", DateTime.Now);
        var resource = new DummyDelayResource();
        var emailTask = resource.SendEmailAsync();
        var numberTask = resource.GetRandomNumberAsync();
        var upperTask = resource.GetSpecialStringAsync(message);
 
        var number = await numberTask;
        var upper = await upperTask;
        await emailTask;
        Console.WriteLine("[{0}] Sortie de la méthode DoMyTasksV3.", DateTime.Now);
        return string.Format("{0}-{1}", number, upper);
    }

 

Puisqu’il n’existe aucune contrainte entre les trois méthodes de la ressource lente simulée il n’y a aucune raison d’attendre l’une plus que l’autre, les trois tâches peuvent être lancées de façon concurrente… C’est exactement ce que fait le code ci-dessus : l’envoi de mail, le calcul aléatoire et le retour d’une chaîne de caractères sont tous lancés à la suite sans aucune pause, sans aucun await.

De fait tous vont s’exécuter en parallèle ce que montre le GIF suivant :

003synch

Temps global, 2 secondes (et quelques miettes).

De 6 secondes pour la première méthode nous avons réussi à réduire le temps d’exécution à 2 secondes, soit un gain de 66.66 % !

De la bonne façon d’attendre

Dans le dernier exemple ci-dessus les trois appels aux méthodes de la ressource sont lancés puis on a choisi d’attendre les tâches par un await.

Finalement ce code ne diffère pas vraiment du premier ! Mais au lieu de faire un await sur l’appel de chaque méthode on le fait sur les variables dans lesquelles nous avons stocké les Task retournés par le lancement des trois méthodes.

C’est peu de choses mais cela change tout. La preuve, de 6 secondes à 2 secondes. Pas besoin d’exagérer ou de balancer des superlatifs savants. Le gain est évident.

Toutefois l’écriture de tous ces await qui se suivent (et dans quel ordre si on veut être précis ?) n’est pas la plus gracieuse. Il serait plus agréable d’écrire un code plus intelligent, plus concis aussi.

C’est bien entendu dans la classe Task que se trouve la réponse avec les méthodes comme WaitAll() ou mieux WhenAll() qui gère correctement le parallélisme que nous recherchions.

Task : la porte des étoiles

Task est une classe très riche, à la fois par son importance dans la gestion du parallélisme, de l’asynchronisme mais aussi par les méthodes et propriétés qu’elle offre.

Etudier Task dépasse largement le cadre de ce billet. Notons toutefois pour allécher le lecteur qui n’aurait pas encore investiguer cet océan de possibilités qu’il est possible d’utiliser des choses comme ContinueWith() qui va permettre de fixer un ordre d’exécution dans une chaîne de tâches asynchrones. On peut aussi utiliser Start() qui lance la tâche mais dans le cadre d’une planification gérée par TaskScheduler. On retrouve aussi Wait() très proche de await mais cette fois-ci en tant que méthode de Task et non comme mot clé de C#. WaitAll(), WaitAny(), aident à gérer des groupes de tâches d’une façon bien plus simple que le multithreading d’il y a quelques années. Task est ainsi le cœur d’une nouvelle façon de programmer à la fois asynchrone et parallèle avec le framework .NET en C#.

Qu’il s’agisse d’une ou deux tâches ou d’une liste de tâches éventuellement observables via TaskObservableExtensions, Task est la pierre angulaire d’une programmation moderne, fluide et efficace, tant du point de vue du code lui-même que de l’application qui sera exécutée.

Je ne peux que vous conseiller vivement de vous intéresser à cette classe et tout ce qui s’y rattache tellement tout cela est essentiel.

Conclusion

Async et await ne sont pas grand chose, juste deux mots clés. Mais ils ouvrent la voie à une programmation plus claire, plus maintenable mais aussi à des applications plus fluides, plus réactives et plus puissantes. La classe Task est le pivot autour duquel gravitent des millions de possibilités. Encore faut-il avoir conscience des effets de bord de async/await et des optimisations importantes du code qu’on peut obtenir en les manipulant correctement.

C’est un sujet très vaste touchant à deux domaines incroyablement riches mais aussi parfois complexes à maitriser. L’asynchronisme et le parallélisme ouvrent au moins autant de questions qu’ils n’apportent de réponse… Un simple billet ne peut avoir la prétention d’avoir couvert ces univers. Tout juste peut-il prétendre avoir aiguisé votre curiosité et avoir attiré votre attention sur le sujet. Et je me satisferai sans problème d’avoir juste et par chance réussi à atteindre cet humble objectif ici…

Stay Tuned !

blog comments powered by Disqus