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

1 septembre 2026

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 visibles

Un 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 objets

Pour 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
Customer

Tant 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)
{
}
}

OrderProcessor conserve le delegate associé à l’événement. Ce delegate référence l’instance de OrderListener.

Si OrderProcessor reste 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 GC

Le 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ès

Un 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 natif

Certaines 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.

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 état

Un 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 Server

Le 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éécriture

Sur 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.