1. Introduzione
Consideriamo una situazione familiare nel lavoro con i thread. Supponiamo di avere un contatore condiviso di successi in una applicazione molto semplice.
int counter = 0;
void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
counter++; // Non atomico!
}
}
// Avviamo due thread:
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Counter: {counter}");
Esegui questo codice più volte. Quasi mai vedrai 200_000! Perché? I due thread si ostacolano costantemente: a volte entrambi leggono la variabile contemporaneamente, la incrementano — e scrivono lo stesso risultato. Alla fine alcuni incrementi "si perdono".
Questo è una race condition. Senza rispettare regole di "coda", i thread letteralmente si contendono i dati.
Sezione critica: cos'è?
Sezione critica — è un pezzo di codice che deve essere eseguito da un solo thread alla volta. Tornando all'analogia della cucina: è come il rubinetto aperto — se due persone cercano di lavarsi allo stesso lavandino, sudore e dentifricio garantiti ovunque. Mettiamola così: in bagno si entra uno alla volta!
Nel nostro esempio la sezione critica è la riga counter++.
2. La parola chiave lock
In C# c'è un modo conciso e sicuro per creare una sezione critica — la parola chiave lock. Nasconde il lavoro complesso con i primitive di sincronizzazione e garantisce che nel blocco protetto entri un solo thread alla volta.
Come usare lock
Sintassi:
lock (lockerObject)
{
// Codice che può eseguire solo un thread alla volta
}
lockerObject — è qualsiasi oggetto che esiste per tutta la vita del programma. Di solito si fa così:
private static object locker = new object();
Nota: non usare mai per questo scopo stringhe, numeri o oggetti che qualcun altro potrebbe raggiungere accidentalmente! Solo oggetti privati che sei sicuro non vengano usati altrove.
Correggiamo il nostro esempio
private static object locker = new object();
int counter = 0;
void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
lock (locker)
{
counter++; // Ora è atomico!
}
}
}
Ora due o dieci thread entreranno a turno in quel pezzo di codice. Il risultato in uscita sarà il perfetto 200_000. I gattini sono felici!
3. Come funziona lock dentro? La classe Monitor
Dentro la parola chiave lock lavora con la classe System.Threading.Monitor. È una specie di segretario che ammette solo con il pass speciale.
Sintassi equivalente a lock (ma più "spoglia"):
Monitor.Enter(locker);
try
{
// Sezione critica
}
finally
{
Monitor.Exit(locker);
}
La differenza chiave — sei obbligato a garantire che Monitor.Exit venga chiamato. Di solito per questo si usa try...finally. Se dimentichi Exit(), il thread rimane "dentro" per sempre, e gli altri thread aspetteranno per sempre — il programma si bloccherà come un vecchio Windows durante un'installazione di aggiornamenti.
Tabella: lock vs. manuale Monitor
| Metodo | Sicurezza dagli errori | Più semplice da scrivere | Flessibilità |
|---|---|---|---|
|
Sì | Sì | No |
|
Solo con try/finally | No | Sì |
Nel 99% dei casi usa lock. Il Monitor manuale serve solo se ti serve massima flessibilità: per esempio se vuoi implementare una lock con timeout.
4. Argomenti per lock: cosa si può e cosa no?
Errore molto comune dei principianti: usare per il lock una stringa o un altro oggetto "visibile". Per esempio:
lock ("mylock") { /*...*/ } // Molto male!
Il problema è che le stringhe sono interned (uniche per tutta l'applicazione), è facile avere conflitti con librerie di terze parti e alla fine la tua app si blocca. Usa sempre oggetti privati:
private readonly object myLock = new object();
lock (myLock)
{
// solo il tuo codice conosce myLock
}
5. lock: esempio con output su console
Alleniamoci! Creiamo una mini-app dove due thread stampano righe, ma l'accesso alla console è sincronizzato — così il testo non si mescola.
private static object consoleLock = new object();
void PrintMessages(string name)
{
for (int i = 0; i < 5; i++)
{
lock (consoleLock)
{
Console.WriteLine($"{name}: Messaggio {i + 1}");
Thread.Sleep(50); // Simuliamo un'elaborazione
}
}
}
Thread t1 = new Thread(() => PrintMessages("Thread 1"));
Thread t2 = new Thread(() => PrintMessages("Thread 2"));
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Risultato: le righe appaiono ordinate una dopo l'altra, niente confusione. Questo approccio è spesso usato per il logging, così non trovi "mozziconi" nei log.
6. Sfumature utili
Gestione manuale del lock: Monitor avanzato
Quando il solito lock non basta (per esempio se vuoi provare ad entrare nella sezione senza aspettare per sempre), puoi usare il metodo Monitor.TryEnter.
if (Monitor.TryEnter(locker, 100)) // 100 ms di attesa
{
try
{
// Sezione critica
}
finally
{
Monitor.Exit(locker);
}
}
else
{
Console.WriteLine("Non è stato possibile ottenere il lock in 100 millisecondi");
}
È comodo se la tua applicazione non vuole "impallarsi" — per esempio puoi mostrare un messaggio all'utente o fare qualcosa di utile mentre la risorsa condivisa è occupata.
Visualizzazione: come funziona il lock (schema)
flowchart LR
A[Thread 1: vuole entrare nella sezione critica]
B[Thread 2: vuole entrare nella sezione critica]
C[locker libero]
D[Thread 1 esegue il codice dentro il lock]
E[Thread 2 aspetta]
F[Thread 1 è uscito dal lock]
G[Thread 2 ottiene l'accesso]
A -- Controlla locker --> C
C -- locker libero --> D
B -- Controlla locker --> D
D -- lock occupato --> E
D -- Ha finito --> F
F -- Locker liberato --> G
E -- locker ora libero --> G
Lock e performance
I lock funzionano semplicemente: solo un thread alla volta può eseguire il pezzo di codice tra le graffe. Questo è ottimo per l'integrità dei dati, ma... più thread stanno "in coda", più tutto rallenta. Quindi la sincronizzazione non è una panacea: cerca di avere sezioni critiche il più piccole possibile.
Lifehack: se la sezione critica dura frazioni di millisecondo — ottimo. Se invece contiene calcoli lunghi, IO, rete o lavoro su file — meglio separare queste operazioni fuori dal blocco lock. Prima leggi/calcola, poi rapidamente aggiorna il valore condiviso dentro la protezione.
Al colloquio e nella vita reale
In qualunque programma serio che usa thread, i datori di lavoro chiederanno sicuramente: "Cosa fare se due thread accedono alla stessa variabile?" Mostri del codice con un lock — e il tuo CV non finirà nel buco nero dell'ATS.
In pratica, specialmente nei sistemi ad alto carico, si usano meccanismi di sincronizzazione più avanzati — ma lock e Monitor restano lo standard d'oro per i casi semplici.
7. Peculiarità d'uso dei lock e errori tipici
L'errore più comune — "dimenticare" di usare lo stesso oggetto come lock. Per esempio:
void Foo() { lock (a) { ... } }
void Bar() { lock (b) { ... } }
Se in entrambi i metodi si gestisce la stessa variabile, ma gli oggetti a e b sono diversi, hai creato una protezione fittizia — i thread lavoreranno contemporaneamente sulla variabile!
Conclusione: usa sempre lo stesso oggetto per proteggere gli stessi dati.
Un altro caso — usare una lock troppo "larga". Per esempio fare lock (this) dentro una classe non sigillata, se non sei sicuro che nessuno esterno userà quell'oggetto per lock. Questo può portare a deadlock e altri bug divertenti ma indesiderati.
Infine: NON bloccare operazioni lunghe o esterne (file, rete) dentro un lock. Rischi di negare l'accesso agli altri thread per troppo tempo, peggiorando le prestazioni. Sezione critica = solo ciò che davvero non si può fare in parallelo!
GO TO FULL VERSION