ConcurrentDictionary, Interlocked et lock, choisir le bon mécanisme de synchronisation en C#

CSharp_haute_performance_3

Un service traite plusieurs calculs en parallèle. Chaque traitement met à jour un compteur, enrichit un cache en mémoire et modifie parfois plusieurs valeurs qui doivent rester cohérentes entre elles.
Les trois opérations partagent de la mémoire entre plusieurs threads. Elles ne demandent pourtant pas le même niveau de synchronisation.
C’est généralement là que le choix entre Interlocked, ConcurrentDictionary et lock commence à avoir du sens.## Un compteur ne nécessite pas forcément un lockPrenons un compteur utilisé pour suivre le nombre de positions traitées.``` private int _processedPositions;

public void PositionProcessed() { _processedPositions++; } L’expression paraît indivisible en C#. Au niveau de l’exécution, elle implique une lecture, une modification puis une écriture.Deux threads peuvent donc lire la même valeur avant de l’incrémenter.Pour cette opération, `Interlocked` fournit directement l’atomicité recherchée : private int _processedPositions;

public void PositionProcessed() { Interlocked.Increment(ref _processedPositions); } Le runtime garantit ici que l’incrément est effectué comme une opération atomique. `Interlocked` fournit aussi `Add`, `Exchange` et `CompareExchange`, ce dernier permettant de modifier une valeur uniquement si elle possède encore la valeur attendue.Pour un compteur ou le remplacement atomique d’une référence, j’utilise donc généralement `Interlocked`. Introduire une section critique complète pour une seule opération augmenterait inutilement le niveau de synchronisation.## Quand l’état partagé devient une collectionImaginons maintenant un batch parallèle qui conserve en mémoire la dernière exposition calculée pour chaque portefeuille.Un `Dictionary<TKey, TValue>` standard ne convient pas aux écritures concurrentes. private readonly ConcurrentDictionary<int, decimal> _exposures = new();

public void UpdateExposure( int portfolioId, decimal exposure) { exposures.AddOrUpdate( portfolioId, exposure, (, _) => exposure); } ````ConcurrentDictionarygère lui-même la synchronisation nécessaire aux accès concurrents. Son implémentation utilise notamment un verrouillage fin pour les écritures, tandis que les lectures sont réalisées sans verrouillage de ce type.Cela permet à plusieurs traitements de travailler sur des clés différentes sans placer tout le dictionnaire derrière un verrou applicatif unique.Il existe toutefois un détail important autour deGetOrAddetAddOrUpdate.``` var portfolio = _portfolios.GetOrAdd( portfolioId, id => LoadPortfolio(id)); ```La factory peut être exécutée plusieurs fois lorsque plusieurs threads travaillent simultanément sur la même clé. Microsoft exécute volontairement ces delegates hors des verrous internes du dictionnaire. Une seule valeur sera finalement associée à la clé, mais plusieurs threads peuvent avoir exécuté la factory entre-temps.J’évite donc d’y placer un traitement ayant des effets de bord, comme l’envoi d’un message ou l’écriture d’une opération métier non idempotente.## Certaines invariants demandent une section critiqueLe problème change lorsque plusieurs modifications doivent être cohérentes ensemble.``` portfolio.Exposure += exposure; portfolio.PositionCount++; portfolio.LastCalculation = DateTime.UtcNow; ```Rendre chaque affectation atomique séparément ne garantit pas que les autres threads observeront un état cohérent entre ces opérations.La frontière à protéger devient l’ensemble de la modification.Depuis .NET 9 et C# 13, System.Threading.Lockest le type recommandé pour les scénarios généraux utilisant l’instructionlock`. Le compilateur reconnaît ce type et utilise son API dédiée.``` private readonly Lock _portfolioLock = new();

public void ApplyExposure( Portfolio portfolio, decimal exposure) { lock (_portfolioLock) { portfolio.Exposure += exposure; portfolio.PositionCount++; portfolio.LastCalculation = DateTime.UtcNow; } } La durée de cette section critique compte directement.Je garde sous le verrou uniquement le code qui protège l’invariant. Une requête SQL, un appel HTTP ou un traitement CPU important augmenterait le temps pendant lequel les autres threads attendent.L’instruction `lock` n’accepte d’ailleurs pas `await`. Lorsqu’une synchronisation doit traverser une opération asynchrone, le problème demande un mécanisme adapté à ce modèle, par exemple `SemaphoreSlim`.## La mauvaise combinaison finit en deadlockAvec plusieurs verrous, l’ordre d’acquisition devient une propriété du système. Thread 1 Lock Portfolio | v attend Lock Position

Thread 2 Lock Position | v attend Lock Portfolio ```Chaque thread détient la ressource dont l’autre a besoin.Le moyen le plus simple de réduire ce risque consiste à maintenir un ordre d’acquisition stable et à limiter le nombre de ressources protégées simultanément.## La frontière de synchronisation détermine l’outilOn peut résumer le choix par la quantité d’état qui doit rester atomique.Interlocked fonctionne bien lorsque l’opération atomique existe déjà.ConcurrentDictionary déplace la gestion de la concurrence au niveau de la collection et permet aux accès par clé de rester efficaces sous charge.Un lock protège une invariant plus large, lorsque plusieurs opérations doivent être observées comme un seul changement cohérent.Dans du code concurrent, je commence donc par identifier précisément cette frontière. Le mécanisme de synchronisation vient ensuite.

Xavier Raboteur