1. Perché il normale lock può non bastare?
Pensiamo insieme: abbiamo una risorsa condivisa — per esempio una lista di clienti che teniamo da qualche parte nella nostra applicazione multithread. La maggior parte del tempo leggiamo solo quella lista — per esempio mostriamo i dati all'interfaccia utente, costruiamo report, filtriamo e ordiniamo le informazioni. Solo di rado qualcuno aggiunge un nuovo cliente o ne rimuove uno.
Se usiamo il solito lock, qualsiasi thread che voglia leggere è costretto ad aspettare che un altro thread finisca di leggere o, peggio, scrivere. E se il 99% delle operazioni sono letture? Allora stiamo rallentando inutilmente tutti i lettori, quando potrebbero lavorare in parallelo.
Qui entra in gioco un tipo speciale di lock — il Reader-Writer Lock. L'idea chiave: molti thread possono leggere contemporaneamente, ma se qualcuno vuole modificare, prende accesso esclusivo e blocca i lettori.
2. Teoria: cos'è ReaderWriterLockSlim
Breve sui classi
- ReaderWriterLock — più vecchio, lento, può portare a deadlock. Usalo solo se hai bisogno di supportare .NET Framework 2.0.
- ReaderWriterLockSlim — alternativa moderna e veloce (la parola chiave è Slim: "snello", "alleggerito"). Questa classe è ottimizzata per operazioni di lettura/scrittura concorrenziali frequenti ed è raccomandata in applicazioni con molti thread dove si legge molto più di quanto si scriva.
Principio di funzionamento
ReaderWriterLockSlim implementa tre modalità di lock:
- Read Lock (lock di lettura): molti thread possono leggere contemporaneamente.
- Write Lock (lock di scrittura): solo un thread può modificare i dati, e nessun altro legge.
- Upgradeable Read Lock (lock di lettura aggiornabile): solo un thread può tenere questo lock alla volta; legge ma, se necessario, può "escalare" a write lock.
Schema dei lock
| Thread-Lettore | Thread-Scrittore | Thread con Upgradeable Read Lock | |
|---|---|---|---|
| Lettore | ✅ | ⛔ | ✅/⛔ |
| Scrittore | ⛔ | ⛔ | ⛔ |
| Upgradeable Read | ✅* | ⛔ | ⛔ |
✅ — permesso
⛔ — bloccato
* — solo se l'upgradeable read lock non è stato "escalato" a write
Quando usare (e quando non usare) ReaderWriterLockSlim
Consigliato:
- Molti thread concorrenti che principalmente leggono dati condivisi (dizionari, cache, configurazioni).
- Le scritture avvengono più raramente (per esempio, ogni secondo/minuto/ora).
- È importante massimizzare le prestazioni in lettura senza bloccare i thread.
Da evitare:
- Scritture/letture in proporzioni simili (meglio il solito lock).
- I dati cambiano spesso (il beneficio è minimo).
3. Uso pratico di ReaderWriterLockSlim
Estendiamo la nostra applicazione didattica! In precedenza abbiamo costruito una semplice rubrica clienti e provato l'accesso multithread alla collezione. Ora complichiamo il caso: supponiamo di avere una lista di clienti a cui accedono diversi thread (per esempio, un servizio notifica tutti i clienti per una nuova promozione, mentre un altro thread aggiunge nuovi clienti).
Prima descriviamo la nostra collezione di clienti:
// Classe Client — il solito protagonista dalle lezioni precedenti
public class Client
{
public int Id { get; set; }
public string Name { get; set; }
}
Ora creiamo un wrapper per l'accesso thread-safe usando ReaderWriterLockSlim:
using System;
using System.Collections.Generic;
using System.Threading;
public class ClientDirectory
{
private readonly List<Client> _clients = new List<Client>();
// Slim-lock per lettura/scrittura
private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
// Aggiunta cliente (write lock)
public void AddClient(Client client)
{
_lock.EnterWriteLock(); // Solo un thread può aggiungere un cliente
try
{
_clients.Add(client);
Console.WriteLine($"[Thread {Thread.CurrentThread.ManagedThreadId}] Aggiunto cliente {client.Name}");
}
finally
{
_lock.ExitWriteLock();
}
}
// Ottenere una copia della lista clienti (read lock)
public List<Client> GetClients()
{
_lock.EnterReadLock(); // Molti thread possono leggere contemporaneamente
try
{
// Ritorniamo una copia così qualcuno non prova a modificare per sbaglio l'originale
return new List<Client>(_clients);
}
finally
{
_lock.ExitReadLock();
}
}
}
4. Letture multithread e scritture periodiche
Supponiamo ci siano 5 thread che periodicamente leggono tutti i clienti ("generano report" o semplicemente controllano chi c'è), e un thread separato che a volte aggiunge un nuovo cliente.
using System.Threading;
class Program
{
static void Main()
{
var directory = new ClientDirectory();
// Thread per aggiungere clienti
var writerThread = new Thread(() =>
{
for (int i = 1; i <= 5; i++)
{
directory.AddClient(new Client { Id = i, Name = $"Cliente {i}" });
Thread.Sleep(700); // Simulazione di operazioni lunghe
}
});
// Più lettori
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($"[Lettore {readerId}][Thread {Thread.CurrentThread.ManagedThreadId}] Numero clienti: {clients.Count}");
Thread.Sleep(200); // Legge più spesso di quanto scrive
}
}).Start();
}
writerThread.Start();
writerThread.Join();
// Dai tempo ai lettori di finire il giro:
Thread.Sleep(3000);
}
}
Quasi sempre più thread possono leggere la lista clienti contemporaneamente (senza aspettare l'un l'altro), ma quando un thread aggiunge un cliente — gli altri devono aspettare che la scrittura finisca. Questo permette di servire molti lettori senza blocchi inutili.
5. Lock aggiornabile: UpgradeableReadLock
A volte succede che un thread legga i dati e solo occasionalmente, se una condizione è soddisfatta, voglia modificarli. Potrebbe sembrare sufficiente ottenere un read lock, controllare la condizione, poi rilasciare il read lock e prendere il write lock. Ma tra i due momenti un altro thread potrebbe aver cambiato i dati! Qui entra in gioco il lock aggiornabile.
Usarlo è semplice:
public bool AddClientIfNotExists(string name)
{
_lock.EnterUpgradeableReadLock(); // Solo un thread può tenere questo lock
try
{
bool exists = _clients.Exists(c => c.Name == name);
if (!exists)
{
_lock.EnterWriteLock(); // Escaliamo a write lock
try
{
_clients.Add(new Client { Name = name });
return true;
}
finally
{
_lock.ExitWriteLock();
}
}
return false;
}
finally
{
_lock.ExitUpgradeableReadLock();
}
}
Prima il thread legge, e se deve modificare — esegue l'escalation del lock senza lasciare la lista agli altri thread.
6. Sfumature utili
Confronto: ReaderWriterLockSlim vs lock
|
|
|
|---|---|---|
| Lettura concorrente | No | Sì |
| Scrittura concorrente | No | No |
| Supporto per lettura aggiornabile | No | Sì (EnterUpgradeableReadLock) |
| Velocità | Molto veloce | Piu veloce con letture frequenti |
| Memoria | Minima | Un po' di più |
| Semplicità | Facile | Più complesso, ci sono sottigliezze |
Come appare nei progetti reali
In applicazioni grandi, con tabelle enormi o cache lette da centinaia di thread ma modificate raramente, usare ReaderWriterLockSlim aiuta a evitare i "colli" sulle letture. Per esempio:
- Cache di configurazione usata dai microservizi.
- Archivio di dizionari per calcoli finanziari.
- Cache interna per il routing dei messaggi.
Sottolineo — nella maggior parte dei casi, soprattutto se stai iniziando con la multithreading, il semplice lock è più facile e più sicuro. ReaderWriterLockSlim è una soluzione per scenari con letture concorrenti davvero frequenti.
Visualizzazione: schema di accesso
flowchart TD
A[Thread-Lettore 1] --Read--> D[ReaderWriterLockSlim]
B[Thread-Lettore 2] --Read--> D
C[Thread-Scrittore] --Write--> D
D --Read permesso--> E[Lettura della collezione condivisa]
D --Write blocca lettura/scrittura--> F[Scrittura della collezione condivisa]
Finché ci sono solo letture — l'accesso è aperto a tutti; al tentativo di scrittura — tutti aspettano che la scrittura finisca.
7. ReaderWriterLockSlim: sottigliezze ed errori tipici
Lock Recursion (ricorsione del lock): Di default ReaderWriterLockSlim non permette allo stesso thread di acquisire ripetutamente lo stesso tipo di lock. Quindi, se un thread tiene già un read lock e prova a prenderlo di nuovo — viene lanciata un'eccezione. Lo stesso vale per il write lock. Tuttavia, UpgradeableReadLock può essere escalato a write lock dall'interno.
Non invertire l'ordine: Acquisisci EnterUpgradeableReadLock() → al suo interno EnterWriteLock(). Ma non il contrario!
Eccezioni: Se non rilasci il lock (per esempio non chiami ExitWriteLock()), gli altri thread aspetteranno per sempre. Per questo usa sempre try { ... } finally { ... }.
Non tenere a lungo: Non eseguire operazioni IO lunghe dentro il lock. Più a lungo un thread tiene la lock, più ritardi causerà agli altri. Cerca di uscire dai blocchi lock il prima possibile.
Valuta la complessità: Non usare ReaderWriterLockSlim per proteggere operazioni "piccole" dove il normale lock è più semplice e veloce.
GO TO FULL VERSION