MVAESTECH
Guide .NET · 04 · Fondations

Publié le · par Mathieu Vaes

Concurrence & async

Une API .NET sert des milliers de requêtes avec une poignée de threads. Ce n'est possible que si personne ne bloque un thread en attendant une I/O.

Chapitres du guide
En clair

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.

Thread #4 I/O (réseau) Thread #7 avant await libre : sert d'autres requêtes requête SQL en cours, aucun thread bloqué suite après await var commande = await db.Orders.FindAsync(id, ct);
Pendant l'attente réseau, aucun thread n'est consommé. Bloquer avec .Result annule tout ce bénéfice et immobilise un thread du pool.
RèglePourquoi
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 UIUne exception y fait tomber le processus ; l'appelant ne peut pas attendre la fin
CancellationToken sur chaque méthode async publiqueRequê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/OEnvelopper 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é

PrimitivePortéeCompatible awaitUsage
lock / Lock (.NET 9+)ProcessusNonSection critique courte et synchrone
InterlockedProcessus—Compteurs et échanges atomiques sans verrou
SemaphoreSlimProcessusOui (WaitAsync)Limiter N accès concurrents ; verrou async (N = 1)
Semaphore / Mutex nommésMachineNonSynchronisation inter-processus (instance unique d'appli)
ReaderWriterLockSlimProcessusNonLectures nombreuses, écritures rares
ConcurrentDictionary, ConcurrentQueueProcessus—Collections thread-safe
Channel<T>ProcessusOuiProducteur / consommateur avec contre-pression
Verrou distribué (Redis, Postgres advisory lock)ClusterOuiPlusieurs 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.

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);
Piège
  • await dans un lock : interdit par le compilateur (CS1996). Utiliser SemaphoreSlim(1, 1).
  • SemaphoreSlim n'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, compteur threadpool-queue-length.
  • État mutable dans un singleton : partagé par toutes les requêtes simultanées. Un Dictionary non protégé peut corrompre sa structure interne.
  • DbContext partagé entre tâches parallèles : il n'est pas thread-safe. Un contexte par unité de travail.

À retenir

  • await libère le thread ; bloquer dessus détruit la scalabilité.
  • lock pour du synchrone court, SemaphoreSlim pour l'async, Channel pour 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).

Un projet .NET à cadrer ou à reprendre ?

API REST documentées et testées, applications ASP.NET MVC (y compris VB.NET), migration de services anciens vers une architecture claire : parlons de votre code.