C# async / await

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

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
using System;
using System.Threading;
using System.Diagnostics;
using System.Threading.Tasks;

var (rés, dt) = Tester(() => Calcul());
Console.WriteLine($"Résultat du calcul : {rés} en {dt} ms.");
using System;
using System.Threading;
using System.Diagnostics;
using System.Threading.Tasks;

var (rés, dt) = Tester(() => Calcul());
Console.WriteLine($"Résultat du calcul : {rés} en {dt} ms.");

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é.

static int F(int n)
{
   Thread.Sleep(2000);
   return n + 1;
}
static int G(int n)
{
   Thread.Sleep(3000);
   return n + 2;
}
static async Task<int> F(int n)
{
   await Task.Delay(2000);
   return n + 1;
}
static async Task<int> G(int n)
{
   await Task.Delay(3000);
   return n + 2;
}

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.

static int Calcul() =>
   F(1) + G(1);
static int Calcul()
{
   var f_ = F(1);
   var g_ = G(1);
   Task<int>.WaitAll(new[] { f_, g_ });
   return f_.Result + g_.Result;
}

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...

static (T rés, long dt) Tester<T>(Func<T> f)
{
   var sw = new Stopwatch();
   sw.Start();
   T rés = f();
   sw.Stop();
   return (rés, sw.ElapsedMilliseconds);
}
static (T rés, long dt) Tester<T>(Func<T> f)
{
   var sw = new Stopwatch();
   sw.Start();
   T rés = f();
   sw.Stop();
   return (rés, sw.ElapsedMilliseconds);
}

... 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.


Résultat du calcul : 5 en 5000 ms.

Résultat du calcul : 5 en 3002 ms.

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...

Piège à éviter

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.

 

static async Task<int> F(int n)
{
   await Task.Delay(2000); // en ms
   return n + 1;
}
static async Task<int> G(int n)
{
   await Task.Delay(3000); // en ms
   return n + 2;
}

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.

static async Task<int> Calcul()
{
   var f_ = await F(1);
   var g_ = await G(1);
   return f_ + g_;
}

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 :

var (rés, dt) = Tester(() =>
{
   var tâche = Calcul();
   int résultat = tâche.Result; // bloquant
});
Console.WriteLine($"Résultat du calcul: {rés} en {dt} ms.");

Cependant, la vitesse d'exécution est alors... décevante. Pourquoi donc?

static (T rés, long dt) Tester<T>(Func<T> f)
{
   var sw = new Stopwatch();
   sw.Start();
   T rés = f();
   sw.Stop();
   return (rés, sw.ElapsedMilliseconds);
}

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.


Résultat du calcul: 5 en 5025 ms.

Quelques liens complémentaires

Pour en savoir plus sur le sujet, quelques suggestions de lecture complémentaire sur async et await :


Valid XHTML 1.0 Transitional

CSS Valide !