C# haute performance : GC, mémoire, concurrence et runtime .NET

CSharp_haute_performance

Une API commence à consommer plus de mémoire que prévu. Un batch qui terminait en deux heures en prend maintenant quatre. Plusieurs traitements parallèles accèdent au même état et la base commence à remonter des deadlocks.Dans ce type de situation, regarder uniquement le code métier donne rarement toute l’explication. Il faut descendre d’un niveau et observer ce que font le runtime .NET, le Garbage Collector, les collections utilisées et les mécanismes de synchronisation.## Quand les allocations deviennent visiblesUn objet créé en C# est généralement placé dans le managed heap. Le Garbage Collector récupère sa mémoire lorsqu’il n’est plus accessible..NET répartit principalement les objets entre Gen 0, Gen 1 et Gen 2. Les objets récemment créés arrivent en Gen 0. Ceux qui survivent à plusieurs collections remontent progressivement vers les générations supérieures. Le modèle fonctionne bien avec une application classique où beaucoup d’objets ont une durée de vie courte.Cela devient intéressant dès qu’un traitement alloue énormément.foreach (var position in positions) { var key = $"{position.Portfolio}-{position.Instrument}"; Process(key); }Sur quelques centaines d’éléments, le sujet est négligeable. Sur plusieurs millions d’itérations, les objets temporaires finissent par augmenter la pression mémoire et la fréquence des collections.Le GC ne se déclenche pas selon un timer fixe. Le runtime observe notamment le volume d’allocations et adapte ses seuils. Une pression mémoire globale peut également provoquer une collecte.Pendant certaines collections, les objets encore vivants peuvent être déplacés afin de rapprocher les zones utilisées du heap. C’est le compactage. Le runtime réduit ainsi la fragmentation et récupère des zones mémoire contiguës.Les objets volumineux, à partir d’environ 85 000 octets, sont généralement placés dans le Large Object Heap. Leur comportement mérite une attention particulière lorsqu’une application manipule de gros buffers ou des tableaux importants.## Le GC part des références, pas des objetsPour savoir ce qui peut être supprimé, le GC commence depuis les GC Roots. On y retrouve notamment les références présentes dans les stacks des threads, certains handles du runtime et les références statiques.Il suit ensuite le graphe d’objets.GC Root | v CacheService | v CustomerCache | v CustomerTant qu’un chemin existe depuis une racine jusqu’à Customer, cet objet reste vivant.C’est aussi ce qui explique qu’une application .NET puisse avoir une fuite mémoire alors que le GC fonctionne correctement.Prenons un service vivant pendant toute la durée du processus :``` public sealed class OrderListener { public OrderListener(OrderProcessor processor) { processor.OrderProcessed += OnOrderProcessed; }

private void OnOrderProcessed(
    object? sender,
    EventArgs args)
{
}

} ````OrderProcessorconserve le delegate associé à l’événement. Ce delegate référence l’instance deOrderListener.Si OrderProcessorreste attaché à une GC Root,OrderListener` reste lui aussi accessible. Il ne sera donc pas collecté, même si aucune autre partie de l’application ne l’utilise.Lorsque les durées de vie diffèrent, le désabonnement doit être maîtrisé.processor.OrderProcessed -= OnOrderProcessed;Le même raisonnement s’applique aux caches sans expiration, aux collections statiques ou aux timers conservant des références trop longtemps.## Workstation GC ou Server GCLe choix du mode de GC commence à compter lorsque la charge augmente.Workstation GC convient particulièrement aux applications pour lesquelles la réactivité du processus et une consommation mémoire contenue sont importantes.Server GC fonctionne différemment. Le runtime crée plusieurs heaps et utilise plusieurs threads GC afin d’exploiter les processeurs disponibles. Une API ou un batch consommant fortement plusieurs cœurs peut obtenir un meilleur throughput avec cette configuration.<PropertyGroup> <ServerGarbageCollection>true</ServerGarbageCollection> </PropertyGroup>Server GC utilise aussi davantage de mémoire. Sur une machine qui exécute beaucoup de petits services, ce choix peut devenir coûteux.Je préfère donc traiter ce paramètre comme une hypothèse de performance à mesurer, jamais comme une optimisation automatique.## List et Dictionary ne répondent pas au même accèsUn batch charge 500 000 positions en mémoire et doit retrouver régulièrement une position à partir de son identifiant.``` Avec une List<T> :

var position =
    positions.First(p => p.Id == positionId);
```la recherche parcourt potentiellement une partie importante de la collection. Sa complexité est en `O(n)`.Si l’identifiant constitue réellement la clé d’accès du traitement, un dictionnaire change le comportement :```
var positionsById =
    positions.ToDictionary(p => p.Id);

var position = positionsById[positionId];
```L’accès moyen est en `O(1)`.Le dictionnaire calcule le hash de la clé pour déterminer où chercher l’entrée. Il consomme plus de mémoire qu’une liste et doit gérer les collisions de hash.Dans un traitement qui parcourt simplement toutes les valeurs, la `List<T>` reste souvent le choix naturel. Le gain apparaît lorsque les recherches par clé deviennent fréquentes.## Boxing, IL et code natifCertaines allocations passent facilement inaperçues.```
int quantity = 42;

object value = quantity;
````quantity` est un value type. Son passage vers `object` provoque ici un boxing : la valeur est copiée dans un objet alloué sur le heap.```
int quantity = (int)value;
```L’opération inverse effectue l’unboxing.Une occurrence isolée coûte peu. Répétée dans une hot path, elle génère des allocations supplémentaires et augmente indirectement le travail du GC. Les API génériques permettent aujourd’hui d’éviter une grande partie de ces conversions.Sous ce code C#, le compilateur produit généralement de l’IL.[![](https://xavierraboteur.wordpress.com/wp-content/uploads/2026/08/image.png?w=1024)](https://xavierraboteur.wordpress.com/wp-content/uploads/2026/08/image.png)Lors de l’exécution classique, le JIT transforme les méthodes utilisées en instructions machine adaptées au processus.Native AOT déplace cette compilation au moment de la publication. Le binaire contient directement du code natif et n’a plus besoin du JIT pendant son exécution. Cela peut réduire le temps de démarrage et l’empreinte mémoire dans certains scénarios, avec des contraintes supplémentaires autour de la réflexion dynamique et de la génération de code.## Plusieurs threads, un même étatUn simple compteur suffit à montrer le problème.```
processedItems++;
```Cette instruction C# ne correspond pas à une seule opération atomique au niveau processeur. Deux threads peuvent lire la même valeur avant d’écrire leur résultat.Pour ce cas précis :```
Interlocked.Increment(ref processedItems);
````Interlocked` fournit directement l’opération atomique dont nous avons besoin.Le même principe existe pour les collections. Un `Dictionary<TKey, TValue>` standard n’est pas conçu pour recevoir des écritures concurrentes.```
private readonly ConcurrentDictionary<int, Position>
    _positions = new();

_positions.TryAdd(position.Id, position);
````ConcurrentDictionary` fournit des opérations comme `GetOrAdd` et `AddOrUpdate` dont l’atomicité est maîtrisée par l’implémentation.Un `lock` reste nécessaire lorsque plusieurs opérations doivent être protégées ensemble.```
lock (_sync)
{
    portfolio.Exposure += exposure;
    portfolio.LastUpdate = DateTime.UtcNow;
}
```La taille de cette section critique compte directement. Un appel HTTP, une requête SQL ou un calcul long effectué pendant que le verrou est détenu bloque tous les threads qui attendent le même objet.Avec plusieurs locks, l’ordre d’acquisition devient lui aussi important.```
Thread 1 possède Lock A et attend Lock B
Thread 2 possède Lock B et attend Lock A
```Le cycle ne peut plus se résoudre seul.## Le deadlock peut aussi venir de SQL ServerLe même type de dépendance circulaire peut apparaître dans la base.Une transaction modifie `Portfolio`, puis `Position`. Une seconde transaction fait l’inverse.```
Transaction A
Portfolio verrouillé
        |
        v
attend Position

Transaction B
Position verrouillée
        |
        v
attend Portfolio
```SQL Server détecte ce cycle et sélectionne une transaction comme victime afin de libérer les ressources.EF Core peut parfaitement être impliqué. Le framework produit et exécute des commandes SQL ; les règles de verrouillage restent celles du moteur de base de données.Un `SELECT` peut également participer à un blocage ou à un deadlock suivant le niveau d’isolation, les index utilisés, la transaction en cours et les autres ressources déjà verrouillées.La différence entre latence et deadlock est importante ici. Une requête qui attend un verrou peut simplement terminer plus tard. Un deadlock implique un cycle d’attente que SQL Server doit casser.Pour le diagnostiquer, je regarde le deadlock graph remonté par SQL Server. Extended Events et la session `system_health` permettent notamment de récupérer `xml_deadlock_report` et d’identifier les transactions, les ressources et les instructions SQL impliquées.La correction se trouve alors souvent dans l’ordre d’accès aux données, la durée de la transaction, les index ou la quantité de données verrouillées.## Le profiler doit précéder la réécritureSur un problème de performance, une lecture du code fournit des hypothèses. Le profiler indique lesquelles méritent réellement d’être poursuivies.Visual Studio Performance Profiler permet déjà d’observer le CPU, les allocations et l’utilisation mémoire. `dotnet-counters`, `dotnet-trace` et `dotnet-dump` permettent d’aller plus loin lorsque le problème apparaît en environnement d’exécution.Un batch lent peut passer son temps dans le GC parce qu’il alloue trop. Une recherche dans une `List<T>` peut représenter une part importante du CPU parce qu’elle est exécutée plusieurs millions de fois. Une requête SQL apparemment lente peut simplement attendre un verrou détenu par une autre transaction.C’est à partir de cette mesure que je change le code.
Xavier Raboteur