CodeGym /Corsi /C# SELF /Blocchi: lock e la cl...

Blocchi: lock e la classe Monitor

C# SELF
Livello 56, Lezione 1
Disponibile

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à
lock(obj)
Sì Sì No
Monitor
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!

2
Compito
C# SELF, livello 56, lezione 1
Bloccato
Sincronizzazione di base usando `lock`
Sincronizzazione di base usando `lock`
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION