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.
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;
var c = CalculerTruc();
Console.WriteLine(Console.ReadKey(true).KeyChar);
Console.WriteLine(c.Result);
static async Task<char> CalculerTruc()
{
await Task.Delay(2000);
return 'X';
}
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.
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.
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 |
---|---|
|
|
É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.
On peut aussi enchaîner les calculs asynchrones et n'attendre qu'après le résultat de l'ensemble de ceux-ci. Par exemple :
Console.WriteLine(Calculer(2, 3).Result);
static async Task<int> SommeAsync(int x, int y) =τ
await Task.Run(() => x + y);
static async Task<int> Calculer(int x, int y)
{
var res = await SommeAsync(2, 3);
return await Task.Run(() => res * 10);
}
Ici, Calculer exécutera SommeAsync de manière asynchrone, puis utilisera (grâce à await) la forme « développée » de res (un Task<int> traité comme s'il s'agissait d'un int) dans ses propres calculs. Le code de Calculer semble opérer sur des int (c'est simple!), et le code client (l'affichage, qui utilise la propriété Result) est le seul point où un blocage en attente d'un résultat surviendra.
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 :
// ...
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);
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);
}
// ...
... 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.
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.
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 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 :
|
|
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. |
|
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. |
|
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. |
|
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;
}
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.
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 :
// ...
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}");
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";
}
... 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 :
// ...
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}");
static async Task<string> Coureur(Random r, string s)
{
await Task.Delay(r.Next(100, 150));
return s;
}
// ...
... ce qui donne envie d'exprimer le même code avec des expressions λ, qui peuvent d'ailleurs être asynchrones elles aussi :
// ...
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. |
|
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. |
|
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. |
|
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;
using (var client = new HttpClient())
{
var s = client.GetStringAsync("https://h-deb.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.