.NET
Comment je comprends la concurrence en .NET
Je pensais que la concurrence voulait juste dire des threads. Elle couvre les race conditions, les deadlocks, les transactions, et le lost update problem, et un lock en C# ne protège en rien une ligne de database.
Quand j'ai entendu le mot concurrence pour la première fois, je pensais que ça voulait simplement dire utiliser plusieurs threads.
Après avoir étudié davantage .NET et SQL Server, j'ai appris que la concurrence est un sujet plus large. Elle se produit chaque fois que plusieurs opérations sont en cours et peuvent accéder ou modifier les mêmes resources. Deux threads qui changent la même variable. Deux requests d'API qui mettent à jour le même record de database. Deux transactions qui attendent des resources que l'une retient pour l'autre. Deux utilisateurs qui modifient la même information presque en même temps.
En tant que développeur junior, je n'ai pas besoin de connaître chaque détail interne des threads, des locks et du moteur de SQL Server. Je devrais comprendre les problèmes les plus courants et les outils disponibles pour les traiter.
Ce que la concurrence signifie vraiment
La concurrence signifie que plusieurs opérations progressent pendant la même période.
Imaginez une API ASP.NET Core qui reçoit deux requests presque en même temps :
Request A : Acheter le dernier produit disponible
Request B : Acheter le dernier produit disponible
Les deux requests peuvent lire que le produit a une unité disponible. Si les deux continuent sans aucune protection, les deux peuvent terminer l'achat, et le système vend le même article deux fois.
C'est pour ça que ça compte. Un code qui fonctionne correctement avec un utilisateur peut produire des résultats inattendus quand plusieurs utilisateurs le sollicitent simultanément.
La concurrence apparaît à trois niveaux : à l'intérieur de l'application .NET quand des threads ou des tasks partagent des données, à l'intérieur de SQL Server quand des transactions accèdent aux mêmes lignes ou tables, et entre les instances de l'application quand elle tourne sur plusieurs servers ou containers.
Ces niveaux sont liés, mais ils ne sont pas contrôlés de la même façon. Un lock en C# peut protéger de la mémoire à l'intérieur d'une instance de l'application. Il ne peut pas protéger une ligne de SQL Server d'un autre server de l'application.
Race conditions
Une race condition se produit quand le résultat d'une opération dépend de quel thread atteint en premier un bout de code.
Prenez un counter simple :
public class RequestCounter
{
private int _count;
public void Increment()
{
_count++;
}
}
Ce _count++ a l'air d'être une seule opération, mais en interne ce sont trois étapes : lire la valeur actuelle, ajouter un, stocker la nouvelle valeur.
Supposons que la valeur actuelle soit 10. Deux threads pourraient tous les deux lire 10, calculer tous les deux 11, et stocker tous les deux 11. Le résultat attendu était 12. Le résultat réel est 11.
Pour les opérations numériques simples, .NET fournit la classe Interlocked :
using System.Threading;
public class RequestCounter
{
private int _count;
public void Increment()
{
Interlocked.Increment(ref _count);
}
}
Interlocked.Increment exécute l'incrément comme une seule opération atomique, donc aucun autre thread ne peut interférer en plein milieu.
Utiliser lock en C#
Pour des opérations plus complexes, C# fournit l'instruction lock :
public class BankAccount
{
private readonly object _balanceLock = new();
private decimal _balance;
public void Deposit(decimal amount)
{
lock (_balanceLock)
{
_balance += amount;
}
}
}
Un seul thread peut exécuter la section protégée en utilisant ce lock object à la fois.
Ajouter des locks partout n'est pourtant pas une bonne solution. Les locks réduisent les performances, font attendre les threads, augmentent la complexité, introduisent des deadlocks, et peuvent cacher des problèmes causés par un mauvais design. Là où c'est possible, il vaut mieux éviter le shared mutable state plutôt que de tout protéger avec des locks.
Un lock en C# ne fonctionne aussi qu'à l'intérieur du processus qui le possède. Si une application tourne sur trois servers, chaque server a son propre lock object. La concurrence du database doit être gérée par le database lui-même ou via un autre mécanisme distribué.
Deadlocks
Un deadlock survient quand deux opérations attendent en permanence des resources que l'une retient pour l'autre.
Thread A :
1. Verrouille la Resource 1
2. Attend la Resource 2
Thread B :
1. Verrouille la Resource 2
2. Attend la Resource 1
Aucun des deux threads ne peut continuer. La même chose se produit dans SQL Server :
Transaction A :
1. Met à jour Customer 1
2. Essaie de mettre à jour Customer 2
Transaction B :
1. Met à jour Customer 2
2. Essaie de mettre à jour Customer 1
SQL Server détecte ce cycle et choisit une transaction comme victime du deadlock. Cette transaction subit un rollback pour que l'autre puisse continuer. Les applications devraient être prêtes à recevoir des erreurs de deadlock, et selon l'opération, réessayer peut être approprié.
Quelques façons de base de réduire les deadlocks : garder les transactions courtes, accéder aux resources dans un ordre cohérent, éviter les opérations inutiles à l'intérieur des transactions, créer des index appropriés, ne jamais attendre d'input utilisateur pendant qu'une transaction est ouverte, et implémenter une retry logic pour les opérations qu'il est sûr de répéter.
Les deadlocks ne peuvent pas toujours être éliminés dans des systèmes à forte concurrence. L'objectif est de réduire leur fréquence et de bien les gérer quand ils se produisent.
Le blocking n'est pas un deadlock
J'avais l'habitude de traiter ces deux choses comme identiques. Elles ne le sont pas.
Le blocking se produit quand une opération attend parce qu'une autre retient une resource dont elle a besoin :
Transaction A met à jour un produit et garde la transaction ouverte.
Transaction B essaie de mettre à jour le même produit.
Transaction B attend que Transaction A se termine.
Cette attente peut être tout à fait normale. Un deadlock exige une dépendance circulaire, où A attend B et B attend A.
Le blocking se termine généralement quand la première transaction fait un commit ou un rollback. Un deadlock ne se résout pas naturellement, c'est pourquoi SQL Server doit interrompre l'une des transactions. Les transactions longues aggravent le blocking, parce que les locks peuvent rester tenus jusqu'à ce que la transaction se termine.
Pourquoi les transactions comptent
Une transaction de database regroupe plusieurs opérations en une seule unité logique de travail. L'exemple classique est le transfert d'argent :
1. Retirer 100 $ du Account A.
2. Ajouter 100 $ au Account B.
Les deux devraient réussir ou les deux devraient échouer. Sans transaction, la première pourrait réussir et la seconde pourrait échouer, et l'argent disparaît.
await using var transaction =
await dbContext.Database.BeginTransactionAsync();
try
{
sender.Balance -= 100m;
receiver.Balance += 100m;
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
EF Core enveloppe déjà les changements d'un seul appel à SaveChanges dans une transaction quand le provider le prend en charge. Le contrôle manuel compte davantage quand une opération métier implique plusieurs appels à SaveChanges, du SQL raw, ou d'autres opérations qui doivent réussir ensemble.
ACID
Une transaction fiable suit quatre propriétés.
Atomicity signifie que la transaction est une seule unité. Soit toutes les opérations se produisent, soit aucune. SQL Server ne doit pas débiter l'expéditeur sans créditer le destinataire.
Consistency signifie que la transaction laisse le database dans un état valide, avec les constraints et les règles métier toujours respectées. Un champ obligatoire ne devrait pas devenir null, une foreign key devrait continuer à pointer vers un record existant, un solde ne devrait pas devenir négatif quand le métier ne l'autorise pas.
Isolation contrôle comment les transactions concurrentes observent les changements les unes des autres. Un autre request ne devrait pas voir l'argent après qu'il a quitté l'expéditeur mais avant qu'il n'arrive au destinataire.
Durability signifie qu'une fois qu'une transaction fait un commit, ses changements survivent même si le server plante ensuite. SQL Server utilise son transaction log pour ça.
Le lost update problem
C'est le problème de concurrence de database que j'ai trouvé le plus facile à comprendre.
Deux employés ouvrent le même record client, où le numéro de téléphone est 1111. L'employé A le change en 2222. L'employé B, qui tient encore l'ancienne version, le change en 3333. A enregistre en premier, puis B enregistre.
Sans vérification de concurrence, la mise à jour de B écrase celle de A, et le changement de A est perdu sans que personne ne soit averti.
Optimistic concurrency
L'optimistic concurrency suppose que les conflits sont possibles mais rares. Au lieu de verrouiller un record pendant tout le temps où quelqu'un le modifie, l'application vérifie si le record a changé avant d'enregistrer.
Lire une ligne et sa version, la laisser être modifiée, envoyer la mise à jour avec la version d'origine, mettre à jour seulement quand la version correspond encore, et signaler un conflit quand ce n'est pas le cas.
Ça convient aux applications web, parce que les utilisateurs ne gardent pas des locks de database ouverts pendant qu'ils consultent et modifient des pages.
EF Core prend ça en charge en permettant de configurer une propriété comme concurrency token. Pendant une mise à jour, il inclut le token d'origine dans la clause WHERE du SQL. Si un autre processus a modifié la ligne, rien ne correspond, et EF Core lève une DbUpdateConcurrencyException.
rowversion de SQL Server
SQL Server fournit le type rowversion. Malgré son ancien synonyme timestamp, ce n'est ni une date ni une heure. C'est une valeur binaire générée automatiquement qui change chaque fois que la ligne est mise à jour, ce qui la rend utile pour détecter si une ligne a changé depuis que vous l'avez lue.
using System.ComponentModel.DataAnnotations;
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
Le SQL généré suit cette idée :
UPDATE Products
SET Price = @newPrice
WHERE Id = @id
AND RowVersion = @originalRowVersion;
Si un autre request a déjà mis à jour le produit, son RowVersion est différent, la mise à jour touche zéro ligne, et EF Core détecte le conflit.
try
{
await dbContext.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
throw new InvalidOperationException(
"This record was changed by another user. Reload it and try again.");
}
Dans une vraie API, ça devient une réponse HTTP appropriée, généralement 409 Conflict. L'application peut demander à l'utilisateur de recharger, montrer les valeurs actuelles, le laisser choisir quelle version garder, fusionner les changements compatibles, ou réessayer automatiquement là où c'est sûr.
Pessimistic et optimistic
Les stratégies de concurrence se répartissent en deux approches générales.
La pessimistic concurrency suppose qu'un conflit est probable, donc le système verrouille la resource et les autres opérations attendent. Utile quand les conflits seraient coûteux ou que les opérations doivent se coordonner strictement, au prix de plus de blocking et de risque de deadlock.
L'optimistic concurrency suppose que les conflits sont rares. Les opérations avancent sans lock long, et l'application vérifie que rien n'a changé avant d'enregistrer. rowversion et les concurrency tokens d'EF Core en sont les exemples courants.
Aucune des deux n'est toujours meilleure. Ça dépend des données, du nombre de conflits attendu, et des règles métier.
Isolation levels
Les isolation levels contrôlent à quel point une transaction est isolée des autres. Les principaux niveaux de SQL Server sont READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SNAPSHOT, et SERIALIZABLE.
La leçon importante à mon niveau, c'est qu'une isolation plus forte donne plus de consistency mais augmente le blocking et réduit la concurrence. Une isolation plus faible améliore la concurrence mais peut laisser les transactions observer les données de façons que certaines opérations métier ne peuvent pas accepter.
SNAPSHOT et les options basées sur la row version laissent les lectures utiliser des versions de ligne stockées au lieu d'attendre les writers, ce qui réduit le blocking entre lecture et écriture. Ça ajoute d'autres considérations, et ça ne supprime pas le besoin de comprendre les conflits de mise à jour.
Je ne changerais pas l'isolation level juste parce qu'une application a des problèmes de blocking. Les queries, les index, les limites de transaction, et le vrai besoin métier passent tous en premier.
Ce que j'en retire
Ne supposez pas qu'une ligne signifie une opération atomique, parce que _count++ fait trois étapes et peut partir en race.
Gardez les transactions courtes. Une transaction devrait contenir les opérations de database d'une seule action métier logique, et ne devrait pas rester ouverte pendant qu'elle appelle des API externes, exécute des calculs longs, ou attend un input utilisateur.
N'utilisez pas un lock en C# comme un lock de database. Il protège de la mémoire à l'intérieur d'un processus et ne coordonne rien entre les servers.
Attendez-vous à des conflits. Dans un système multi-utilisateur, deux requests qui mettent à jour le même record n'est pas un edge case impossible, et l'application devrait décider ce qui se passe quand ça arrive.
Comprenez d'abord la règle métier, parce que la bonne solution dépend de l'opération. Mettre à jour le titre d'un article de blog n'est pas la même chose que réserver la dernière chambre d'hôtel, traiter un paiement, réduire un stock, transférer de l'argent, ou utiliser un voucher à usage unique. Plus l'opération est importante, plus son comportement de concurrence doit être conçu avec soin.
Il y a bien plus à apprendre ici, y compris les types de lock, les execution plans, les deadlock graphs, les stratégies de retry, et les systèmes distribués. Ces bases m'aident déjà à écrire des applications plus sûres et à repérer des problèmes qui n'apparaissent que quand plusieurs requests tournent en même temps.
Références
- Microsoft Learn, Managed Threading Best Practices: race conditions, synchronization, et
Interlocked - Microsoft Learn, Synchronizing Data for Multithreading
- Microsoft Learn, Deadlocks Guide for SQL Server
- Microsoft Learn, Transaction Locking and Row Versioning Guide
- Microsoft Learn, Handling Concurrency Conflicts in EF Core
- Microsoft Learn, SQL Server Value Generation in EF Core
- Microsoft Learn, Transactions in EF Core
- Microsoft Learn, SET TRANSACTION ISOLATION LEVEL