1. Che cos'è il deadlock e perché sembra che il programma «si sia bloccato»
Quando incontrate un deadlock per la prima volta, la sensazione è di solito: «Ecco, il mio codice si è messo a riflettere sulla vita». E la cosa più fastidiosa è che può riflettere all'infinito, senza terminare con un errore e senza stampare nulla di utile. Il deadlock è come una sala d'attesa in ambulatorio, dove due medici sono andati a prendere il caffè perché ciascuno aspetta che l'altro restituisca la cartella del paziente.
Deadlock (stallo mutuo) — una condizione in cui due o più thread si aspettano indefinitamente a vicenda, perché ciascuno detiene una risorsa (di solito un mutex) e prova ad acquisirne un'altra che è trattenuta da un altro thread. A differenza dei «calcoli lunghi», il deadlock non termina mai (a meno che non uccidiate il processo).
I sintomi tipici in pratica sono: il programma rimane stabilmente «congelato» nello stesso punto, l'utilizzo della CPU può essere basso, l'output sulla console cessa, e le chiamate a join() sui thread nel main non ritornano mai. E qui sta l'insidia: a volte succede una volta ogni mille esecuzioni, perché dipende dallo scheduler del SO e dai timing.
Per proseguire ricorda: il deadlock è quasi sempre un errore nel protocollo di acquisizione delle lock, non una questione di «sfortuna».
2. Il deadlock classico: due mutex e ordini di acquisizione diversi
Cominciamo con un esempio minimo per vedere il problema. È volutamente un po' «accademico» con sleep_for per aumentare la probabilità di cogliere lo stallo. Nella vita reale al posto di sleep_for ci sarà qualsiasi rallentamento inatteso: logging, I/O, accesso al disco, allocazione di memoria, o anche uno switch di contesto nel momento meno adatto.
#include <chrono>
#include <mutex>
#include <thread>
std::mutex mA;
std::mutex mB;
void f1() {
std::lock_guard<std::mutex> lockA(mA);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard<std::mutex> lockB(mB);
}
void f2() {
std::lock_guard<std::mutex> lockB(mB);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard<std::mutex> lockA(mA);
}
Se lo lanciate da main su due thread, a volte si ottiene la situazione: il thread t1 ha preso mA e aspetta mB, mentre il thread t2 ha preso mB e aspetta mA. Nessuno può procedere. Stiamo educatamente fermi tenendoci la porta a vicenda.
#include <thread>
int main() {
std::thread t1(f1);
std::thread t2(f2);
t1.join();
t2.join(); // a volte rimane bloccato per sempre
}
È importante capire la vera causa: non «i mutex sono cattivi», ma che li abbiamo presi in ordini diversi. In un punto «prima A poi B», in un altro «prima B poi A».
3. Deadlock come errore di protocollo di acquisizione
Ora un punto cruciale: il deadlock di solito non si risolve con «aggiungiamo un altro mutex» (è come spegnere un incendio con la benzina, sembra efficace ma non lo è). Si risolve stabilendo un protocollo chiaro su come vengono prese le lock nel progetto, e facendo in modo che sia difficile violarlo.
Descrivendo il deadlock in modo semplice, esso si verifica quando si verificano contemporaneamente quattro condizioni: le risorse sono esclusive (un mutex è detenuto da un solo thread), un thread detiene una risorsa e chiede la seconda, non è possibile sottrarre la lock al thread, e si forma un ciclo di attesa. Nei nostri esempi il ciclo è elementare: t1 aspetta mB, t2 aspetta mA. Il cerchio è chiuso.
Per questo dico: la regola più efficace per prevenire è non «pregare», ma spezzare la possibilità di formare un ciclo di attesa. Praticamente si fa o imponendo un ordine unico di acquisizione (lock ordering), o acquisendo più mutex in un'unica operazione.
Tra l'altro, anche nella documentazione della standard library ci sono avvisi e note riguardo al deadlock avoidance — il problema è così comune che viene sottolineato a livello di specifiche.
4. Regola №1: ordine unico di acquisizione delle lock
Introduciamo ora la regola che ha salvato più progetti di quanto non abbia fatto «riscrivere tutto su un singolo thread». È banale ma funziona quasi sempre: se dovete acquisire più mutex, fatelo sempre nello stesso ordine in tutto il progetto.
L'ordine può essere qualsiasi, l'importante è che sia unico e documentato. Per esempio:
- «Prima il mutex del database, poi il mutex del logger».
- «Prima l'id minore, poi quello maggiore».
- «Prima Bank::m_, poi Logger::m_».
Schema di deadlock — ciò che non vogliamo:
flowchart LR
T1["Thread 1 tiene mA"] -->|aspetta| mB["mB"]
T2["Thread 2 tiene mB"] -->|aspetta| mA["mA"]
Schema «ordine unico» — ciò che vogliamo:
flowchart TD
R["Regola: sempre acquisire mA → poi mB"] --> OK["Non si forma il ciclo di attesa"]
Mostriamo con lo stesso esempio minimo: se sia f1 che f2 acquisiscono prima mA e poi mB, il deadlock per ordine scompare.
void f2_fixed() {
std::lock_guard<std::mutex> lockA(mA);
std::lock_guard<std::mutex> lockB(mB);
}
Notate: non abbiamo reso il codice «più veloce». L'abbiamo reso impossibile da rompere in questo modo.
5. Regola №2: acquisire più mutex con un'unica operazione
Situazione molto comune nel codice reale: un'operazione è logicamente atomica, ma i dati sono protetti da mutex diversi. È facile sbagliare e acquisirli in ordine diverso in punti diversi. Quindi è estremamente utile l'abitudine di acquisire più mutex con una sola costruzione, per esempio con std::scoped_lock.
L'idea di std::scoped_lock: «voglio prendere più mutex contemporaneamente e tenerli fino alla fine dello scope». Scrivete una singola espressione, rendendo così esplicita l'intenzione: «mi servono entrambe le risorse insieme».
#include <mutex>
extern std::mutex mA;
extern std::mutex mB;
void safe() {
std::scoped_lock lock(mA, mB);
// lavoro con due risorse
}
Perché è meglio di due lock_guard consecutivi? Perché i «due lock_guard» sembrano innocui ma possono essere facilmente invertiti in un'altra parte del codice. scoped_lock(mA, mB) invece fissa visivamente «questa coppia» e riduce la probabilità che qualcuno (anche voi dopo una settimana) prenda mB prima di mA.
Pensiero ingegneristico importante: non stiamo solo scrivendo «codice corretto», stiamo scrivendo codice che è più difficile da rovinare con una modifica accidentale.
6. Esempio: mini-banca e trasferimento tra conti
Assembliano ora una piccola storia didattica. Immaginiamo di scrivere un'applicazione console “MiniBank”: ci sono conti, saldo, trasferimenti. Tutto semplice, ma vogliamo eseguire trasferimenti da thread diversi (per esempio per simulare operazioni di clienti).
Iniziamo dal modello del conto:
#include <mutex>
struct Account {
int id = 0;
int balance = 0;
mutable std::mutex m;
};
Un trasferimento ingenuo sembra sensato: blocchi from, poi blocchi to, poi esegui l'operazione.
void transfer_bad(Account& from, Account& to, int amount) {
std::lock_guard<std::mutex> lockFrom(from.m);
std::lock_guard<std::mutex> lockTo(to.m);
from.balance -= amount;
to.balance += amount;
}
Ed ecco la trappola: se in un altro thread avviene un trasferimento nella direzione opposta (to → from), l'ordine di acquisizione sarà speculare. Talvolta i due thread «si incontrano» e restano bloccati.
Per simulare la «collisione» dei thread aggiungiamo una piccola pausa. (Nella realtà al suo posto può esserci qualsiasi cosa, per esempio il logging.)
#include <chrono>
#include <thread>
void transfer_bad(Account& from, Account& to, int amount) {
std::lock_guard<std::mutex> lockFrom(from.m);
std::this_thread::sleep_for(std::chrono::milliseconds(1));
std::lock_guard<std::mutex> lockTo(to.m);
from.balance -= amount;
to.balance += amount;
}
Uso:
#include <thread>
int main() {
Account a{.id = 1, .balance = 1000};
Account b{.id = 2, .balance = 1000};
std::thread t1([&]{ transfer_bad(a, b, 10); });
std::thread t2([&]{ transfer_bad(b, a, 20); });
t1.join();
t2.join(); // a volte rimane bloccato
}
Correzione: std::scoped_lock sui due mutex
La riparazione più diretta è acquisire entrambi i mutex con una sola costruzione:
void transfer_scoped(Account& from, Account& to, int amount) {
std::scoped_lock lock(from.m, to.m);
from.balance -= amount;
to.balance += amount;
}
Correzione: ordine basato su id
Talvolta volete fissare una regola esplicita: «l'id più piccolo si prende sempre per primo». Così il codice è identico indipendentemente dalla direzione del trasferimento.
Qui sotto c'è un esempio di cattiva idea (logicamente sbagliata, ma sintatticamente valida): confrontare gli indirizzi dei campi, invece degli id stessi. È facile scrivere questo «per errore», compila, ma non rappresenta l'ordine desiderato e può comportarsi in modo imprevedibile.
void transfer_ordered_bad(Account& x, Account& y, int amount) {
Account* first = (&x.id < &y.id) ? &x : &y; // non fare così
Account* second = (first == &x) ? &y : &x;
std::scoped_lock lock(first->m, second->m);
x.balance -= amount;
y.balance += amount;
}
È meglio confrontare direttamente lo id, non gli indirizzi di «qualcosa dentro». Facciamo la versione corretta e concisa:
void transfer_ordered(Account& from, Account& to, int amount) {
Account& first = (from.id < to.id) ? from : to;
Account& second = (from.id < to.id) ? to : from;
std::scoped_lock lock(first.m, second.m);
from.balance -= amount;
to.balance += amount;
}
Notate l'effetto piacevole: non è obbligatorio usare scoped_lock, si potrebbe fare anche con due lock_guard consecutivi. Ma qui scoped_lock aggiunge un ulteriore livello di sicurezza: anche se in futuro i due mutex dovessero comparire «in ordine diverso», l'intenzione «prendo entrambi» è espressa da un unico oggetto.
7. Deadlock causati da azioni collaterali sotto lock
Molti deadlock non nascono dove avete esplicitamente scritto «prendo due mutex». Nascono quando pensate: «Ora faccio rapidamente un log». E così entra in scena un secondo mutex — il mutex del logger — e inconsapevolmente iniziate a costruire una catena di lock.
Facciamo un logger thread-safe molto semplice:
#include <iostream>
#include <mutex>
#include <string>
class Logger {
public:
void log(const std::string& s) {
std::lock_guard<std::mutex> lock(m_);
std::cout << s << '\n';
}
private:
std::mutex m_;
};
Immaginiamo che in Bank ci sia un mutex per i dati, e che dentro un'operazione bancaria chiamiamo il logger.
#include <mutex>
class Bank {
public:
Bank(Logger& logger) : logger_(logger) {}
void deposit(Account& a, int amount) {
std::lock_guard<std::mutex> lock(m_);
a.balance += amount;
logger_.log("deposit"); // logging sotto lock
}
private:
std::mutex m_;
Logger& logger_;
};
A prima vista tutto sembra a posto. Ma il problema nasce quando in un altro punto si fa il contrario: prima si logga, poi si entra nella banca. Per esempio, la «stampa del report»:
void print_report(Bank& bank, Logger& logger) {
logger.log("report"); // logger è lockato dentro log()
// ... e poi qualcosa che porta ad acquisire bank.m_ (per esempio, bank.total())
}
Se durante la «stampa del report» chiamate un metodo della banca che acquisisce Bank::m_, potete ottenere un potenziale ciclo: un thread detiene Bank::m_ e aspetta Logger::m_, un altro detiene Logger::m_ e aspetta Bank::m_.
Come si risolve in modo maturo? Ci sono due vie, entrambe disciplinari.
Prima — ordine unico: per esempio, «sempre prima Bank::m_, poi Logger::m_». Allora print_report dovrebbe evitare di loggare prima di acquisire lo stato bancario (o loggare senza mutex — ma questo è un altro discorso).
Seconda — ridurre il tempo sotto lock e usare “snapshot dello stato”: sotto il mutex bancario preparate la stringa o i dati per il report, rilasciate il mutex, e solo dopo stampate/loggate.
Ecco un esempio di «snapshot» (l'idea è più importante del contenuto):
#include <string>
std::string make_report_line(const Account& a) {
return "id=" + std::to_string(a.id) + " balance=" + std::to_string(a.balance);
}
Ora l'output fatto con cura: una sezione critica breve solo per leggere i dati, poi l'output senza tenere il mutex della banca.
void print_account(Logger& logger, const Account& a) {
std::string line;
{
std::lock_guard<std::mutex> lock(a.m);
line = make_report_line(a); // leggiamo sotto lock
}
logger.log(line); // stampiamo senza lock sul conto
}
Il senso è: non manteniamo subito due mutex «per sicurezza». Meno sovrapposizioni di lock significano meno probabilità di formare cicli di attesa.
8. Regole pratiche per prevenire i deadlock
È utile raccogliere ora un «kit di buone abitudini». Non sono incantesimi, ma soluzioni ingegneristiche per casi diversi. La tabella sottostante non indica l'unica via corretta, ma quale strumento meglio esprime l'intenzione.
| Situazione | Migliore regola | Perché |
|---|---|---|
| Serve acquisire più mutex per una singola operazione | |
Un'unica espressione, meno rischio di errore e più leggibilità |
| Esiste un «ordine naturale» degli oggetti (id, indice, nome) | lock ordering basato su questa chiave | Facile da spiegare e verificare: «id minore prima» |
| Sotto lock c'è lavoro pesante (I/O, calcoli, cicli lunghi) | «snapshot sotto lock → lavoro senza lock» | Riduce il tempo di detenzione e diminuisce le sovrapposizioni di lock |
| Sotto lock vengono chiamate funzioni «esterne» (logger, callback, codice utente) | non chiamarle sotto lock o fissare un ordine chiaro | Una «funzione esterna» può acquisire un altro mutex e rompere il protocollo |
9. Come distinguere un deadlock da un semplice rallentamento
Quando un programma «si blocca», il principiante inizia a sospettare di tutto: da «cout si è rotto» a «il computer è stanco». È importante saper distinguere il deadlock da un ritardo ordinario.
Il deadlock in genere si manifesta così: il processo è vivo ma non succede nulla; se avete stampe di debug «all'inizio della funzione» e «alla fine della funzione», vedrete la stampa iniziale ma mai quella finale. Se conoscete il debugger, la tipica situazione è: un thread è fermo cercando di acquisire un mutex, un altro sta cercando di acquisire un altro mutex. Ciascuno aspetta l'ingresso nella sezione critica, che non avverrà mai.
Una buona abitudine pratica: se scrivete codice con più mutex, considerate il deadlock possibile «per default», finché non dimostrate il contrario con un protocollo. Non è paranoia — è rispetto della realtà.
10. Errori tipici nella protezione dal deadlock
Errore №1: «Prendo due mutex, ma l'ordine non è importante».
In pratica l'ordine è sempre importante, quando nel progetto esiste più di un punto che può incrociare quei mutex. Oggi prendete A → B, domani il collega (o voi stessi dopo una settimana) prenderete B → A, e otterrete un blocco che è impossibile «indovinare» dalla logica perché dipende dai timing.
Errore №2: «Metto mutex ovunque per essere sicuro».
Se iniziate a tenere due-tre mutex per metà funzione «per tranquillità», aumentate il numero di sovrapposizioni e la probabilità di cicli di attesa. Nella concorrenza la tranquillità spesso viene da sezioni critiche brevi e da un protocollo chiaro, non dal numero di lock.
Errore №3: «Sotto lock posso chiamare qualsiasi funzione, è solo una chiamata».
Chiamare una funzione «esterna» sotto lock è una delle fonti più pericolose di deadlock. «Esterna» non significa solo una libreria di terze parti, ma anche il vostro logger, il vostro print_report, o un vostro piccolo helper. Al suo interno potrebbe acquisire un altro mutex — e avrete costruito il ciclo senza accorgervene.
Errore №4: «Logging sotto mutex è sicuro, è utile».
Il logging è utile, ma quasi sempre usa una risorsa condivisa (console, file, buffer) e quindi è sincronizzato. Se il logger ha il suo mutex, avete automaticamente una seconda risorsa che può incrociarsi con altre. Buona pratica: costruire il messaggio sotto lock e scriverlo senza lock (o fissare rigidamente l'ordine «prima i dati, poi il log»).
Errore №5: «È successo un deadlock — aggiungo un altro mutex per coordinare».
Un mutex addizionale raramente sistema il protocollo; spesso aggiunge un altro vertice nel grafo di attesa. La riparazione inizia solitamente elencando quali mutex esistono, quali coppie vengono prese insieme e in che ordine. A quel punto si vede dove l'ordine è violato — e la correzione spesso è sorprendentemente piccola.
GO TO FULL VERSION