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 lock
Prenons 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 collection
Imaginons 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);}
ConcurrentDictionary gè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 de GetOrAdd et AddOrUpdate.
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 critique
Le 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.Lock est le type recommandé pour les scénarios généraux utilisant l’instruction lock. 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 deadlock
Avec plusieurs verrous, l’ordre d’acquisition devient une propriété du système.
Thread 1Lock Portfolio | vattend Lock PositionThread 2Lock Position | vattend 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’outil
On 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.




