La programmation asynchrone avec async/await est au cœur du développement .NET moderne. Elle permet de libérer des threads pendant les opérations d’entrée/sortie (réseau, fichiers, base de données) au lieu de les bloquer. Mais derrière cette syntaxe simple se cachent une machine à états, un SynchronizationContext, et de nombreux pièges. Cet article détaille le fonctionnement réel d’async/await, de la théorie aux bonnes pratiques.
Pourquoi l’asynchrone ?
Le problème : le thread bloqué
Dans un serveur web, chaque requête HTTP est traitée par un thread du pool. Si ce thread effectue un appel réseau synchrone, il reste bloqué en attente de la réponse :
// ❌ Synchrone : le thread est bloqué pendant toute la durée de l'appel HTTP
public IActionResult GetData()
{
var client = new HttpClient();
var response = client.Send(new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/data"));
var content = new StreamReader(response.Content.ReadAsStream()).ReadToEnd();
return Ok(content);
}
Avec 100 requêtes simultanées et un pool de 100 threads, le serveur est saturé : plus aucun thread disponible pour traiter de nouvelles requêtes, alors que chaque thread ne fait que attendre.
La solution : libérer le thread pendant l’attente
// ✅ Asynchrone : le thread est libéré pendant l'appel HTTP
public async Task<IActionResult> GetData()
{
var client = new HttpClient();
var response = await client.GetAsync("https://api.example.com/data");
var content = await response.Content.ReadAsStringAsync();
return Ok(content);
}
Avec await, le thread est rendu au pool pendant l’attente réseau. Il peut traiter d’autres requêtes. Quand la réponse arrive, un thread du pool reprend l’exécution là où elle s’était arrêtée.
Ce que l’asynchrone n’est PAS
- Ce n’est PAS du parallélisme :
async/awaitne crée pas de thread supplémentaire. Il libère le thread courant. - Ce n’est PAS plus rapide pour une seule opération : l’appel HTTP prend toujours le même temps. Le gain est en scalabilité (plus de requêtes simultanées avec moins de threads).
- Ce n’est PAS utile pour du calcul CPU : si le travail est du calcul pur (boucle, algorithme),
awaitn’apporte rien — le thread est occupé à calculer, pas à attendre.
Task et Task<T> : la promesse d’un résultat
Task : une opération sans résultat
public async Task EnvoyerEmailAsync(string destinataire, string message)
{
await smtpClient.SendMailAsync(destinataire, message);
// Pas de valeur de retour
}
Task<T> : une opération avec résultat
public async Task<Client> GetClientAsync(int id)
{
var client = await dbContext.Clients.FindAsync(id);
return client; // retourne un Client, encapsulé dans Task<Client>
}
Un Task représente une opération en cours (ou déjà terminée). C’est l’équivalent d’une “promesse” (Promise en JavaScript). On peut :
- Attendre son résultat avec
await. - Vérifier son état :
IsCompleted,IsFaulted,IsCanceled. - Accéder au résultat (si terminé) via
.Result— mais attention aux deadlocks (voir plus bas).
ValueTask<T> : l’alternative légère
ValueTask<T> évite l’allocation d’un objet Task sur le tas quand le résultat est souvent déjà disponible (cache, valeur par défaut) :
// Si le cache contient la valeur, pas besoin d'allouer un Task
public ValueTask<Client> GetClientAsync(int id)
{
if (_cache.TryGetValue(id, out var client))
return new ValueTask<Client>(client); // pas d'allocation
return new ValueTask<Client>(GetClientFromDbAsync(id)); // allocation Task si nécessaire
}
private async Task<Client> GetClientFromDbAsync(int id)
{
return await dbContext.Clients.FindAsync(id);
}
Règle : utiliser ValueTask<T> quand la méthode retourne souvent un résultat synchrone (cache hit, valeur par défaut). Sinon, Task<T> suffit.
Ce que le compilateur génère
La machine à états
Quand le compilateur rencontre async/await, il transforme la méthode en une machine à états (IAsyncStateMachine). Chaque await correspond à un point de suspension.
// Ce qu'on écrit
public async Task<string> GetDataAsync()
{
var response = await httpClient.GetAsync("https://api.example.com");
var content = await response.Content.ReadAsStringAsync();
return content.ToUpper();
}
Le compilateur génère (version très simplifiée) :
// Ce que le compilateur produit
private struct GetDataAsyncStateMachine : IAsyncStateMachine
{
public int _state; // -1 = début, 0 = après 1er await, 1 = après 2ème await
public AsyncTaskMethodBuilder<string> _builder;
public HttpClient httpClient;
// Variables locales "promues" en champs
private HttpResponseMessage _response;
private string _content;
// Awaiters pour chaque await
private TaskAwaiter<HttpResponseMessage> _awaiter1;
private TaskAwaiter<string> _awaiter2;
public void MoveNext()
{
try
{
switch (_state)
{
case -1: // début de la méthode
_awaiter1 = httpClient.GetAsync("https://api.example.com").GetAwaiter();
if (!_awaiter1.IsCompleted)
{
_state = 0;
_builder.AwaitUnsafeOnCompleted(ref _awaiter1, ref this);
return; // le thread est libéré ICI
}
goto case 0;
case 0: // reprise après le 1er await
_response = _awaiter1.GetResult();
_awaiter2 = _response.Content.ReadAsStringAsync().GetAwaiter();
if (!_awaiter2.IsCompleted)
{
_state = 1;
_builder.AwaitUnsafeOnCompleted(ref _awaiter2, ref this);
return; // le thread est libéré ICI
}
goto case 1;
case 1: // reprise après le 2ème await
_content = _awaiter2.GetResult();
_builder.SetResult(_content.ToUpper());
return;
}
}
catch (Exception ex)
{
_builder.SetException(ex);
}
}
}
Points clés
- Les variables locales (
response,content) deviennent des champs de la struct, car elles doivent survivre entre les reprises. IsCompletedest vérifié d’abord : si leTaskest déjà terminé, on continue sans suspendre (optimisation synchrone).returndans leMoveNext()libère le thread. Quand l’opération asynchrone se termine,MoveNext()est rappelé avec le bon_state.- Les exceptions sont capturées et stockées dans le
TaskviaSetException.
Le SynchronizationContext
Le problème de la reprise
Quand un await se termine, sur quel thread reprend l’exécution ? C’est le rôle du SynchronizationContext.
| Environnement | SynchronizationContext |
Reprise sur |
|---|---|---|
| ASP.NET Core | Aucun (null) |
N’importe quel thread du pool |
| WPF / WinForms | UI context | Le thread UI |
| Application console | Aucun (null) |
N’importe quel thread du pool |
| ASP.NET classique (.NET Framework) | AspNetSynchronizationContext |
Le même thread de requête |
Pourquoi c’est important
En WPF/WinForms, après un await, l’exécution reprend automatiquement sur le thread UI (pour pouvoir mettre à jour les contrôles) :
// WPF : après await, on est de retour sur le thread UI
private async void Button_Click(object sender, RoutedEventArgs e)
{
var data = await httpClient.GetStringAsync("https://api.example.com");
TextBlock.Text = data; // ✅ OK : on est sur le thread UI
}
En ASP.NET Core, il n’y a pas de SynchronizationContext, donc la reprise se fait sur n’importe quel thread du pool — ce qui est plus performant.
ConfigureAwait(false)
ConfigureAwait(false) dit : “je n’ai pas besoin de revenir sur le contexte d’origine”.
// Dans du code de bibliothèque (pas de UI, pas de contexte HTTP)
public async Task<string> GetDataAsync()
{
var response = await httpClient.GetAsync(url).ConfigureAwait(false);
// La reprise se fait sur n'importe quel thread du pool
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
return content;
}
Règles :
- Code applicatif ASP.NET Core :
ConfigureAwait(false)est inutile (pas deSynchronizationContext), mais ne fait pas de mal. - Bibliothèques : toujours mettre
ConfigureAwait(false)pour éviter les deadlocks quand la bibliothèque est utilisée dans un contexte UI ou ASP.NET classique. - Code UI (WPF/WinForms) : ne pas mettre
ConfigureAwait(false)si on accède à des contrôles UI après leawait.
Pièges courants
1. async void — à éviter absolument
// ❌ async void : les exceptions ne sont pas capturables
public async void EnvoyerNotification()
{
await httpClient.PostAsync(url, content);
// Si une exception est levée, elle crashe l'application
// car personne ne peut await cette méthode (pas de Task)
}
// ✅ async Task : les exceptions sont propagées via le Task
public async Task EnvoyerNotificationAsync()
{
await httpClient.PostAsync(url, content);
}
Exception unique : les event handlers UI (WPF, WinForms) imposent async void car la signature de l’événement est void.
// Seul cas acceptable pour async void
private async void Button_Click(object sender, RoutedEventArgs e)
{
try
{
await DoWorkAsync();
}
catch (Exception ex)
{
// Toujours un try-catch dans un async void !
MessageBox.Show(ex.Message);
}
}
2. Deadlock avec .Result ou .Wait()
// ❌ DEADLOCK en WPF / ASP.NET classique
public void GetData()
{
var data = GetDataAsync().Result; // bloque le thread UI
// GetDataAsync attend de revenir sur le thread UI (SynchronizationContext)
// Mais le thread UI est bloqué par .Result → DEADLOCK
}
public async Task<string> GetDataAsync()
{
var result = await httpClient.GetStringAsync(url);
// await veut revenir sur le thread UI, mais il est bloqué
return result;
}
Le scénario du deadlock :
.Resultbloque le thread courant (UI) en attendant leTask.- Le
awaitdansGetDataAsyncveut reprendre sur le thread UI (viaSynchronizationContext). - Le thread UI est bloqué → deadlock.
Solutions :
// ✅ Solution 1 : utiliser await partout (async all the way)
public async Task<string> GetData()
{
return await GetDataAsync();
}
// ✅ Solution 2 : ConfigureAwait(false) dans la méthode appelée
public async Task<string> GetDataAsync()
{
var result = await httpClient.GetStringAsync(url).ConfigureAwait(false);
return result;
}
3. async inutile (élision d’await)
Quand une méthode ne fait que transmettre un Task sans rien faire après, async/await est superflu :
// ❌ async/await inutile : crée une machine à états pour rien
public async Task<Client> GetClientAsync(int id)
{
return await repository.GetByIdAsync(id);
}
// ✅ Retourner directement le Task
public Task<Client> GetClientAsync(int id)
{
return repository.GetByIdAsync(id);
}
Documentation
Attention concernant l’élision async/await
Ce commentaire avertit que l’élision de async (c’est-à-dire la suppression du mot-clé async d’une méthode qui retourne directement une Task) modifie le comportement du code de deux façons importantes :
1. Sémantique des exceptions
- Avec
async: les exceptions sont capturées et enveloppées dans laTaskretournée - Sans
async: les exceptions sont levées directement lors de l’appel
2. Stack trace
- Avec
async: la pile d’appels peut être modifiée/raccourcie par le compilateur - Sans
async: la pile d’appels originale est préservée
Recommandation
Conserver le mot-clé async si la méthode contient de la validation ou de la logique avant le return, car cela garantit que :
- Toutes les exceptions sont gérées de manière cohérente
- La stack trace reste complète et utile pour le déboggage
Attention : l’élision change la sémantique des exceptions et du stack trace. Si la méthode contient de la validation avant le
return, garderasync:
// ✅ Garder async car il y a de la logique avant le await
public async Task<Client> GetClientAsync(int id)
{
if (id <= 0) throw new ArgumentException("Id invalide");
return await repository.GetByIdAsync(id);
// Sans async, l'exception serait levée à l'appel, pas encapsulée dans le Task
}
4. Oublier d’await un Task
// ❌ Le Task est créé mais jamais attendu : fire-and-forget silencieux
public async Task TraiterCommandeAsync(Commande cmd)
{
EnvoyerConfirmationAsync(cmd.Email); // ⚠️ pas de await !
// L'exécution continue immédiatement
// Si EnvoyerConfirmationAsync lève une exception, elle est perdue
}
// ✅ Toujours await les Task
public async Task TraiterCommandeAsync(Commande cmd)
{
await EnvoyerConfirmationAsync(cmd.Email);
}
Le compilateur émet un warning CS4014 pour ce cas — ne pas l’ignorer.
5. Lancer des tâches en séquentiel au lieu de parallèle
// ❌ Séquentiel : 3 secondes au total (1 + 1 + 1)
var client = await GetClientAsync(id); // attend 1s
var commandes = await GetCommandesAsync(id); // attend 1s
var factures = await GetFacturesAsync(id); // attend 1s
// ✅ Parallèle : ~1 seconde au total (les 3 en même temps)
var clientTask = GetClientAsync(id);
var commandesTask = GetCommandesAsync(id);
var facturesTask = GetFacturesAsync(id);
await Task.WhenAll(clientTask, commandesTask, facturesTask);
var client = clientTask.Result; // déjà terminé, pas de blocage
var commandes = commandesTask.Result;
var factures = facturesTask.Result;
// OU en C# plus concis :
var (client, commandes, factures) = (
await clientTask,
await commandesTask,
await facturesTask
);
Task.WhenAll lance toutes les opérations en parallèle et attend qu’elles soient toutes terminées.
Patterns utiles
Timeout avec CancellationToken
public async Task<string> GetDataAvecTimeoutAsync(CancellationToken cancellationToken = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(5)); // timeout de 5 secondes
try
{
return await httpClient.GetStringAsync(url, cts.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException("L'appel a dépassé le délai de 5 secondes.");
}
}
Retry simple
public async Task<T> AvecRetryAsync<T>(Func<Task<T>> operation, int maxRetries = 3)
{
for (int i = 0; i < maxRetries; i++)
{
try
{
return await operation();
}
catch (HttpRequestException) when (i < maxRetries - 1)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i))); // backoff exponentiel
}
}
throw new InvalidOperationException("Ne devrait pas arriver");
}
// Utilisation
var data = await AvecRetryAsync(() => httpClient.GetStringAsync(url));
IAsyncEnumerable<T> — streaming asynchrone
Introduit en C# 8, IAsyncEnumerable<T> permet de produire des éléments un par un de manière asynchrone :
// Producteur : chaque élément est produit de façon asynchrone
public async IAsyncEnumerable<Client> GetClientsEnStreamAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
var page = 0;
while (true)
{
var batch = await dbContext.Clients
.OrderBy(c => c.Id)
.Skip(page * 100)
.Take(100)
.ToListAsync(ct);
if (batch.Count == 0) yield break;
foreach (var client in batch)
yield return client;
page++;
}
}
// Consommateur : itère de manière asynchrone
await foreach (var client in GetClientsEnStreamAsync())
{
Console.WriteLine(client.Nom);
}
Channel<T> — producteur/consommateur asynchrone
var channel = Channel.CreateBounded<Commande>(100);
// Producteur
_ = Task.Run(async () =>
{
await foreach (var cmd in GetCommandesEnStreamAsync())
{
await channel.Writer.WriteAsync(cmd);
}
channel.Writer.Complete();
});
// Consommateur
await foreach (var cmd in channel.Reader.ReadAllAsync())
{
await TraiterCommandeAsync(cmd);
}
Task.Run : quand et quand ne pas l’utiliser
Ce que Task.Run fait vraiment
Task.Run planifie du travail sur le ThreadPool. C’est utile uniquement pour décharger du calcul CPU du thread courant.
// ✅ Décharger un calcul lourd du thread UI
private async void CalculerButton_Click(object sender, RoutedEventArgs e)
{
var resultat = await Task.Run(() => CalculComplexe(donnees));
ResultatTextBlock.Text = resultat.ToString(); // retour sur le thread UI
}
Quand ne PAS utiliser Task.Run
// ❌ Ne pas wrapper un appel déjà asynchrone dans Task.Run
public Task<string> GetDataAsync()
{
return Task.Run(async () =>
{
return await httpClient.GetStringAsync(url);
});
}
// httpClient.GetStringAsync est déjà asynchrone, Task.Run gaspille un thread
// ✅ Appeler directement
public Task<string> GetDataAsync()
{
return httpClient.GetStringAsync(url);
}
Règle : Task.Run pour le calcul CPU en contexte UI. Jamais dans du code serveur (ASP.NET Core), où les threads sont précieux.
Résumé
| Concept | À retenir |
|---|---|
async/await |
Libère le thread pendant les opérations I/O, ne crée pas de thread |
Task<T> |
Représente une opération en cours avec un résultat futur |
ValueTask<T> |
Alternative légère quand le résultat est souvent déjà disponible |
| Machine à états | Le compilateur transforme la méthode en IAsyncStateMachine avec un switch sur _state |
SynchronizationContext |
Détermine sur quel thread reprendre après un await |
ConfigureAwait(false) |
Indispensable dans les bibliothèques, inutile en ASP.NET Core |
async void |
À éviter sauf pour les event handlers UI — les exceptions sont non capturables |
.Result / .Wait() |
Risque de deadlock — préférer await partout |
Task.WhenAll |
Paralléliser des opérations indépendantes |
CancellationToken |
Toujours propager pour permettre l’annulation |
IAsyncEnumerable<T> |
Streaming asynchrone élément par élément |
Task.Run |
Uniquement pour décharger du calcul CPU, jamais pour wrapper de l’async |