Une API sert beaucoup de clients en même temps avec peu de threads, les « bras » qui exécutent le code. Quand le code attend la base de données, await permet à ce bras d'aller servir quelqu'un d'autre au lieu d'attendre les bras croisés. Un sémaphore, c'est un parking à N places : quand il est plein, les voitures attendent qu'une place se libère.
Ce que fait réellement await Junior
Le compilateur transforme une méthode async en machine à états. À chaque await sur une opération non terminée, le thread est rendu au ThreadPool. Quand l'I/O se termine (notifiée par le système, IOCP sous Windows ou epoll sous Linux), la suite de la méthode reprend sur un thread disponible, pas forcément le même.
.Result annule tout ce bénéfice et immobilise un thread du pool.| Règle | Pourquoi |
|---|---|
| async de bout en bout | .Result / .Wait() bloquent un thread et provoquent des interblocages avec un SynchronizationContext (WPF, WinForms, ASP.NET classique) |
Jamais async void sauf handler d'événement UI | Une exception y fait tomber le processus ; l'appelant ne peut pas attendre la fin |
CancellationToken sur chaque méthode async publique | Requête annulée par le client = travail stoppé (HttpContext.RequestAborted) |
ConfigureAwait(false) dans les bibliothèques | Évite de reprendre sur le contexte UI ; inutile dans le code ASP.NET Core applicatif |
Task.Run pour le CPU, pas pour l'I/O | Envelopper une I/O dans Task.Run consomme un thread pour rien |
ValueTask seulement si mesuré | Utile sur un chemin chaud souvent synchrone ; à n'attendre qu'une fois |
Primitives de synchronisation Confirmé
| Primitive | Portée | Compatible await | Usage |
|---|---|---|---|
lock / Lock (.NET 9+) | Processus | Non | Section critique courte et synchrone |
Interlocked | Processus | — | Compteurs et échanges atomiques sans verrou |
SemaphoreSlim | Processus | Oui (WaitAsync) | Limiter N accès concurrents ; verrou async (N = 1) |
Semaphore / Mutex nommés | Machine | Non | Synchronisation inter-processus (instance unique d'appli) |
ReaderWriterLockSlim | Processus | Non | Lectures nombreuses, écritures rares |
ConcurrentDictionary, ConcurrentQueue | Processus | — | Collections thread-safe |
Channel<T> | Processus | Oui | Producteur / consommateur avec contre-pression |
| Verrou distribué (Redis, Postgres advisory lock) | Cluster | Oui | Plusieurs instances : un lock local ne protège rien |
Le sémaphore en action Confirmé
Un SemaphoreSlim(2) laisse passer au plus deux tâches simultanément. Les autres attendent sans bloquer de thread grâce à WaitAsync. Cas typique : limiter les appels concurrents à une API partenaire qui impose un quota.
EN ATTENTE · WaitAsync()
EN COURS · section protégée
TERMINÉ · Release()
public sealed class PartnerClient(HttpClient http)
{
// Quota partenaire : 2 appels simultanés maximum, partagé par toute l'appli (singleton)
private readonly SemaphoreSlim _gate = new(initialCount: 2, maxCount: 2);
public async Task<Quote> GetQuoteAsync(string sku, CancellationToken ct)
{
await _gate.WaitAsync(ct); // attend sans bloquer de thread
try
{
return await http.GetFromJsonAsync<Quote>($"quotes/{sku}", ct)
?? throw new InvalidOperationException("Réponse vide");
}
finally
{
_gate.Release(); // toujours dans finally
}
}
}
// Alternative déclarative pour paralléliser une liste avec un degré borné
await Parallel.ForEachAsync(skus, new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = ct },
async (sku, token) => await client.GetQuoteAsync(sku, token));
Producteur / consommateur avec Channel
var channel = Channel.CreateBounded<Job>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait // le producteur ralentit si la file est pleine
});
// Producteur (ex. endpoint HTTP)
await channel.Writer.WriteAsync(job, ct);
// Consommateur (BackgroundService)
await foreach (var job in channel.Reader.ReadAllAsync(stoppingToken))
await ProcessAsync(job, stoppingToken);
awaitdans unlock: interdit par le compilateur (CS1996). UtiliserSemaphoreSlim(1, 1).SemaphoreSlimn'est pas réentrant : une méthode qui le reprend alors qu'elle le détient déjà s'interbloque elle-même.- Release oublié sur un chemin d'exception : le slot est perdu pour toujours, l'appli finit par geler.
- Famine du ThreadPool : du code synchrone bloquant sous charge. Le pool n'ajoute des threads que lentement ; la latence explose. Diagnostic :
dotnet-counters, compteurthreadpool-queue-length. - État mutable dans un singleton : partagé par toutes les requêtes simultanées. Un
Dictionarynon protégé peut corrompre sa structure interne. DbContextpartagé entre tâches parallèles : il n'est pas thread-safe. Un contexte par unité de travail.
À retenir
awaitlibère le thread ; bloquer dessus détruit la scalabilité.lockpour du synchrone court,SemaphoreSlimpour l'async,Channelpour les files.- Dès qu'il y a plusieurs instances (Kubernetes, Cloud Run), la synchronisation doit être distribuée ou supprimée par conception (idempotence).