Ceci est un exemple montrant comment il est possible de réaliser des calculs en parallèle avec la mécanique reposant sur async et await en C#.
L'idée de base de cette mécanique est somme toute simple.
|
Le programme de test est identique dans chaque cas. | Synchrone (sens faible) | Asynchrone |
|---|---|---|
|
|
|
Si une méthode retourne normalement un T lorsqu'elle s'exécute de manière synchrone, elle peut s'exécuter de manière asynchrone si on la qualifie par le mot clé async et si elle retourne plutôt une Task<T>. Il faut par contre que la version asynchrone contienne une mise en attente (avec await); une fonction qui ne rencontre pas ces deux critères aura un comportement synchrone au sens faible du terme : l.'appelant sera suspendu pendant l'exécution de l'appelé. |
|
|
Il est possible d'appeler les versions asynchrones des services de manière semblable à un appel synchrone, à ceci près que l'appel initial provoque le démarrage du traitement, pas nécessairement sa complétion. Pour cette raison, dans la déclinaison asynchrone, un Task<T> est retourné, pas un T. Le Task<T> est en fait une sorte de future, qui connaît le contexte de l'appel en cours et est conscient de la complétion (ou non) du traitement qui lui est associé. Ici, pour réduire au minimum le temps de traitement, la version asynchrone attend (Task.WaitAll) la complétion de l'ensemble des tâches lancées dans Calcul, et retourne la somme des résultats obtenus. Ce faisant, les traitements se font en parallèle. |
|
|
Ce qui est amusant dans cet exemple est que la fonction Calcul encapsule le caractère synchrone ou non du calcul, ce qui explique (comme mentionné plus haut) que le programme de test soit identique dans chaque cas... |
|
|
... mais la vitesse d'exécution n'est pas la même! On remarquera toutefois dans la version asynchrone le coût du lancement des threads associés aux tâches, qui n'est pas négligeable. |
|
|
Notez qu'une fonction qui fait un await doit être marquée async. Ici, dans la version asynchrone, l'appel à Task<int>.WaitAll est le lieu où la dernière mise en attente s'effectue; d'autres options existent, mais un appel « bête » à f sans await dans Calcul aurait provoqué un avertissement du compilateur, indiquant que ceci transforme le calcul réalisé en calcul synchrone...
Il est facile de se mettre dans le pétrin par une mauvaise compréhension des mécanismes tels que ceux induits par async et await. Voici un bref exemple d'une erreur trop facile à faire. Reprenons les versions asynchrones des fonctions F et G vues précédemment.
|
|
On pourrait être tenté d'appeler F(1) et G(1) avec await dans chaque cas, puis de retourner la somme de f_ et g_ (deux Task<int>). Ceci est logique et fonctionne : le résultat calculé sera le bon. Toutefois, retournant désormais une Task<int> dont le résultat n'est peut-être pas encore calculé, il nous faut alors qualifier Calcul du mot clé async. |
|
En conservant pratiquement le même programme de test, à ceci près que Calcul retourne ici une Task<int> nommée localement tâche, et que le résultat effectif est tiré de tâche.Result. Un programme de test possible serait : Cependant, la vitesse d'exécution est alors... décevante. Pourquoi donc? |
|
Le problème se situe dans Calcul, où await F(1) complète temporairement Calcul et retourne un Task<int> « conscient » du contexte d'exécution asynchrone courant. L'exécution de Calcul reprend suite à la complétion de F(1) pour se remettre en attente de G(1). Conséquemment, l'évaluation de tâche.Result dans le programme principal bloquera pour un temps proportionnel à la somme des temps de calcul de F(1) et de G(1), une vitesse d'exécution équivalente à celle d'une séquence d'appels synchrones avec un peu d'effort supplémentaire pour gérer les calculs en arrière-plan. Cette approche n'est donc pas à retenir dans un cas comme celui-ci. |
|
Le code asynchrone peut être avantageux, en particulier lorsque les opérations asynchrones sont des entrées / sorties : pendant que l'entrée / sortie se réalise sur un flux quelque part, le fil d'exécution qui attend le résultat de cette opération peut travailler sur autre chose sans que cela ne pose problème, du moins dans la mesure où les opérations qui dépendent de cette entrée / sortie de procède pas tant que les données qui en résultent ne sont pas disponibles. C'est l'idée du await : on laisse un marqueur dans le code indiquant « j'aurai éventuellement besoin de ce résultat avant de continuer les opérations ».
Quand on parle de programmes CPU-Bound, donc qui dépendent strictement de calculs, les opérations asynchrones sont moins avantageuses. Prenez par exemple le programme suivant, réalisant des tris fusion (avec un tri par insertion très naïf quand un certain seuil est atteint... Ne faites pas¸ça à la maison, c'est de complexité !).
Dans sa version séquentielle, on parle de :
const int N = 100_000;
const int SEUIL_MAX = 16;
for (int i = 2; i <= SEUIL_MAX; i *= 2)
{
Console.WriteLine($"Génération de {N} int pseudoaléatoires");
Random dé = new();
int[] tab = GénérerDonnées(N, () => dé.Next(0, 10_000));
Console.WriteLine($"Tri fusion, seuil séquentiel {i}");
var (res, dt) = Test(() => TriFusion(tab, 0, tab.Length, i));
if (!EstTrié(res))
Console.WriteLine("ERREUR DANS LE TRI");
Console.WriteLine($"Temps écoulé : {dt} ms");
}
static void Permuter<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
// ark
static void TriInsertion<T>(T[] tab, int deb, int fin)
where T : IComparable<T>
{
for (int gauche = deb; gauche < fin - 1; ++gauche)
{
int n = gauche;
for (int droite = gauche + 1; droite < fin; ++droite)
if (tab[n].CompareTo(tab[droite]) > 0)
n = droite;
Permuter(ref tab[gauche], ref tab[n]);
}
}
static bool EstTrié<T>(T[] tab) where T : IComparable<T>
{
for (int i = 1; i < tab.Length; ++i)
if (tab[i - 1].CompareTo(tab[i]) > 0)
return false;
return true;
}
// tri de tab aux indices [deb,fin)
static T[] TriFusion<T>(T[] tab, int deb, int fin, int seuil)
where T : IComparable<T>
{
if (seuil == 1)
TriInsertion(tab, deb, fin); // ne faites pas ça à la maison
else
{
int taille_bloc = fin - deb;
int mid = deb + taille_bloc;
TriFusion(tab, deb, mid, seuil / 2);
TriFusion(tab, mid, fin, seuil / 2);
Fusion(tab, deb, mid, fin);
}
return tab;
}
// Ceci est très naïf, mais un vrai algo de
// fusion «in place» est compliqué
static void Fusion<T>(T[] tab, int deb, int mid, int fin)
where T : IComparable<T>
{
T[] res = new T[fin - deb];
int i = deb, j = mid, k = 0;
while (i != mid && j != fin)
{
if (tab[i].CompareTo(tab[j]) < 0)
res[k++] = tab[i++];
else
res[k++] = tab[j++];
}
if (i != mid)
while (k != res.Length)
res[k++] = tab[i++];
else
while (k != res.Length)
res[k++] = tab[j++];
for (int ii = 0; ii != res.Length; ++ii)
tab[deb + ii] = res[ii];
}
static T[] GénérerDonnées<T>(int n, Func<T> gen)
{
T[] tab = new T[n];
for (int i = 0; i != n; ++i)
tab[i] = gen();
return tab;
}
static (T, long) Test<T>(Func<T> f)
{
System.Diagnostics.Stopwatch sw = new();
sw.Start();
T res = f();
sw.Stop();
return (res, sw.ElapsedMilliseconds);
}... et les temps d'exécution sur mon ordinateur portatif sont :
Génération de 100000 int pseudoaléatoires
Tri fusion, seuil séquentiel 2
Temps écoulé : 2328 ms
Génération de 100000 int pseudoaléatoires
Tri fusion, seuil séquentiel 4
Temps écoulé : 2298 ms
Génération de 100000 int pseudoaléatoires
Tri fusion, seuil séquentiel 8
Temps écoulé : 2287 ms
Génération de 100000 int pseudoaléatoires
Tri fusion, seuil séquentiel 16
Temps écoulé : 2413 msSi on en fait une version asynchrone, on en arrive à :
const int N = 100_000;
const int SEUIL_MAX = 16;
for (int i = 2; i <= SEUIL_MAX; i *= 2)
{
Console.WriteLine($"Génération de {N} int pseudoaléatoires");
Random dé = new();
int[] tab = GénérerDonnées(N, () => dé.Next(0, 10_000));
Console.WriteLine($"Tri fusion, seuil séquentiel {i}");
var (res, dt) = Test
(
() => TriFusion(tab, 0, tab.Length, i).Result
);
if (!EstTrié(res))
Console.WriteLine("ERREUR DANS LE TRI");
Console.WriteLine($"Temps écoulé : {dt} ms");
}
static void Permuter<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
// ark
static async Task TriInsertion<T>(T[] tab, int deb, int fin)
where T : IComparable<T>
{
await Task.Run(() =>
{
for (int gauche = deb; gauche < fin - 1; ++gauche)
{
int n = gauche;
for (int droite = gauche + 1; droite < fin; ++droite)
if (tab[n].CompareTo(tab[droite]) > 0)
n = droite;
Permuter(ref tab[gauche], ref tab[n]);
}
});
}
static bool EstTrié<T>(T[] tab) where T : IComparable<T>
{
for (int i = 1; i < tab.Length; ++i)
if (tab[i - 1].CompareTo(tab[i]) > 0)
return false;
return true;
}
// tri de tab aux indices [deb,fin)
static async Task<T[]> TriFusion<T>(T[] tab, int deb, int fin, int seuil)
where T : IComparable<T>
{
if (seuil == 1)
await TriInsertion(tab, deb, fin); // ne faites pas ça à la maison
else
{
int taille_bloc = fin - deb;
int mid = deb + taille_bloc;
await TriFusion(tab, deb, mid, seuil / 2);
await TriFusion(tab, mid, fin, seuil / 2);
await Fusion(tab, deb, mid, fin);
}
return tab;
}
// Ceci est très naïf, mais un vrai algo de
// fusion «in place» est compliqué
static async Task Fusion<T>(T[] tab, int deb, int mid, int fin)
where T : IComparable<T>
{
await Task.Run(() =>
{
T[] res = new T[fin - deb];
int i = deb, j = mid, k = 0;
while (i != mid && j != fin)
{
if (tab[i].CompareTo(tab[j]) < 0)
res[k++] = tab[i++];
else
res[k++] = tab[j++];
}
if (i != mid)
while (k != res.Length)
res[k++] = tab[i++];
else
while (k != res.Length)
res[k++] = tab[j++];
for (int ii = 0; ii != res.Length; ++ii)
tab[deb + ii] = res[ii];
});
}
static T[] GénérerDonnées<T>(int n, Func<T> gen)
{
T[] tab = new T[n];
for (int i = 0; i != n; ++i)
tab[i] = gen();
return tab;
}
static (T, long) Test<T>(Func<T> f)
{
System.Diagnostics.Stopwatch sw = new();
sw.Start();
T res = f();
sw.Stop();
return (res, sw.ElapsedMilliseconds);
}... et les temps d'exécution sur le même ordinateur sont :
Génération de 100000 int pseudoaléatoires
Tri fusion, seuil séquentiel 2
Temps écoulé : 3029 ms
Génération de 100000 int pseudoaléatoires
Tri fusion, seuil séquentiel 4
Temps écoulé : 2947 ms
Génération de 100000 int pseudoaléatoires
Tri fusion, seuil séquentiel 8
Temps écoulé : 2972 ms
Génération de 100000 int pseudoaléatoires
Tri fusion, seuil séquentiel 16
Temps écoulé : 2933 msComme vous pouvez le constater, pour un problème « pur calcul » comme celui-ci, les coûts de l'ajout de l'asynchronicité ne sont pas vraiment avantageux. Ceci ne signifie pas que les fonctions asynchrones ne sont pas pertinentes (bien au contraire!), seulement qu'il ne s'agit pas d'une panacée.
Pour en savoir plus sur le sujet, quelques suggestions de lecture complémentaire sur async et await :