Cet article fait partie d’une série d’articles sur async/await
.
Les mot-clés async/await
sont apparus avec C# 5.0 et la version 4.5 du framework .NET. Sous l’apparente simplicité des mot-clés se cache une implémentation complexe et beaucoup de mécanismes implicites qu’il est préférable d’avoir en tête car leurs implications peuvent être significatives en terme de performance.
Fonctionnement général
async
et await
ne sont pas des mot-clés qui permettent la création de thread ou de task à proprement parlé mais ils permettent d’indiquer au compilateur:
- les méthodes pour lesquelles l’exécution sera asynchrone en utilisant
async
, - les endroits dans le code où on va attendre la fin de l’exécution d’une tâche en utilisant
await
.
L’utilisation d’async/await
entraîne l’exécution d’une partie du code de façon asynchrone. Comme on a pu l’indiquer précédemment, effectuer des traitements de façon asynchrone n’est pas tout à fait la même chose que d’exécuter du code en parallèle:
- L’asynchronisme consiste à exécuter du code de façon non bloquante et éventuellement d’attendre le résultat de l’exécution de code. Exécuter de façon asynchrone peut entraîner l’exécution d’une partie du code en parallèle mais ce n’est pas indispensable. Le but recherché avec l’exécution asynchrone est l’aspect non bloquant.
- Exécuter du code en parallèle implique de tirer partie des ressources matérielles pour effectuer plus de traitements pour une période de temps donnée en les exécutant en parallèle (sur plusieurs machines, plusieurs processeurs, plusieurs threads etc…).
Async
Async s’utilise dans la signature d’une fonction pour indiquer qu’elle contient du code qui peut être exécuté de façon asynchrone. Il s’applique sur des méthodes qui renvoient un objet de type Task<TResult>
, Task
, ValueTask
ou ValueTask<TResult>
(depuis C# 7.0). Utiliser async
implique dans la majorité des cas d’utiliser le mot-clé await
(mais ce n’est pas indispensable) pour attendre la fin de l’exécution.
Si le corps d’une méthode avec async
ne contient pas le mot clé await
, il y aura un message d’avertissement du compilateur car cela signifie que le code sera exécuté de façon synchrone.
Par exemple, le code suivant est exécuté de façon synchrone i.e. le thread principal est bloqué jusqu’à la fin du “Sleep” même avec la présence du mot-clé async
:
public string WaitSynchronously()
{
Thread.Sleep(10000);
return "Finished";
}
En revanche le code suivant sera exécuté de façon asynchrone i.e. le thread principal ne sera pas bloqué:
public async Task<string> WaitAsynchronouslyAsync()
{
await Task.Delay(10000);
return "Finished";
}
Tout ce qui se trouve après l’instruction await
dans la fonction async
sera considéré comme une continuation. Ainsi le code ne sera pas bloquant et ce qui se trouve après le await
dans la fonction sera exécuté quand la tâche sera terminée.
Si on utilise une boucle:
Task GetWebPageAsync(string uri)
{
...
}
async void Test()
{
for (int i = 0; i < 5; i++)
{
string html = await GetWebPageAsync("...");
Console.WriteLine(html):
}
}
L’exécution des boucles se fera sans blocages. Ce qui se trouve après la ligne avec le await
sera exécuté comme une continuation quand le GetWebPageAsync()
aura terminé son exécution.
Pour arriver à exécuter ce traitement, le compilateur transforme le code en une machine à états dont le but est de capturer le contexte d’exécution à chaque état c’est-à-dire à chaque fois qu’une instruction est lancée avec un await
. Ainsi les valeurs des variables à ce moment sont celles au moment du lancement de l’instruction await
.
Await
Ce mot-clé permet d’indiquer l’emplacement du code où il faut attendre la fin de l’exécution pour continuer. Ainsi, lorsque l’exécution arrive au mot-clé await
, elle est stoppée pour attendre la fin de l’exécution du code qui suit. Ce code étant exécuté en parallèle de façon à ne pas être bloquant. Ainsi la partie située après la ligne await
est considérée comme une continuation et sera exécutée lorsque la ligne await
aura terminé son exécution.
Par exemple, si on reprend l’exemple précédent:
public async Task<string> WaitAsynchronouslyAsync()
{
// Exécution synchrone
Console.WriteLine("Before await...");
// L'exécution est stoppée jusqu'à la fin du Task.Delay(10000)
await Task.Delay(10000);
// Continuation exécutée quand Task.Delay(10000) est terminé
Console.WriteLine("Before await...");
return "Finished";
}
Si on exécute ce code:
string result = WaitAsynchronouslyAsync().Result;
Console.WriteLine(result);
On obtient:
Before await... ⇐ on attends 10 sec Continuation... ⇐ la continuation est lancée après attente des 10 sec Finished
On modifie légèrement le code pour que l’intérêt de await
soit plus compréhensible:
public static async Task<string> WaitAsynchronouslyAsyncModified()
{
// Exécution synchrone
Console.WriteLine("Before await...");
// L'exécution est stoppée jusqu'à la fin du Task.Delay(10000)
Task<int> delayTask = Task.Run<int>(() =>
{
int taskThreadId = Environment.CurrentManagedThreadId;
Task.Delay(10000);
return taskThreadId;
});
Console.WriteLine("Code exécuté par le thread principal (thread ID: {0})...", Environment.CurrentManagedThreadId);
int taskThreadId = await delayTask;
// Continuation exécutée quand Task.Delay(10000) est terminé
Console.WriteLine("Continuation...");
Console.WriteLine("Thread ID du code exécuté en parallèle: {0}", taskThreadId);
return "Finished";
}
On lance l’exécution:
string result = WaitAsynchronouslyAsyncModified().Result;
Console.WriteLine(result);
On obtient:
Before await... ⇐ L'exécution en parallèle est lancée mais on n'attend pas à ce stade Code exécuté par le thread principal (thread ID: 1)... ⇐ Comme le lancement de la Task est non bloquant, ce code est exécuté ⇐ tout de suite par le thread principal ⇐ On attends 10 sec au niveau du await Continuation... ⇐ la continuation est lancée après attente des 10 sec Thread ID du code exécuté en parallèle: 9 ⇐ On affiche l'ID du thread utilisé pour l'exécution du code en parallèle Finished
Dans cet exemple, on peut voir qu’une partie du code est exécutée en parallèle c’est-à-dire dans un thread différent du thread principal (mais ce n’est pas obligatoire):
Task<int> delayTask = Task.Run<int>(() =>
{ ... });
Le thread principal continue de s’exécuter. Lorsque l’exécution atteint le await
, elle est stoppée pour attendre la fin de l’exécution du code en parallèle.
Exemple WPF
Le grand intérêt d’async/await
n’est pas d’implémenter ce modèle complètement mais de l’utiliser avec les bibliothèques du framework .NET qui fournissent des méthodes et fonctions compatibles. Par exemple, si on considère un exemple WPF consistant à utiliser 3 boutons:
- Un bouton “Start/Stop counter” permettant de lancer et stopper un compteur exécuté par le thread principal.
- Un bouton “Launch sync process” qui va lancer un traitement bloquant pendant 20 sec.
- Un bouton “Launch async process” pour lancer un traitement asynchrone non bloquant pendant 20 sec.
Le code de l’application est disponible sur: github.com/msoft/asyncAwaitExamples.
Le traitement consiste à attendre pendant 20 sec et à renvoyer un nombre aléatoire. L’implémentation synchrone est:
public class UselessProcessSync
{
private readonly int processExecutionTimeMs;
private readonly Random randomGenerator;
public UselessProcessSync(int processExecutionTimeMs)
{
this.processExecutionTimeMs = processExecutionTimeMs;
randomGenerator = new Random();
}
public int ProceedAndWait()
{
Thread.Sleep(processExecutionTimeMs);
return randomGenerator.Next();
}
}
Le traitement asynchrone est le même mais utilise async/await
:
public class UselessProcess
{
private readonly UselessProcessSync uselessProcessSync;
public UselessProcess(int processExecutionTimeMs)
{
uselessProcessSync = new UselessProcessSync(processExecutionTimeMs);
}
public async ValueTask<int> Proceed()
{
return await Task<int>.Run(() => uselessProcessSync.ProceedAndWait());
}
}
Le traitement synchrone est lancé sans async/await
:
private void LaunchSyncProcess(object sender, RoutedEventArgs e)
{
// ...
int result = synchronousBackEndProcess.ProceedAndWait();
this.Dispatcher.Invoke(() =>
{
RandomValue.Content = result;
// ...
});
}
Le traitement asynchrone est lancé avec async/await
:
private async void LunchAsyncProcess(object sender, RoutedEventArgs e)
{
// ...
int result = await asynchronousBackEndProcess.Proceed();
this.Dispatcher.Invoke(() =>
{
RandomValue.Content = result;
// ...
});
}
Le lancement des méthodes LaunchSyncProcess()
et LaunchAsyncProcess()
se fait de la même façon car WPF supporte async/await
et prend en compte cette implémentation avec LaunchAsyncProcess()
:
<Button Name="SyncProcessLaunchButton" Content="Launch sync process" Click="LaunchSyncProcess"/>
<Button Name="AsyncProcessLaunchButton" Content="Launch async process" Click="LunchAsyncProcess"/>
A l’exécution, on peut se rendre compte que le traitement synchrone gèle l’application, le compteur s’arrête pendant le traitement. L’exécution du traitement synchrone étant effectuée par le thread principal, aucun autre traitement n’est possible. A la différence, le traitement asynchrone ne gèle pas l’interface car le thread principal est disponible. Il n’exécute pas le traitement asynchrone.
Modèle awaitable
Les mots clés async/await
permettent de simplifier par des éléments de syntaxe l’implémentation du modèle awaitable. Ce modèle permet d’implémenter une action à exécuter de façon asynchrone et une continuation à appeler quand l’action est exécutée. On peut ensuite récupérer le résultat de l’opération asynchrone. Le modèle awaitable est proche du modèle basé sur des tasks (i.e. Task-based Asynchronous Pattern) vu précédemment.
La condition pour utiliser async/await
est que l’objet qui suit await
possède une fonction GetAwaiter()
:
TaskAwaiter GetAwaiter();
Appeler la fonction GetAwaiter()
permet de retourner un objet de type TaskAwaiter
contenant des propriétés et fonctions permettant d’implémenter le modèle awaitable:
IsCompleted()
pour savoir si l’opération est exécutéeGetResult()
pour récupérer le résultatOnCompleted()
cette méthode sera exécutée en tant que continuation.
L’objet awaitable doit aussi satisfaire la classe System.Runtime.CompilerServices.INotifyCompletion
.
Les objets Task
et ValueTask
(disponibles à partir de C# 7.0) contiennent une fonction GetAwaiter()
. L’utilisation des objets Task
et ValueTask
n’est pas indispensable après await
, il suffit d’utiliser un objet fournissant la méthode GetAwaiter()
.
L’évaluation d’une expression await
peut se résumer avec le diagramme de séquence suivant:
Pour résumer, await
doit être suivi d’un objet awaitable ou d’une action fournissant un objet awaitable. Par exemple, les objets Task
et ValueTask
sont des objets awaitables. Les objets awaitable permettent de fournir un autre objet qui pourra être utilisé pour attendre la fin de l’exécution de la tâche asynchrone avec awaitable.GetAwaiter()
. L’objet awaiter
permet de fournir des propriétés et des méthodes pour vérifier que la tâche asynchrone a terminé son exécution.
Lors de l’instanciation de l’objet awaitable et plus généralement lors de l’exécution de la tâche asynchrone, des informations liées au contexte transitent entre le corps de la méthode async
et l’objet awaitable. Ces informations correspondent au contexte d’exécution. Le contexte d’exécution est un ensemble de données d’état du thread dans lequel le code est exécuté. Ces données d’état peuvent être capturées et restaurées dans un autre thread. L’objet ExecutionContext
permet de gérer les données de contexte du thread courant.
En plus du contexte d’exécution, il existe une autre notion appelée SynchronizationContext
. Cette notion correspond à l’environnement dans lequel le code est exécuté. L’objet SynchronizationContext
va fournir une abstraction permettant d’interagir avec cet environnement. Plus concrètement, on peut récupérer l’instance d’un objet correspondant au contexte de synchronisation (avec SynchronizationContext.Current
). L’instance permet d’interagir avec le contexte en y lançant l’exécution de code de façon synchrone (avec Send()
) ou asynchrone (avec Post()
). Ces méthodes peuvent s’appliquer pour des technologies différentes comme WPF ou en Windows Forms etc…
Avec aync/await
, lors d’un appel à await
, un objet awaiter
est instancié. Il sera utilisé pour attendre la fin de l’exécution grâce à un autre objet awaitable. Lorsque l’exécution dans la méthode async
est suspendue, le contexte d’exécution est capturé. La continuation correspondant au code exécuté lorsque l’exécution asynchrone est terminée, utilisera aussi ce contexte d’exécution. Lors de l’utilisation d’async
, les objets awaitables sont créés par l’intermédiaire d’objets System.Runtime.CompilerServices.AsyncTaskMethodBuilder
qui assurent que le contexte d’exécution transitera de l’appelant vers le delegate.
Exemple d’implémentation d’un objet awaitable
Comme indiqué plus haut, on peut implémenter un objet awaitable personnalisé plutôt que d’utiliser les classes Task
ou ValueTask
. Pour illustrer les différentes étapes du diagramme plus haut, on implémente un objet awaitable qui satisfait INotifyCompletion
et contient des propriétés et fonctions:
IsCompleted()
GetResult()
OnCompleted()
Cette classe ne fait qu’attendre un certain temps avant de renvoyer un entier aléatoire:
internal class CustomAwaitable: INotifyCompletion
{
private Task<int> waitingTask;
private int result;
public CustomAwaitable(TimeSpan waitingTime)
{
result = (new Random()).Next();
if (waitingTime == TimeSpan.Zero)
this.waitingTask = Task.FromResult<int>(result);
else
this.waitingTask = Task.Run(() =>
{
// Attente avant de renvoyer un résultat
Task.Delay(waitingTime).Wait();
return result;
});
}
public bool IsCompleted
{
get
{
bool isCompleted = this.waitingTask.IsCompleted;
Log.LogConsole($"Calling \"IsCompleted\": {isCompleted}");
return isCompleted;
}
}
public void OnCompleted(Action continuation)
{
Log.LogConsole("Calling \"OnCompleted()\"");
continuation();
}
public int GetResult() // Can also be void
{
Log.LogConsole("Calling \"GetResult()\"");
return this.waitingTask.Result;
}
}
L’objet se trouvant après await
doit implémenter une fonction GetAwaiter()
qui doit instancier l’objet awaitable. Une possibilité est de créer une méthode d’extension sur TimeSpan
correspondant au temps d’attente:
internal static class CustomAwaitableBuilder
{
public static CustomAwaitable GetAwaiter(this TimeSpan timeSpan)
{
Log.LogConsole("GetAwaiter()");
return new CustomAwaitable(timeSpan);
}
}
On peut lancer l’exécution en utilisant await suivi d’un objet TimeSpan
qui grâce à la méthode d’extension implémente la fonction GetAwaiter()
:
Log.LogConsole("Starting...");
//TimeSpan timeSpan = TimeSpan.FromSeconds(0); // Temps d'attente nul
TimeSpan timeSpan = TimeSpan.FromSeconds(10);
int result = await timeSpan;
Log.LogConsole($"Ending with result: {result}");
A l’exécution, on obtient:
14:13:36.278: Starting... 14:13:36.318: GetAwaiter() 14:13:36.319: Calling "IsCompleted": False 14:13:36.320: Calling "OnCompleted()" 14:13:36.320: Calling "GetResult()" 14:13:46.329: Ending with result: 706177357
Avec un temps d’attente nul:
14:14:47.755: Starting... 14:14:47.774: GetAwaiter() 14:14:47.775: Calling "IsCompleted": True 14:14:47.775: Calling "GetResult()" 14:14:47.775: Ending with result: 1427412265
Le code de cet exemple est disponible sur: github.com/msoft/asyncAwaitExamples/blob/master/ExampleWithTestLauncher/CustomAwaitable.cs.
- How Async/Await Really Works in C#: https://devblogs.microsoft.com/dotnet/how-async-await-really-works/#enter-tasks
- ExecutionContext vs SynchronizationContext: https://devblogs.microsoft.com/pfxteam/executioncontext-vs-synchronizationcontext/
- TaskAwaiter Structure: https://learn.microsoft.com/fr-fr/dotnet/api/system.runtime.compilerservices.taskawaiter
- How to Write a Custom Awaitable Method: https://www.dajbych.net/how-to-write-a-custom-awaitable-method
- What is awaitable?: https://stackoverflow.com/questions/40853158/what-is-awaitable
- ExecutionContext Class: https://learn.microsoft.com/en-us/dotnet/api/system.threading.executioncontext
- SynchronizationContext Class: https://learn.microsoft.com/en-us/dotnet/api/system.threading.synchronizationcontext
- Demystifying Synchronization Context in .NET: https://medium.com/@josesousa8/demystifying-synchronization-context-in-net-7ddf4473efcb
- Exploring the async/await State Machine – Main Workflow and State Transitions: https://vkontech.com/exploring-the-async-await-state-machine-main-workflow-and-state-transitions/
- Parallel Computing – It’s All About the SynchronizationContext: https://learn.microsoft.com/en-us/archive/msdn-magazine/2011/february/msdn-magazine-parallel-computing-it-s-all-about-the-synchronizationcontext
- How to get a Task that uses SynchronizationContext? And how are SynchronizationContext used anyway?: https://stackoverflow.com/questions/16916253/how-to-get-a-task-that-uses-synchronizationcontext-and-how-are-synchronizationc
- Understanding the SynchronizationContext in .NET with C#: https://www.codeproject.com/Articles/5274751/Understanding-the-SynchronizationContext-in-NET-wi
- How does .NET ExecutionContext actually work?: https://stackoverflow.com/questions/9815575/how-does-net-executioncontext-actually-work
- Hidden Workings of Execution Context in .NET: https://medium.com/net-under-the-hood/hidden-workings-of-execution-context-in-net-43b491726c65
- ExecutionContext in Task code: https://github.com/dotnet/runtime/blob/81977309048600e67fdb44a7d4c99aaad89846d7/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs#L1539C9-L1539C51
- ExecutionContext, SynchronizationContext and CallContext: https://nirmalyabhattacharyya.wordpress.com/2013/08/31/executioncontext-synchronizationcontext-and-callcontext/