Apprivoiser les fonctions asynchrones

Il est possible en C# d'exécuter certaines fonctions de manière asynchrone, au sens où la fonction appelée retournera avant d'avoir complété son traitement, et retournera un objet qui permettra de valider la complétion du traitement (ou, dans le cas de fonctions non-void, de consommer la valeur de retour) à un moment ultérieur.

Exemple simple

Pour un exemple simple, voici une fonction qui appelle une fonction asynchrone CalculerTruc(), puis lit une touche, affiche la touche lue, et affiche enfin le résultat du calcul fait par CalculerTruc(). Essayez le programme; si vous entrez une touche dans les deux premières secondes, la touche entrée s'affichera tout de suite, puis il y aura un délai avant que la valeur retournée par CalculerTruc() ne s'affiche :

using System;
using System.Threading.Tasks;
public class Program
{
   static async Task<char> CalculerTruc()
   {
      await Task.Delay(2000);
      return 'X';
   }
   public static void Main()
   {
      var c = CalculerTruc();
      Console.WriteLine(Console.ReadKey(true).KeyChar);
      Console.WriteLine(c.Result);
   }
}

Qu'est-ce qui explique ce comportement? Voici :

Avec des fonctions asynchrones, on parvient donc, en quelque sorte, à faire de la multiprogrammation sans avoir besoin de plusieurs fils d'exécution.

Calcul parallèle asynchrone

Référez-vous à ../../../Sujets/Divers--cdiese/async_await.html pour ceci. Vous remarquerez que le calcul, s'il est fait de manière asynchrone, permet au code client de gagner beaucoup de temps en ne bloquant pas pendant que l'un ou l'autre des calculs se complète. Le moment où il y a lieu de bloquer est celui où le résultat du calcul devient nécessaire; moins un programme bloquera, règle générale, et plus il sera efficace.

Transformer une tâche synchrone en tâche asynchrone

Que faire si nous réalisons un calcul synchrone que nous voulons transformer en calcul asynchrone, pour quelque raison que ce soit? Une solution simple est d'utiliser Task.Run(), qui accepte une tâche synchrone, l'exécute de manière asynchrone et retourne le Task<T> correspondant. Pour un exemple archi-simpliste :

Version synchrone Version asynchrone
static int Somme(int x, int y) => x + y;
static void Afficher()
{
   Console.Write(Somme(2,3));
}
static async Task<int> SommeAsync(int x, int y) => await Task.Run(() => x + y);
static void Afficher()
{
   var res = SommeAsync(2,3);
   Console.Write(res.Result);
}

Évidemment, une somme d'entiers n'est probablement pas un calcul qui mérite un tel traitement, mais cela permet au moins d'illustrer la syntaxe.

Réaliser une tâche bloquante de manière asynchrone

Si nous souhaitons faire une tâche comme celle décrite dans ../../../Sujets/Client-Serveur/TiPointsCDiese.html, soit afficher un petit point à l'écran jusqu'à ce que l'usager appuie sur une touche, l'approche asynchrone devient un peu délicate.

Un avantage de cette approche est qu'elle conclura l'exécution dès qu'une touche sera pressée, n'ayant pas à attendre l'expiration d'un délai d'une seconde pour tester une condition d'arrêt.

En effet, le réflexe serait d'appeler de manière asynchrone deux fonctions, appelons-les LireTouche() et Attendre(), où LireTouche lirait une touche de manière bloquante, et où Attendre se suspendrait pour une seconde, puis de faire un Task.WhenAny() sur cette paire de fonctions pour savoir laquelle s'est complétée d'abord : si nous avons lu une touche, alors le programme se termine, alors que si l'attente d'une seconde a expiré en premier lieu, nous affichons un '.' à l'écran.

Naïvement, nous obtiendrions :

// ...
static async Task<ConsoleKey> Attendre()
{
   await Task.Delay(1000);
   return default; // voir plus bas
}
static async Task<ConsoleKey> LireTouche()
{
   return await Task.Run(() => Console.ReadKey(true).Key);
}
static void Main()
{
   bool fini = false;
   do
   {
      var tâches = new List<Task<ConsoleKey>>() { Attendre(), LireTouche() };
      var résultat = Task.WhenAny(tâches);
      fini = résultat.Result.Result != default;
      if (!fini)
      {
         Console.Write('.');
      }
   }
   while (!fini);
}
// ...

... ce qui n'est pas la plus jolie structure de boucle (deux tests sur fini), mais soit. Cela dit, vous pouvez l'essayer, ça ne fonctionne pas tout à fait.

Notez le mot clé default qui est retourné de Attendre() et testé dans le programme principal. En C#, il est possible de représenter la valeur par défaut d'un type (0 pour un entier, 0.0 pour un double, null pour une référence, etc.) par le mot clé default, ce qui simplifie un peu l'écriture du code générique.

Ici, Attendre() retourne un ConsoleKey pour que l'on puisse l'utiliser dans Task.WhenAny() en passant des méthodes de signature homogène, et nous utilisons default (qui ne peut pas être entrée au clavier) comme valeur de retour de Attendre pour la distinguer de ce que retournera (éventuellement) LireTouche.

Qu'advient-il des tâches asynchrones non terminées?

Le problème ici est que si une touche n'a pas été lue, LireTouche s'exécute encore, et que si nous exécutons un autre LireTouche comme c'est le cas avec ce programme un peu trop naïf, nous aurons deux fonctions qui chercheront à lire du clavier, ce qui bloquera la bonne exécution du programme. En effet, avec une fonction comme WhenAny, les tâches asynchrones autres que la première s'exécuteront jusqu'à la fin et le résultat de leur traitement sera simplement « oublié ». Exprimé simplement, ça ne fonctionnera pas.

Il nous faudra donc ajuster un peu le code pour être en mesure d'annuler la fonction LireTouche si l'exécution de celle-ci ne s'est pas encore complétée.

Gérer l'annulation des tâches asynchrones

La bibliothèque de tâches asynchrones de C# comprend quelques outils pour faciliter la gestion collaborative de l'annulation de tâches asynchrones. Parmi ceux-ci, on trouve le type CancellationTokenSource et le type CancellationToken.

L'idée va comme suit :

Voyons voir comment nous pourrons appliquer ce mécanisme à notre programme pour le rendre un peu moins naïf.

Une solution plus complète

Une solution plus complète pourrait être la suivante :

La méthode LireTouche fait une tâche à la base synchrone, mais que l'on transforme en tâche asynchrone en l'encapsulant dans une λ et en passant cette dernière à Task.Run(). Cette technique permet d'insérer un await dans l'implémentation et de faire de LireTouche une méthode asynchrone.

Notez la gestion de la variable touche :

  • Elle est initialisée à default
  • Elle est capturée par la λ, qui ne la modifiera que si une touche est effectivement lue
  • La λ s'exécute de maniere asynchrone, et ne bloque pas sur la lecture de la touche. Ceci consomme du temps d'exécution sur le processeur (on pourrait ajouter une suspension volontaire dans la fonction, si souhaité, pour compenser), ce qui lui permet de tester le jeton d'annulation et d'interrompre son traitement avec une faible latence
// ...
   static async Task<ConsoleKey> LireTouche(CancellationToken jeton)
   {
      ConsoleKey touche = default;
      await Task.Run(() =>
      {
         while (!Console.KeyAvailable)
            if (jeton.IsCancellationRequested)
               return;
         touche = Console.ReadKey(true).Key;
      });
      return touche;
   }

La méthode Attendre est asynchrone, et retourne un ConsoleKey de valeur default une fois l'attente complétée. Notez que Task.Delay est une suspension asynchrone, et tient compte du jeton d'annulation, ce qui permet d'interrompre cette suspension si une demande d'annulation survient.

   static async Task<ConsoleKey> Attendre(CancellationToken jeton)
   {
      await Task.Delay(1000, jeton);
      return default;
   }

Le coeur de cette version est la méthode ChoisirParmiActions. Cette méthode prend un prédicat sur T nommé terminer, qui permet de vérifier si le T retourné par la première fonction ayant conclu son exécution devrait mener à la fin du traitement, et un nombre arbitrairement grand (j'utilise params ici) de fonctions prenant un CancellationToken en paramètre et retournant un T (c'est la signature à laquelle se conforment LireTouche et Attendre).

Cette version est semi-générale, mais suffit pour nos besoins; une version plus générale suit un peu plus bas. Elle crée un jeton d'annulation, puis appelle les diverses fonctions asynchrones en leur passant ce jeton pour récupérer les Task<T> retournées.

Quand l'une des fonctions asynchrones se termine, son résultat est passé au prédicat pour voir s'il est temps de terminer le programme, puis annule les autres tâches. Notez le try... catch ici, pour éviter que le programme ne plante si nous interrompons une tâche qui, comme Attendre, lèverait OperationCanceledException.

   static bool ChoisirParmiActions<T>(Func<T, bool> terminer,
                                      params Func<CancellationToken, Task<T>> [] fcts)
   {
      var ctSrc = new CancellationTokenSource();
      var jeton = ctSrc.Token;
      var résultats = new List<Task<T>>();
      foreach (var f in fcts)
         résultats.Add(f(jeton));
      bool fini = false;
      try
      {
         var laquelle = Task.WhenAny(résultats).Result;
         fini = terminer(laquelle.Result);
         ctSrc.Cancel();
      }
      catch (OperationCanceledException)
      {
      }
      return fini;
   }

Sur cette base, le programme devient tout simple : une boucle qui lance deux tâches asynchrones à chaque itération d'une boucle, et affiche '.' si la tâche qui s'est complétée en premier n'est pas LireTouche.

   public static void Main()
   {
      while (!ChoisirParmiActions(k => k != default,
                                  Attendre, LireTouche))
      {
         Console.Write(".");
      }
   }
// ...

Une version plus générale de ChoisirParmiActions accepterait en paramètre non pas un prédicat sur T mais une fonction acceptant un T en paramètre et retournant un U. Ceci permettrait à la fonction de faire autre chose que de simplement retourner true ou false, tout en acceptant les prédicats (cas où U est bool). On pourrait donc l'utiliser telle quelle dans le code ci-dessus, sans changer une ligne du code client.

Pour le code :

static U ChoisirParmiActions<T,U>(Func<T, U> ensuite, params Func<CancellationToken, Task<T>> [] fcts)
{
   var ctSrc = new CancellationTokenSource();
   var jeton = ctSrc.Token;
   var résultats = new List<Task<T>>();
   foreach (var f in fcts)
      résultats.Add(f(jeton));
   U conclusion = default;
   try
   {
      var laquelle = Task.WhenAny(résultats).Result;
      conclusion = ensuite(laquelle.Result);
      ctSrc.Cancel();
   }
   catch (OperationCanceledException)
   {
   }
   return conclusion;
}

Quelques règles

Une fonction qui fait un await doit être qualifiée async et retourner une sorte de Task. Cela signifie qu'il est possible de l'appeler sans bloquer. Quand la valeur retournée sera consommée (au point où await se trouve, conceptuellement), elle sera « développée » implicitement, transformant le Task<T> en T.

Une fonction peut consommer la valeur de retour d'une fonction async de manière synchrone, en l'appelant sans faire await. Ceci permet à la fonction appelante d'être synchrone si elle le souhaite. Pour extraire le résultat de la Task<T> retournée, la fonction bloquera sur la propriété Result de cet objet.

Petite course entre fonctions asynchrones

Soit les deux fonctions asynchrones FA et FB ci-dessous. Nous souhaitons que les deux s'exécutent, et que le programme ne consomme le résultat de la première ayant terminé (la gagnante de la course). Une implémentation possible serait :

// ...
   static async Task<string> FA(Random r)
   {
      await Task.Delay(r.Next(100, 150));
      return "A";
   }
   static async Task<string> FB(Random r)
   {
      await Task.Delay(r.Next(100, 150));
      return "B";
   }
   public static void Main()
   {
      var r = new Random();
      var tâches = new Task<string>[] { FA(r), FB(r) };
      var rés = Task.WaitAny(tâches);
      Console.WriteLine($"La gagnante est {tâches[rés].Result}");
   }
}

... où parfois A gagnera, et parfois B gagnera.

Visiblement, il serait possible de simplifier ce programme, les tâches asynchrones étant toutes identiques au message retourné près :

// ...
   static async Task<string> Coureur(Random r, string s)
   {
      await Task.Delay(r.Next(100, 150));
      return s;
   }
   public static void Main()
   {
      var r = new Random();
      var tâches = new Task<string>[] { Coureur(r, "A"), Coureur(r, "B") };
      var rés = Task.WaitAny(tâches);
      Console.WriteLine($"La gagnante est {tâches[rés].Result}");
   }
// ...

... ce qui donne envie d'exprimer le même code avec des expressions λ, qui peuvent d'ailleurs être asynchrones elles aussi :

// ...
   public static void Main()
   {
      const int N = 10;
      var r = new Random();
      var tâches = new Func<Task<string>>[N];
      for (int i = 0; i != tâches.Length; ++i)
      {
         string s = new string((char)('A' + i), 1);
         tâches[i] = async () =>
         {
            await Task.Delay(r.Next(100, 150));
            return s;
         };
      }
      var résultats = new Task<string>[N];
      for (int i = 0; i != tâches.Length; ++i)
         résultats[i] = tâches[i]();

      var laquelle = Task.WaitAny(résultats);
      Console.WriteLine($"La gagnante est {résultats[laquelle].Result}");
   }
// ...

Une λ asynchrone retourne une Task<T> (ici, une Task<string>). Notre exemple crée toutes les fonctions asynchrones, puis les exécute, puis bloque jusqu'à ce que l'une de ces fonctions se termine et affiche la valeur retournée par cette dernière.

Notez que j'ai utilisé WaitAny(), qui retourne l'indice de la tâche ayant terminé son exécution en premier. D'autres services existent :

La méthode Task.WhenAny(), qui prend une séquence de Task<T> et retourne une Task<Task<T>> menant vers la tâche ayant complété en premier. Notez qu'il faudra y aller de Result.Result pour déballer les deux niveaux de tâches résultant de cette fonction. Cette fonction est surtout utile quand la fonction qui attend la fin de la première tâche asynchrone est elle-même asynchrone.

// ...
      var laquelle = Task.WhenAny(résultats);
      Console.WriteLine($"La gagnante est {laquelle.Result.Result}");
// ...

La méthode Task.WaitAll(), qui prend une séquence de Task<T> et bloque jusqu'à ce qu'elles aient toutes complété leur exécution. Cette fonction est void.

// ...
      Task.WaitAll(résultats);
      Console.WriteLine("La course est terminée");
// ...

La méthode Task.WhenAll(), qui prend une séquence de Task<T> et retourne une Task sur laquelle le code client peut attendre à sa convenance, ce qui permet à la fois de poursuivre les opérations et de bloquer, au moment opportun, en attente de la conclusion des travaux.

// ...
      var jeton = Task.WhenAll(résultats);
      // ... faire des trucs
      jeton.Wait(); // supposant qu'on doive maintenant
                     // attendre la fin des travaux
      Console.WriteLine("La course est terminée");
// ...

Quelques exercices

Voici quelques exercices que je vous recommande de faire.

EX00 – Le programme suivant lit une URL de manière synchrone et dépose le résultat dans un fichier :

using System.Threading.Tasks;
using System.IO;
using System.Net.Http;
public class Program
{
   public static void Main()
   {
      using (var client = new HttpClient())
      {
         var s = client.GetStringAsync("http://h-deb.clg.qc.ca").Result; // URL prise au hasard
         using (var sw = new StreamWriter("h-deb-racine.txt"))
            sw.Write(s);
      }
   }
}

Transformez ce programme pour qu'il consomme une série d'URL de manière asynchrone et place le texte consommé de chacune dans un fichier distinct. Pour ce faire, écrivez une fonction asynchrone LireUrl() qui lira une URL et retournera un Task<string>, puis appelez cette fonction pour chaque URL à consommer. Prenez soin de vous assurer que toutes les URL aient été lues avant que le processus ne termine son exécution!

EX01 – Examinez le pipeline proposé à EX02 de exercice-apprivoiser-multiprog.html. Écrivez un programme équivalent, mais où plutôt que d'avoir un fil d'exécution par tâche, vous avez une fonction asychrone par tâche.

EX02 – Examinez le pipeline proposé à EX03 de exercice-apprivoiser-multiprog.html. Écrivez un programme équivalent, mais où plutôt que d'avoir un fil d'exécution par tâche, vous avez une fonction asychrone par tâche.


Valid XHTML 1.0 Transitional

CSS Valide !