1. Pourquoi le lock classique peut être insuffisant ?
Réfléchissons : on a une ressource partagée — par exemple, une liste de clients qu'on stocke quelque part dans notre application multithread. La plupart du temps on ne fait que lire cette liste — afficher les données à l'utilisateur, générer des rapports, filtrer et trier l'info. Seuls de temps en temps quelqu'un ajoute un client ou en supprime un.
Si on utilise le lock habituel, tout thread qui veut lire doit attendre que l'autre thread ait fini sa lecture ou, pire, son écriture. Et si 99% des opérations sont des lectures ? On freine inutilement tous les lecteurs alors qu'ils pourraient s'exécuter en parallèle.
C'est là qu'intervient un type spécial de verrou — le Reader-Writer Lock. Idée clé : plusieurs threads peuvent lire en même temps, mais si quelqu'un veut modifier, il obtient un accès exclusif qui bloque les lecteurs.
2. Théorie : qu'est-ce que ReaderWriterLockSlim
Bref sur les classes
- ReaderWriterLock — plus ancien, lent, peut mener à des deadlocks. À utiliser seulement si vous devez supporter .NET Framework 2.0.
- ReaderWriterLockSlim — alternative moderne et rapide (Slim : «maigre», «allégé»). Cette classe est optimisée pour des opérations fréquentes de lecture/écriture concurrentes et est recommandée quand il y a beaucoup de threads et que la lecture est beaucoup plus fréquente que l'écriture.
Principe de fonctionnement
ReaderWriterLockSlim implémente trois modes de verrouillage :
- Read Lock (verrou de lecture) : plusieurs threads peuvent lire simultanément.
- Write Lock (verrou d'écriture) : un seul thread peut modifier les données, et aucun autre ne lit.
- Upgradeable Read Lock (verrou de lecture upgradable) : un seul thread peut détenir ce type de verrou ; il lit, mais peut, si besoin, «monter» en write lock.
Schéma de verrouillage
| Thread-Lecteur | Thread-Écrivain | Thread avec Upgradeable Read Lock | |
|---|---|---|---|
| Lecteur | ✅ | ⛔ | ✅/⛔ |
| Écrivain | ⛔ | ⛔ | ⛔ |
| Upgradeable Read | ✅* | ⛔ | ⛔ |
✅ — autorisé
⛔ — bloqué
* — seulement si l'upgradeable read lock n'a pas été «monté» en write
Quand utiliser (et ne pas utiliser) ReaderWriterLockSlim
Recommandé :
- Beaucoup de threads concurrents qui lisent majoritairement des données partagées (répertoires, cache, configuration).
- Les écritures sont rares (par ex. une fois par seconde/minute/heure).
- On veut optimiser la performance des lectures sans gêner les threads lecteurs.
À éviter :
- Lecture/écriture en proportions égales (mieux vaut un lock classique).
- Les données changent souvent (le gain est minime).
3. Utiliser ReaderWriterLockSlim en pratique
Allongeons notre appli didactique ! On avait un répertoire simple de clients et montré l'accès multithread à la collection. Maintenant compliquons : imaginons une liste de clients consultée par plusieurs threads (un service notifie tous les clients d'une promo, un autre ajoute des clients).
Décrivons d'abord la collection de clients :
// Classe Client — le héros familier des leçons précédentes
public class Client
{
public int Id { get; set; }
public string Name { get; set; }
}
Maintenant créons un wrapper pour l'accès thread-safe via ReaderWriterLockSlim :
using System;
using System.Collections.Generic;
using System.Threading;
public class ClientDirectory
{
private readonly List<Client> _clients = new List<Client>();
// Slim-lock pour lecture/écriture
private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
// Ajout d'un client (write lock)
public void AddClient(Client client)
{
_lock.EnterWriteLock(); // Un seul thread peut ajouter un client
try
{
_clients.Add(client);
Console.WriteLine($"[Thread {Thread.CurrentThread.ManagedThreadId}] Client ajouté {client.Name}");
}
finally
{
_lock.ExitWriteLock();
}
}
// Obtention d'une copie de la liste des clients (read lock)
public List<Client> GetClients()
{
_lock.EnterReadLock(); // Plusieurs threads peuvent lire en même temps
try
{
// On retourne une copie pour éviter qu'on modifie par erreur l'original
return new List<Client>(_clients);
}
finally
{
_lock.ExitReadLock();
}
}
}
4. Lecture multithread et écritures périodiques
Imaginons 5 threads qui lisent périodiquement tous les clients (génèrent des rapports ou vérifient qui est là) et un thread séparé qui ajoute parfois un client.
using System.Threading;
class Program
{
static void Main()
{
var directory = new ClientDirectory();
// Thread pour ajouter des clients
var writerThread = new Thread(() =>
{
for (int i = 1; i <= 5; i++)
{
directory.AddClient(new Client { Id = i, Name = $"Client {i}" });
Thread.Sleep(700); // Simulation d'opérations longues
}
});
// Plusieurs lecteurs
for (int j = 0; j < 5; j++)
{
int readerId = j + 1;
new Thread(() =>
{
for (int k = 0; k < 10; k++)
{
var clients = directory.GetClients();
Console.WriteLine($"[Lecteur {readerId}][Thread {Thread.CurrentThread.ManagedThreadId}] Nombre de clients : {clients.Count}");
Thread.Sleep(200); // Lecture plus fréquente que l'écriture
}
}).Start();
}
writerThread.Start();
writerThread.Join();
// Laisser le temps aux lecteurs de finir :
Thread.Sleep(3000);
}
}
Presque toujours plusieurs threads peuvent lire la liste simultanément (sans attendre les uns les autres), mais quand un thread ajoute un client — les autres doivent attendre la fin de l'écriture. Ça permet de servir beaucoup de lecteurs sans blocages inutiles.
5. Verrou upgradable : UpgradeableReadLock
Parfois un thread lit les données et seulement si une condition est remplie il veut les modifier. On pourrait obtenir un read lock, vérifier, relâcher puis obtenir un write lock. Mais pendant l'intervalle, un autre thread aurait pu changer les données ! L'upgradeable lock aide ici.
Son usage est simple :
public bool AddClientIfNotExists(string name)
{
_lock.EnterUpgradeableReadLock(); // Un seul thread peut détenir ce verrou
try
{
bool exists = _clients.Exists(c => c.Name == name);
if (!exists)
{
_lock.EnterWriteLock(); // On monte en write lock
try
{
_clients.Add(new Client { Name = name });
return true;
}
finally
{
_lock.ExitWriteLock();
}
}
return false;
}
finally
{
_lock.ExitUpgradeableReadLock();
}
}
D'abord le thread lit, et s'il doit modifier — il élève le niveau du verrou sans laisser passer d'autres threads.
6. Petites astuces utiles
Comparaison : ReaderWriterLockSlim vs lock
|
|
|
|---|---|---|
| Lecture simultanée | Non | Oui |
| Écriture simultanée | Non | Non |
| Support du read upgradable | Non | Oui (EnterUpgradeableReadLock) |
| Vitesse | Très rapide | Plus rapide quand les lectures sont fréquentes |
| Mémoire | Minimale | Légèrement plus |
| Simplicité | Facile | Un peu plus complexe, avec des subtilités |
À quoi ça ressemble dans des vrais projets
Dans de grosses applis, où il y a de grandes tables ou caches lus par des centaines de threads mais modifiés rarement, ReaderWriterLockSlim évite les goulets d'étranglement sur les lectures. Par exemple :
- Cache de configuration consulté par des microservices.
- Référentiel (lookup) pour des calculs financiers.
- Cache interne pour le routage de messages.
Je souligne — dans la majorité des cas, surtout si vous débutez en multithreading, le lock classique est plus simple et plus sûr. ReaderWriterLockSlim est pour les cas où il y a vraiment beaucoup de lectures concurrentes.
Visualisation : schéma d'accès
flowchart TD
A[Thread-Lecteur 1] --Read--> D[ReaderWriterLockSlim]
B[Thread-Lecteur 2] --Read--> D
C[Thread-Écrivain] --Write--> D
D --Read autorisé--> E[Lecture de la collection partagée]
D --Write bloque lecture/écriture--> F[Écriture de la collection partagée]
Tant qu'il n'y a que des lectures — l'accès est ouvert à tous ; dès qu'on tente une écriture — tout le monde attend la fin de l'écriture.
7. ReaderWriterLockSlim : subtilités et erreurs typiques
Lock Recursion (récursion de verrou) : Par défaut ReaderWriterLockSlim n'autorise pas qu'un même thread acquière plusieurs fois le même type de verrou. Si un thread a déjà un read lock et tente de le référencer de nouveau — une exception est levée. Même chose pour le write lock. En revanche, l'UpgradeableReadLock peut être monté en write lock depuis l'intérieur.
Ne mélangez pas l'ordre : Appelez EnterUpgradeableReadLock() → à l'intérieur EnterWriteLock(). Mais jamais l'inverse !
Exceptions : Si vous n'êtes pas sûr de relâcher le verrou (par ex. vous oubliez ExitWriteLock()), les autres threads attendront indéfiniment. Utilisez toujours try { ... } finally { ... }.
Ne gardez pas le verrou longtemps : N'effectuez pas d'opérations IO longues à l'intérieur d'un lock. Plus vous gardez la lock, plus vous retardez les autres threads. Sortez du verrou le plus vite possible.
Évaluez la complexité : N'utilisez pas ReaderWriterLockSlim pour protéger des petites opérations — là où un simple lock est plus simple et souvent plus rapide.
GO TO FULL VERSION