Quand plusieurs threads lisent et écrivent les mêmes données en même temps, des mises à jour peuvent être perdues ou vues à moitié faites. L'instruction lock rend un bloc de code mutuellement exclusif : pendant qu'un thread s'y trouve, chaque autre thread qui atteint un lock sur le même objet attend son tour.
Le problème : une condition de concurrence
Quatre tâches ajoutent chacune 100 000 à un compteur partagé. La réponse devrait être 400 000 :
Exemple de sortie :
Expected 400000, got 245609
Le total est en général inférieur, et d'une valeur différente à chaque exécution (sur une machine à un seul cœur, il peut parfois être juste, ce qui rend ces bugs difficiles à attraper). count++ ressemble à une seule opération mais en fait trois : lire count, ajouter un, réécrire. Deux threads peuvent tous deux lire 500, calculer tous deux 501 et écrire tous deux 501, et une incrémentation est perdue.
La correction : lock
Enveloppez la lecture, la modification et l'écriture dans un lock sur un objet partagé :
Sortie :
Expected 400000, got 400000
Désormais, un seul thread à la fois peut se trouver dans le bloc, donc chaque incrémentation se termine avant que la suivante ne lise la valeur. Le verrou garantit aussi la visibilité : un thread qui entre dans le verrou voit toutes les écritures faites par le détenteur précédent avant qu'il ne le quitte.
La protection ne fonctionne que si chaque accès à count passe par le même verrou. Un seul count++ non verrouillé ailleurs dans le programme ramène la condition de concurrence.
En quoi lock se compile
lock est un raccourci pour la classe Monitor, avec un try/finally pour que le verrou soit libéré même quand le bloc lève une exception :
lock (sync)
{
count++;
}
// is compiled roughly as:
bool taken = false;
try
{
Monitor.Enter(sync, ref taken);
count++;
}
finally
{
if (taken) Monitor.Exit(sync);
}
Monitor propose aussi TryEnter(obj, timeout), qui abandonne après un délai au lieu d'attendre indéfiniment, et Wait/Pulse pour la signalisation entre threads. Un thread qui détient déjà un verrou peut y entrer à nouveau (les verrous sont réentrants), donc une méthode verrouillée peut appeler une autre méthode verrouillée sur le même objet.
Choisir l'objet de verrou
L'objet placé dans lock (...) n'est qu'un jeton sur lequel les threads s'accordent. Les règles :
- Utilisez un champ privé en lecture seule de type
object.private readonly object sync = new object();. Privé, pour qu'aucun code extérieur ne puisse prendre le même verrou ;readonly, pour que le jeton ne puisse pas être remplacé pendant qu'un thread le détient. - Jamais
lock (this). Quiconque détient une référence à votre objet peut aussi verrouiller dessus, et son code bloque alors le vôtre ou provoque un interblocage avec lui. - Ne verrouillez jamais sur une chaîne ou sur
typeof(...). Les littéraux de chaîne sont internés (chaque"orders"du processus est le même objet), et les objetsTypesont partagés par toute l'application, si bien que du code sans rapport peut finir par se disputer le même verrou. - Ne verrouillez jamais sur un type valeur.
lock (count)sur unintne compile pas (erreur CS0185), et le boxer à la main crée un nouvel objet à chaque fois, donc cela ne verrouillerait rien. - Utilisez un verrou par ensemble de données qui doit rester cohérent : un champ de verrou statique pour les données statiques, un champ d'instance pour les données propres à chaque instance.
Protéger des opérations en plusieurs étapes
lock est surtout nécessaire là où une vérification et une action doivent se produire ensemble. Un compte thread-safe montre à la fois le schéma vérifier puis agir et un instantané cohérent de deux champs :
Sortie :
10 left after 33 withdrawals
Sans le verrou, deux threads pourraient voir tous deux un solde de 40, passer tous deux la vérification et retirer tous deux 30, laissant le compte à moins 20. La méthode Summary verrouille aussi, pour ne jamais rapporter un balance postérieur à un retrait avec un compte de withdrawals antérieur à celui-ci.
Dictionary<TKey, TValue> et List<T> ne sont pas thread-safe non plus : des écritures concurrentes peuvent corrompre leurs tableaux internes, et pas seulement perdre des mises à jour. Protégez-les avec un verrou ou utilisez ConcurrentDictionary<TKey, TValue> et les autres types de System.Collections.Concurrent.
Interlocked pour les valeurs uniques
Quand l'état partagé est un seul entier ou une seule référence et que la mise à jour tient en une étape, la classe Interlocked la fait de façon atomique, sans verrou :
Sortie :
Visits: 10000
Bytes: 5120000
Peak: 10000
Increment, Decrement, Add et Exchange correspondent à des opérations atomiques du processeur (une seule instruction sur x64). CompareExchange(ref location, newValue, expected) n'écrit que si l'emplacement contient toujours expected, et renvoie ce qu'il a trouvé, ce qui permet de construire n'importe quelle mise à jour sous forme de boucle de réessai. Tout ce qui touche deux variables à la fois a toujours besoin d'un lock.
Les interblocages
Un interblocage se produit quand deux threads détiennent chacun un verrou dont l'autre a besoin :
// Thread 1 // Thread 2
lock (accountA) lock (accountB)
{ {
lock (accountB) { /* ... */ } lock (accountA) { /* ... */ }
} }
Si le thread 1 prend accountA pendant que le thread 2 prend accountB, chacun attend ensuite l'autre indéfiniment. Il n'y a ni exception ni délai d'expiration ; le programme se fige. Un virement entre deux comptes qui verrouille « source » puis « destination » produit exactement cela quand deux virements opposés s'exécutent en même temps.
Les défenses standard :
- Verrouillez dans un ordre global fixe. Pour un virement, verrouillez d'abord le compte à l'identifiant le plus petit, quel que soit le sens du mouvement d'argent.
- Tenez les verrous brièvement et faites le travail lent (E/S, journalisation, appels réseau) en dehors.
- N'appelez pas de code inconnu pendant que vous détenez un verrou : événements, callbacks et méthodes virtuelles peuvent prendre leurs propres verrous.
- Utilisez
Monitor.TryEnteravec un délai là où un blocage serait pire qu'un échec.
Pas d'await dans un lock : utilisez SemaphoreSlim
await n'est pas autorisé dans un bloc lock (erreur de compilation CS1996). Après un await, la méthode peut continuer sur un autre thread, et un verrou Monitor doit être libéré par le thread qui l'a pris. Pour le code async, SemaphoreSlim avec un compte de 1 fait office de verrou compatible avec l'async :
Sortie :
5 saved, one at a time
Associez toujours WaitAsync à Release dans un finally, sinon une exception laisse la porte fermée pour de bon. Un SemaphoreSlim(3, 3) laisse entrer trois appelants à la fois, ce qui permet de limiter les requêtes concurrentes vers une API à débit limité.
System.Threading.Lock (.NET 9)
.NET 9 avec C# 13 ajoute un type dédié, System.Threading.Lock. Quand l'objet d'une instruction lock est un Lock, le compilateur utilise son API EnterScope, plus rapide, au lieu de Monitor :
private readonly Lock sync = new Lock();
public void Add(decimal amount)
{
lock (sync) // uses Lock.EnterScope(), not Monitor
{
balance += amount;
}
}
Les règles de choix de l'objet restent les mêmes. Sur les versions antérieures, private readonly object est le bon choix.
Erreurs courantes
- Verrouiller certains accès mais pas tous. Chaque lecture et écriture des données partagées doit prendre le même verrou.
lock (this),lock (typeof(X)),lock ("name"). Du code extérieur peut prendre le même verrou.- Faire des E/S lentes dans un verrou. Tous les autres threads attendent ; gardez le bloc court.
- Prendre deux verrous dans des ordres différents à des endroits différents. L'interblocage classique.
- Utiliser
lockautour d'unawait. Cela ne compile pas ; utilisezSemaphoreSlim. - Un verrou par appel.
lock (new object())ne protège rien ; l'objet doit être partagé.
Questions fréquentes
Que fait lock en C# ?
lock (obj) { ... } ne laisse qu'un seul thread à la fois exécuter le bloc pour un objet de verrou donné. Un second thread qui atteint un lock sur le même objet attend que le premier quitte le bloc. Le verrou est libéré même si le bloc lève une exception, car lock se compile en Monitor.Enter et Monitor.Exit dans un try/finally.
Sur quel objet faut-il verrouiller en C# ?
Un champ privé dédié : private readonly object _sync = new object();. Ce doit être un type référence, partagé par chaque thread qui touche aux données protégées, et inaccessible depuis l'extérieur de votre classe. Ne verrouillez jamais sur this, un Type (typeof(MyClass)) ou une chaîne, car d'autres codes peuvent verrouiller le même objet et vous bloquer ou provoquer un interblocage.
Quand utiliser Interlocked plutôt que lock ?
Quand l'état partagé est un seul nombre ou une seule référence et que l'opération tient en une étape : Interlocked.Increment(ref count), Interlocked.Add(ref total, x), Interlocked.Exchange ou CompareExchange. Ce sont des opérations matérielles atomiques, plus rapides qu'un verrou. Pour tout ce qui touche plusieurs champs ou une collection, utilisez lock.
Peut-on utiliser await dans un lock en C# ?
Non, le compilateur rejette await dans un bloc lock (erreur CS1996), car le code qui suit l'await peut reprendre sur un autre thread que celui qui détient le verrou. Utilisez plutôt SemaphoreSlim avec un compte de 1 : await sem.WaitAsync(); try { ... } finally { sem.Release(); }.
Comment se produisent les interblocages avec lock ?
Le thread 1 détient le verrou A et attend le verrou B, tandis que le thread 2 détient le verrou B et attend le verrou A. Aucun ne peut avancer, et le programme se fige sans exception. Évitez-le en prenant toujours plusieurs verrous dans le même ordre global, en tenant les verrous le moins longtemps possible, et en n'appelant jamais de code inconnu pendant que vous détenez un verrou.