Le fonctionnement de async/await diffère considérablement entre une application avec interface graphique (Desktop) et un service backend. Dans une application de bureau, l'instruction await capture le SynchronizationContext actuel, ce qui garantit que la suite de la méthode (la continuation) s'exécutera sur le thread de l'interface utilisateur. En revanche, dans un environnement serveur (comme ASP.NET), il n'y a pas de contexte spécifique ; la continuation est simplement prise en charge par un thread aléatoire du pool de threads. Il est possible de modifier ce comportement par défaut en utilisant .ConfigureAwait(false).
L'exemple suivant illustre un cas classique de deadlock dans une application WinForms. L'appel bloquant .Wait() sur le thread UI combiné à la tentative de reprise sur ce même thread bloque indéfiniment l'application. La solution consiste à appliquer .ConfigureAwait(false) au niveau de la tâche la plus profonde.
private async void OnButtonClick(object sender, EventArgs e)
{
// Provoque un blocage mortel si ConfigureAwait(false) n'est pas utilisé
ProcessDataAsync().Wait();
}
async Task ProcessDataAsync()
{
await FetchRemoteDataAsync();
}
async Task FetchRemoteDataAsync()
{
// ConfigureAwait(false) empêche la capture du contexte UI, évitant le deadlock
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
BtnDisplay.Text = "Terminé";
Console.WriteLine("Traitement achevé");
}
Persistance des références mémoire à travers await
Une question fréquente est de savoir si l'adresse mémoire d'une variable référence reste identique avant et après une instruction await, surtout si la continuation s'exécute sur un thread différent. Les types références étant alloués sur le tas (heap) géré, qui est partagé par l'ensemble du processus, la référence elle-même ne change pas.
Cependant, la méthode utilisée pour lire cette adresse peut influencer le résultat. L'utilisation de GCHandleType.WeakTrackResurrection peut renvoyer des pointeurs différents liés au maniement des handles, tandis que l'épinglage (Pinning) avec GCHandleType.Pinned révèle la véritable adresse physique constante de l'objet sur le tas.
static async Task AnalyzeReferences()
{
var myText = "données";
Console.WriteLine($"{FetchPinnedAddress(myText)} - Avant await - Thread: {Thread.CurrentThread.ManagedThreadId}");
await PerformBackgroundWork();
Console.WriteLine($"{FetchPinnedAddress(myText)} - Après await - Thread: {Thread.CurrentThread.ManagedThreadId}");
}
static async Task PerformBackgroundWork()
{
await Task.Run(() => Console.WriteLine("Exécution en arrière-plan..."));
}
public static string FetchPinnedAddress(object target)
{
var handle = GCHandle.Alloc(target, GCHandleType.Pinned);
IntPtr address = handle.AddrOfPinnedObject();
handle.Free();
return "0x" + address.ToString("X");
}
Interaction avec les contrôles d'interface graphique
Le mot-clé async agit comme un modificateur transformant la méthode en une machine à états. Le code situé après un await constitue une continuation (callback). Dans un gestionnaire d'événements WinForms, préfixer la méthode par async permet au compilateur de générer le code nécessaire pour que cette continuation s'exécute de retour sur le thread principal, autorisant ainsi la manipulation sécurisée des contrôles graphiques.
private async void BtnStart_Click(object sender, EventArgs e)
{
TxtOutput.Text += $"Début sur le thread {Thread.CurrentThread.ManagedThreadId}\r\n";
await Task.Run(() => { Thread.Sleep(2000); });
// Cette section s'exécute sur le thread UI grâce au SynchronizationContext capturé
TxtOutput.Text += "Traitement terminé\r\n";
MessageBox.Show("Succès");
}
Impact sur les applications web
Dans un contexte d'application web, l'utilisation de async/await n'accélère pas le temps de réponse individuel d'une requête. En revanche, elle libère les threads du pool pendant les phases d'attente (ex: I/O réseau ou base de données), ce qui augmente considérablement la capacité de traitement (throughput) globale du serveur.
Exécution bloquante vs Exécution asynchrone
Il est crucial de bien distinguer l'appel bloquant (.Wait() ou .Result) de l'attente asynchrone (await). L'appel bloquant fige le thread appelant, tandis que await rend le contrôle à l'appelant et permet au thread de traiter d'autres travaux.
public static async Task RunSequenceAsync()
{
var job1 = Task.Run(() => {
Thread.Sleep(2000);
Console.WriteLine("Job 1 terminé");
});
await job1;
Console.WriteLine("Suite du Job 1");
var job2 = Task.Run(() => {
Thread.Sleep(2000);
Console.WriteLine("Job 2 terminé");
});
await job2;
Console.WriteLine("Suite du Job 2");
}
public static void Main(string[] args)
{
Console.WriteLine("Lancement");
RunSequenceAsync();
Console.WriteLine("Main terminé");
Console.ReadLine();
}
// Résultat avec await :
// Lancement
// Main terminé
// Job 1 terminé
// Suite du Job 1
// Job 2 terminé
// Suite du Job 2
Anatomie de la machine à états générée par le compilateur
Lorsque le compilateur C# rencontre une méthode async, il génère une structure implémentant IAsyncStateMachine. Chaque await crée une nouvelle transition d'état. Si l'on examine le code décompilé d'une méthode contenant deux instructions await, on observe distinctement les sauts d'états et la gestion des TaskAwaiter.
[CompilerGenerated]
private sealed class AsyncFlowMachine : IAsyncStateMachine
{
public int CurrentState;
public AsyncTaskMethodBuilder Builder;
private TaskAwaiter firstAwaiter;
private TaskAwaiter secondAwaiter;
private void MoveNext()
{
int localState = CurrentState;
try
{
if (localState != 0)
{
if (localState == 1)
{
secondAwaiter = this.firstAwaiter; // Réutilisation du champ
this.firstAwaiter = default(TaskAwaiter);
localState = (CurrentState = -1);
goto Label_Step3;
}
Console.WriteLine("Étape 1");
firstAwaiter = Task.Run(() => Console.WriteLine("Étape 2")).GetAwaiter();
if (!firstAwaiter.IsCompleted)
{
CurrentState = 0;
Builder.AwaitUnsafeOnCompleted(ref firstAwaiter, ref this);
return;
}
}
else
{
firstAwaiter = this.firstAwaiter;
this.firstAwaiter = default(TaskAwaiter);
localState = (CurrentState = -1);
}
firstAwaiter.GetResult();
secondAwaiter = Task.Run(() => Console.WriteLine("Étape 3")).GetAwaiter();
if (!secondAwaiter.IsCompleted)
{
CurrentState = 1;
Builder.AwaitUnsafeOnCompleted(ref secondAwaiter, ref this);
return;
}
Label_Step3:
secondAwaiter.GetResult();
Console.WriteLine("Étape 4");
}
catch (Exception ex)
{
CurrentState = -2;
Builder.SetException(ex);
return;
}
CurrentState = -2;
Builder.SetResult();
}
void IAsyncStateMachine.MoveNext() => this.MoveNext();
[DebuggerHidden]
private void SetStateMachine(IAsyncStateMachine stateMachine) { }
void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) => this.SetStateMachine(stateMachine);
}
Si l'on supprime le second await de cette méthode, la complexité de la machine à états est drastiquement réduite : le compilateur ne génère plus qu'un seul TaskAwaiter et simplifie les transitions, éliminant les sauts conditionnels liés au second état.
[CompilerGenerated]
private sealed class SimplifiedAsyncFlowMachine : IAsyncStateMachine
{
public int CurrentState;
public AsyncTaskMethodBuilder Builder;
private TaskAwaiter singleAwaiter;
private void MoveNext()
{
int localState = CurrentState;
try
{
if (localState != 0)
{
Console.WriteLine("Étape 1");
singleAwaiter = Task.Run(() => Console.WriteLine("Étape 2")).GetAwaiter();
if (!singleAwaiter.IsCompleted)
{
CurrentState = 0;
Builder.AwaitUnsafeOnCompleted(ref singleAwaiter, ref this);
return;
}
}
else
{
singleAwaiter = this.singleAwaiter;
this.singleAwaiter = default(TaskAwaiter);
CurrentState = -1;
}
singleAwaiter.GetResult();
Console.WriteLine("Étape 4");
}
catch (Exception ex)
{
CurrentState = -2;
Builder.SetException(ex);
return;
}
CurrentState = -2;
Builder.SetResult();
}
void IAsyncStateMachine.MoveNext() => this.MoveNext();
[DebuggerHidden]
private void SetStateMachine(IAsyncStateMachine stateMachine) { }
void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) => this.SetStateMachine(stateMachine);
}