Best practice pratiche sul multithreading: edizione C++ — eliminare gli incidenti strutturalmente con RAII e jthread
· Go Komura · Windows, Multithreading, C++, Visual Studio, Applicazioni aziendali, Indagine sui bug, Progettazione
«Un progetto che in C# andava bene ha iniziato a crashare di tanto in tanto una volta portato in C++.» «Abbiamo usato std::thread e, quando è stata lanciata un’eccezione, l’intera applicazione è morta all’istante via terminate.» «Fermavamo le cose con un flag volatile bool, ma solo in build release non si fermava.» — Il multithreading in C++ porta un pericolo che i linguaggi gestiti semplicemente non hanno: una data race è, di per sé, comportamento indefinito (UB). Non è solo che potreste leggere un valore corrotto; le assunzioni di ottimizzazione del compilatore crollano, e finite in uno stato in cui può succedere letteralmente qualsiasi cosa.
Questo articolo è l’edizione C++ della nostra serie pratica sul multithreading. Rivolto a chi scrive applicazioni aziendali, software di controllo macchine e DLL in C++ moderno (C++17/20), traduce i principi generali della progettazione multithread — non aggiungere thread direttamente, ridurre lo stato mutabile condiviso, applicare la disciplina dei lock, progettare come si ferma prima di ogni altra cosa — negli strumenti di C++ e Windows, insieme alle trappole specifiche di C++, tutto ancorato a fonti primarie aggiornate ad agosto 2026. È scritto per stare in piedi da solo. Gli stessi principi elaborati per altri linguaggi sono anche nell’«edizione .NET», nell’«edizione C» e nell’«edizione Java».
1. Prima il sodo
- In C++ una data race non è «potreste leggere un valore corrotto»: è comportamento indefinito. Non lasciare nemmeno un accesso mutabile condiviso non sincronizzato in tutto il codice è un requisito assoluto, più che in altri linguaggi.1
- Non usate
std::threadnudo. Se il distruttore di unstd::threadgira mentre il thread è ancora joinable,std::terminateuccide il processo all’istante. Ilstd::jthreaddi C++20 fa join automaticamente nel distruttore e ha un meccanismo di richiesta di stop (stop_token) incorporato.23 - Tenete sempre i lock tramite RAII. Smettete di scrivere a mano
mtx.lock(); usatelock_guard/scoped_lock. Il distruttore rilascia il lock in modo affidabile anche se viene lanciata un’eccezione. Quando si acquisiscono più lock insieme,scoped_lockse ne occupa con un algoritmo di evitamento dei deadlock.4 volatilenon è uno strumento di sincronizzazione. Usatestd::atomicper flag e contatori condivisi, estd::mutexper proteggere insieme più variabili.std::atomicfornisce sia atomicità sia ordinamento basato sumemory_order.5- Fate l’attesa di rendezvous con la forma a predicato di
waitdicondition_variable. Le condition variable sono soggette a spurious wakeup (risveglio senza notifica), quindi chiamarewaitsenza predicato è un terreno fertile per i bug.6 jthread+stop_token(C++20) è la forma di base per come fermare un thread. Negli ambienti precedenti costruite a mano uno stop cooperativo constd::atomic<bool>più una condition variable. Trattate la terminazione forzata di un thread come qualcosa che nel mondo C++ semplicemente non esiste.3- Sappiate che il distruttore di un
futurepuò bloccarsi prima di usarestd::async. Scartate il valore di ritorno e ottenete lo stesso effetto di un’esecuzione seriale.7 - Gli oggetti di sincronizzazione Win32 meritano il loro posto solo per «lavorare con le API di attesa Win32» e per gli scenari «interprocesso». Ovunque altrove, scrivere contro la libreria standard è la scelta migliore per portabilità e manutenibilità.8
2. Perché il multithreading è difficile — race condition, deadlock e comportamento indefinito
Ridotti all’osso, i problemi che il multithreading introduce sono di due tipi, indipendentemente dal linguaggio.
Una race condition è un bug in cui l’esito dipende dall’ordine in cui più thread raggiungono un pezzo di codice. L’esempio classico è un contatore condiviso: la singola espressione ++count si spezza a livello di codice macchina in tre passi — lettura, addizione, riscrittura. Se due thread entrano in quei tre passi nello stesso momento, l’addizione di un thread viene sovrascritta e persa dalla riscrittura dell’altro. Il risultato cambia da un’esecuzione all’altra, e quale risultato otterrete è imprevedibile.
sequenceDiagram
participant A as Thread A
participant M as Variabile condivisa count
participant B as Thread B
Note over M: count = 10
A->>M: Lettura (10)
B->>M: Lettura (10)
A->>A: Addizione locale (11)
B->>B: Addizione locale (11)
A->>M: Riscrittura (11)
B->>M: Riscrittura (11)
Note over M: Due incrementi sono avvenuti,<br/>eppure count = 11 — l'addizione del Thread A è andata persa
Figura 1: Una race condition classica in cui un incremento su un contatore condiviso viene perso. Se un altro thread si intreccia durante i tre passi di ++count, la riscrittura che avviene per ultima sovrascrive l’altra
Un deadlock è uno stato in cui due thread aspettano ciascuno un lock tenuto dall’altro, così nessuno dei due può procedere. Il thread A tiene il lock 1 e aspetta il lock 2; il thread B tiene il lock 2 e aspetta il lock 1 — questo da solo basta perché entrambi si fermino per sempre.
flowchart LR
A["Thread A<br/>tiene il lock 1"] -->|"in attesa di acquisire il lock 2"| B["Thread B<br/>tiene il lock 2"]
B -->|"in attesa di acquisire il lock 1"| A
Figura 2: L’attesa circolare di un deadlock. Nel momento in cui le frecce di attesa formano un anello, ogni thread in quell’anello si ferma per sempre
Ciò che rende scomodi entrambi è che dipendono dal timing. Un intreccio che su una macchina di sviluppo colpisce una volta su decine di migliaia di esecuzioni può accadere ogni singolo giorno sulla macchina del cliente, con un diverso numero di core e un timing diverso. «Non si riproduce con il debugger attaccato» e «è sparito quando ho aggiunto il logging» succedono entrambi perché l’osservazione stessa cambia il timing — è il comportamento classico dei bug da race. È esattamente per questo che ogni principio di questo articolo punta in una direzione: ridurre i posti che hanno bisogno di sincronizzazione, prima di preoccuparsi di sincronizzarli correttamente.
2.1. In C++ una data race è direttamente comportamento indefinito
In più, C++ ha un ulteriore strato che gli altri linguaggi non hanno. Secondo lo standard C++, se più thread accedono alla stessa locazione di memoria senza sincronizzazione e almeno uno di essi scrive, quella è una data race, ed è comportamento indefinito. Il capitolo sulla concorrenza delle C++ Core Guidelines (CP.2, «Avoid data races») lo enuncia come la primissima regola assoluta.1 Il comportamento indefinito non è la storia mite di «potreste leggere il valore vecchio o quello nuovo». Il compilatore ottimizza sul presupposto che non esista alcuna data race, quindi comportamenti che dal codice sorgente non potreste mai predire — un controllo di condizione che sparisce da un ciclo, scritture riordinate o fuse — si verificano legittimamente. L’incidente classico in cui «un flag di stop volatile bool fallisce solo in build release» è un caso da manuale proprio di questo.
2.2. RAII è il fondamento
Un’altra premessa specifica di C++ sono le eccezioni e la gestione delle risorse. C++ non ha finally; al suo posto ha RAII (rilascio automatico tramite i distruttori), e gli strumenti del multithreading sono progettati nell’assunto che lo userete. «Gestire i lock attraverso la vita di un oggetto»; «garantire il join del thread attraverso la vita di un oggetto, anche» — aderire a quella convenzione è il fondamento per scrivere C++ multithread in modo sicuro.
3. Come avviare un thread — la trappola di thread, e jthread
3.1. Il distruttore di std::thread è «progettato per provocare incidenti»
std::thread ha una trappola nota. Se il suo distruttore gira mentre il thread è ancora joinable (né joined né detached), viene chiamato std::terminate e il processo muore all’istante.9
void process()
{
std::thread worker([]{ HeavyWork(); });
DoSomething(); // ← se qui viene lanciata un'eccezione...
worker.join(); // ← join non viene mai raggiunto; il distruttore di worker chiama terminate
}
Rendere questo exception-safe richiedeva di garantire il join con try/catch — uno stato distorto in un linguaggio RAII in cui i soli thread andavano gestiti a mano. Il std::jthread di C++20 lo risolve. Poiché il suo distruttore emette automaticamente una richiesta di stop e poi fa join, il codice sopra diventa exception-safe semplicemente passando a std::jthread.2 Su MSVC, <stop_token> e jthread sono disponibili da Visual Studio 2019 16.9 in poi.3
flowchart TB
T["Il thread è stato avviato"] --> Q{"Cosa succede<br/>quando lo scope esce?"}
Q -->|"std::thread<br/>né joined né detached"| X["std::terminate<br/>il processo muore all'istante"]
Q -->|"std::thread<br/>già joined"| OK1["Join in sicurezza"]
Q -->|"std::jthread - C++20"| OK2["request_stop + join automatici<br/>sicuro anche se viene lanciata un'eccezione"]
Figura 3: La vita di un oggetto thread e come termina. std::thread è specificato per morire all’istante se dimenticate il join, quindi da C++20 in poi fate di jthread il default
Di regola, non usate detach(). Un thread che ha perso ogni mezzo di join diventa una causa classica di crash in chiusura, in corsa contro la distruzione delle variabili statiche e dell’heap all’uscita del processo.
3.2. Strumenti «sopra il livello del thread» — async, future e algoritmi paralleli
Il principio dell’edizione .NET «non create i thread da soli» si mappa in C++ sui seguenti strumenti.
std::async+std::future: per un task asincrono una tantum e per riceverne il risultato. C’è però una peculiarità importante: ilfuture(o l’ultimoshared_future) legato a un task lanciato viastd::asyncsi blocca fino al completamento se il suo distruttore gira mentre il task è ancora incompleto.7 Per il lavoro effettivamente lanciato constd::launch::async, scartare il future restituito equivale a un’esecuzione sincrona in quel punto. Peggio: se non specificate una policy di lancio, l’implementazione è libera di sceglieredeferred(esecuzione pigra) per default, nel qual caso, se nessuno chiamaget()/wait(), il lavoro non viene eseguito affatto e sparisce in silenzio. Se volete garantire l’esecuzione concorrente, specificatestd::launch::asyncesplicitamente, e fate gestire al proprietario la vita del future.- PPL - Parallel Patterns Library -
concurrency::parallel_for/parallel_for_each: applicare lavoro in parallelo su ogni elemento di una collezione. Tuttavia, se il lavoro in una singola iterazione è troppo piccolo, l’overhead di fork/join mangia i guadagni, quindi di regola parallelizzate sul ciclo esterno.10 - Algoritmi paralleli di C++17 -
std::execution::par: su MSVC i principali algoritmi sono parallelizzati (non tutti).11 Notate che se un’eccezione esce dall’elaborazione di un elemento sotto una policy di esecuzione, viene chiamatostd::terminate. Porre un vostro confine di eccezione (try/catch) dentro la callback segue lo stesso ragionamento del confine del thread nella sezione 6.
La linea tracciata altrove — «aspettare I/O non è qualcosa che si risolve aggiungendo thread» — vale ancora senza cambiamenti. Per codice Windows nativo, I/O OVERLAPPED e IOCP sono gli strumenti che raccolgono quel lavoro (per i meccanismi, si veda «Le profondità dell’I/O di Windows, parte 2»).
4. Ridurre lo stato mutabile condiviso — suddividere, passare per valore, const e code
La contesa nasce solo quando sono presenti sia «più thread» sia «dati mutabili condivisi». Il numero di thread è dettato dai requisiti, quindi ciò che il progetto può tagliare è la condivisione. I mezzi cadono in tre famiglie — suddividere, rendere immutabile e consegnare i dati — ed ecco come si scrive ciascuno in C++.
Suddividere. In un lavoro come un’aggregazione parallela, invece di far scrivere a ogni thread in un totale condiviso, date a ogni thread il proprio subtotale locale e fonderli una volta sola, alla fine. Le scritture sul valore condiviso scendono da «ogni iterazione» a «una volta per thread», tagliando di ordini di grandezza sia il costo di sincronizzazione sia la finestra di contesa. Quell’unico passo di fusione si può fare con un std::mutex o con un fetch_add su un std::atomic — entrambi vanno bene.
Passare per valore. Se consegnate al thread i dati di cui ha bisogno per copia (o move) all’avvio, quei dati diventano esclusivamente del thread, e non serve sincronizzazione. Catturare le lambda per riferimento ([&]) e poi toccare una variabile la cui vita è finita è un incidente comune, quindi le lambda passate ai thread dovrebbero usare catture esplicite, per copia o move di regola. Detto ciò, «copiato, quindi esclusivo» vale solo quando il valore è un grafo di valori profondi che non contiene alias come puntatori o shared_ptr. Copiare una struct che contiene un puntatore grezzo lascia comunque condiviso ciò a cui punta.
Condividere come const. I dati che vengono solo letti sono sicuri da leggere da un numero qualsiasi di thread insieme. Valori di configurazione, dati anagrafici, input di calcolo e simili si possono condividere senza sincronizzazione se ne fate una condivisione const che non viene riscritta dopo la costruzione (std::shared_ptr<const Config>, ad esempio). Un avvertimento: ciò che shared_ptr<const T> vieta è solo la mutazione attraverso quella particolare handle. Se da qualche altra parte sopravvive un alias non-const, o un membro mutable viene riscritto, la contesa resta — quindi progettate anche per quello, fino a «una volta finita la costruzione, rilasciare il riferimento non-const e non far scrivere più a nessuno». Decidere semplicemente che «quando serve un cambiamento, si costruisce un nuovo oggetto e lo si sostituisce, invece di mutare sul posto» toglie un pezzo di stato mutabile che altrimenti dovreste proteggere (per la gestione della vita della sostituzione stessa, si veda l’avvertenza nella sezione 5.2).
Consegnare attraverso una coda. Incaminate il flusso di dati tra thread attraverso una coda produttore/consumatore piuttosto che una variabile condivisa. Lo standard C++ non ha un tipo canale, quindi scrivere una piccola coda con std::mutex + std::condition_variable è il pattern consolidato.
template <typename T>
class BlockingQueue {
public:
explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
{
if (capacity == 0) // capacità 0 è una trappola in cui ogni Push aspetta per sempre
throw std::invalid_argument("capacity must be positive");
}
// Aspetta finché c'è spazio (o una richiesta di stop) se piena. false significa richiesta di stop.
bool Push(T item, std::stop_token st)
{
{
std::unique_lock lock(mtx_);
if (!not_full_.wait(lock, st, [this]{ return queue_.size() < capacity_; }))
return false; // svegliato da una richiesta di stop
if (st.stop_requested()) // se spazio e stop arrivano insieme, preferire lo stop,
return false; // e rifiutare i push una volta iniziato lo stop
queue_.push(std::move(item));
}
not_empty_.notify_one(); // notificare fuori dal lock
return true;
}
// Aspetta una richiesta di stop (stop_token) o l'arrivo di un elemento. nullopt quando è fermo.
std::optional<T> Pop(std::stop_token st)
{
std::optional<T> item;
{
std::unique_lock lock(mtx_);
if (!not_empty_.wait(lock, st, [this]{ return !queue_.empty(); }))
return std::nullopt; // svegliato da una richiesta di stop
if (st.stop_requested()) // se un elemento e lo stop arrivano insieme, preferire lo stop,
return std::nullopt; // e non iniziare nuovo lavoro una volta iniziato lo stop
item = std::move(queue_.front());
queue_.pop();
}
not_full_.notify_one();
return item;
}
private:
const std::size_t capacity_;
std::mutex mtx_;
std::condition_variable_any not_empty_; // condition_variable_any, per usare wait consapevole di stop_token
std::condition_variable_any not_full_;
std::queue<T> queue_;
};
Ci sono due punti di progetto. Primo, limitare la capacità e far aspettare il lato produttore quando è piena. Una coda senza tetto diventa una bomba a orologeria negli assetti in cui la produzione supera il consumo: «continua a girare», ma la memoria cresce. Far bloccare Push quando è piena agisce come backpressure naturale, propagando meccanicamente il sovraccarico a monte. Secondo, poiché le condition variable sono soggette a spurious wakeup (risveglio senza notifica), chiamate sempre wait con un predicato. La forma a predicato di wait esegue internamente per voi la logica «ciclo finché la condizione è vera».6
5. Disciplina dei lock — RAII e scoped_lock
Anche dopo aver ridotto lo stato mutabile condiviso, spesso non si arriva a zero. Usate l’esclusione per ciò che resta condiviso, ma lockare senza disciplina nasconde solo la contesa.
Prima, pensate l’unità di lock non come un «tratto di codice» ma come «dati». Assegnate un mutex a ciascun insieme di dati mutabili che volete proteggere (fatelo membro private, non esposto all’esterno), e prendete quello stesso mutex in ogni punto che tocca quei dati — una versione rotta di questa tabella di corrispondenza è ciò che la maggior parte dei bug da race è in realtà. E l’unica cosa che potete fare mentre tenete un lock è leggere e scrivere i dati che protegge. I/O su file, chiamate di rete e callback (chiamate in codice esterno) mentre tenete un lock non solo allungano quanto lo tenete — aprono un percorso in cui il chiamato tenta di prendere un lock diverso e va in deadlock. Preparare fuori dal lock, e dentro il lock non fare altro che sostituire è la forma di base.
5.1. Scrivere a mano lock()/unlock() è vietato
Il codice che chiama direttamente lock() / unlock() di std::mutex finisce per non rilasciare il lock su eccezioni o return anticipati. Lasciate sempre acquisizione e rilascio del lock a un wrapper RAII.
| Wrapper | Uso |
|---|---|
std::lock_guard |
Tiene un singolo mutex per esattamente la durata di uno scope — la forma più basica |
std::scoped_lock (C++17) |
Acquisisce più mutex insieme. Risolve il problema dell’ordine con un algoritmo di evitamento dei deadlock4 |
std::unique_lock |
Per quando volete sbloccare e ribloccare a metà, o dovete passarlo a condition_variable::wait |
Quando ci sono due o più lock, avere l’ordine di acquisizione che si inverte a seconda del thread è il pattern classico di deadlock (l’attesa circolare della Figura 2 nasce esattamente così). La correzione è fare una regola che «ogni thread acquisisce i lock nello stesso ordine», ma quando li state acquisendo nello stesso momento, C++ ha una risposta migliore: consegnate insieme più mutex a std::scoped_lock e la libreria vi garantisce un ordine di acquisizione libero da deadlock.4 In situazioni come un trasferimento tra due oggetti in cui volete «entrambi lockati», non prendeteli mai individualmente — prendeteli sempre insieme.
void Transfer(Account& from, Account& to, int amount)
{
if (&from == &to) return; // non fare nulla per lo stesso conto (vedi nota sotto)
std::scoped_lock lock(from.mtx, to.mtx); // entrambi insieme; la libreria risolve l'ordine
from.balance -= amount;
to.balance += amount;
}
Il controllo di identità in cima non è decorativo. Se lo stesso Account viene passato sia come from sia come to, finite per passare due volte lo stesso mutex non ricorsivo a scoped_lock, il che causa un hang o comportamento indefinito. Affiancate sempre un’esclusione dello stesso oggetto a ogni funzione che «locka entrambi».
Per dati «letti spesso, scritti di rado», potete usare std::shared_mutex (C++17) come lock di lettura/scrittura.12 recursive_mutex è un tipo progettato perché «la riacquisizione da parte dello stesso thread non si rompa», ma un progetto che ha bisogno di acquisizione ricorsiva è spesso un segno che il confine di responsabilità di un lock si è offuscato — considerate prima di rivedere la struttura.
5.2. Il ruolo corretto di atomic
std::atomic fornisce operazioni atomiche su una singola variabile, più l’ordinamento basato su memory_order.5 Merita il suo posto nelle stesse situazioni di Interlocked nell’edizione .NET: aggiornare una singola variabile, come un contatore o un flag. Non può tenere insieme consistenti più variabili, quindi per quello si torna a std::mutex.
Sostituire un puntatore grezzo (std::atomic<T*>) ha una trappola tutta sua. Anche se la sostituzione stessa è atomica, nessuno protegge la vita del vecchio oggetto una volta che è stato sostituito. Se un lettore carica il vecchio puntatore un attimo prima che lo scrittore lo sostituisca e lo delete, ottenete un accesso a memoria liberata. Se volete fare un progetto «sostituisci e condividi un oggetto immutabile» in C++, scegliete un mezzo che arrivi accoppiato alla gestione della vita — sostituire un std::shared_ptr<const T> protetto da lock, o std::atomic<std::shared_ptr<T>> di C++20.
E per ripetere: volatile non è uno strumento di sincronizzazione tra thread. La programmazione lock-free in cui specificate voi memory_order è territorio da esperti, e richiede sia una ragione legittima per allentarlo dal default (seq_cst) sia un modo per verificare di averlo fatto correttamente. Nelle applicazioni aziendali, o usate il default o scrivetelo con mutex in partenza.
6. Progettare come si ferma — stop_token e stop cooperativo
La prima domanda da fare nel rivedere un progetto multithread è «come si ferma?». E C++ non ha mezzi per fermare in sicurezza un thread dall’esterno (quanto sia pericoloso TerminateThread di Win32 è trattato in dettaglio nell’edizione C). Quindi come un thread si ferma va costruito con gli strumenti di C++ intorno allo stop cooperativo — il lato che ferma emette solo una richiesta; il thread stesso decide quando e come finire, in un punto che lascia le cose in ordine; e il completamento del join è ciò che conta come «fermato».
In C++20, std::jthread ha il meccanismo di stop incorporato. Chiamare request_stop() alza la richiesta di stop sullo std::stop_token che la funzione del thread ha ricevuto, e il ciclo lo interroga. Il wait di condition_variable_any può prendere direttamente uno stop_token, quindi anche un «thread in attesa che arrivi lavoro» può essere svegliato all’istante da una richiesta di stop (BlockingQueue::Pop della sezione 4 ha esattamente questa forma).
class Worker {
public:
void Start()
{
if (thread_.joinable()) // Rifiutare un doppio Start mentre è già in esecuzione.
throw std::logic_error("already running"); // Se assegnassimo invece di rifiutare, un nuovo
// thread inizierebbe a girare, e mentre aspetta
// che il vecchio thread si fermi, due worker
// finirebbero a girare affiancati
thread_ = std::jthread([this](std::stop_token st) {
try {
while (!st.stop_requested()) {
if (auto item = queue_.Pop(st)) { // si sveglia anche su una richiesta di stop
try {
Process(*item, st); // passare st anche nel lavoro che può bloccarsi internamente
} catch (...) {
ReportError(std::current_exception()); // registrare un singolo fallimento e continuare
}
}
}
} catch (...) {
// L'ultima linea di difesa al confine del thread (cattura anche i fallimenti
// in Pop o in una move). Se un'eccezione esce da qui, std::terminate
// abbatte l'intero processo, quindi assicuratevi che ReportError stesso non lanci
ReportError(std::current_exception());
}
});
}
// Non serve uno Stop esplicito:
// distruttore di Worker -> distruttore di jthread -> request_stop() + join()
private:
BlockingQueue<WorkItem> queue_{100}; // capacità limitata (sezione 4)
std::jthread thread_;
};
flowchart TB
OWNER["Il lato che ferma<br/>- distruttore di jthread, o request_stop"] -->|"richiesta di stop"| ST["stop_token"]
ST --> P["Ciclo di calcolo:<br/>interroga stop_requested()"]
ST --> W["Thread in attesa:<br/>condition_variable_any::wait(lock, st, pred)<br/>si sveglia immediatamente"]
P --> E["Pulisce e ritorna da solo"]
W --> E
E --> J["join completa il rendezvous<br/>solo ora possiamo dire che è fermo"]
Figura 4: Stop cooperativo in C++20. Il lato che ferma emette solo la richiesta; il thread stesso decide come finisce; il completamento del join è ciò che conta come fermo
Un altro punto: il try/catch dentro il worker non si può omettere. Ciò che jthread rende exception-safe è il join, e solo il join — se un’eccezione esce dalla funzione del thread, std::terminate abbatte il processo, proprio come con std::thread. Decidete esplicitamente, al confine del thread, come gestire il fallimento di un singolo pezzo di lavoro (registrarlo e continuare, o segnalarlo al proprietario su un canale di errore).
Per la stessa ragione, notate che anche a Process viene passato lo stop_token. Se l’elaborazione di un singolo pezzo di lavoro si blocca internamente (attesa di rete, un calcolo lungo, e così via) e quel punto non può osservare la richiesta di stop, il join implicito del distruttore aspetterà per sempre che quell’unico elemento finisca. Lo stop cooperativo regge solo una volta che il token ha raggiunto ogni posto che aspetta. Se il lavoro include una chiamata esterna che non si può interrompere, attaccate un timeout e mettete un tetto a quanto a lungo un singolo elemento può girare.
Negli ambienti precedenti a C++17, costruite a mano la stessa forma con un flag di stop std::atomic<bool> più notify_all di condition_variable. Il punto chiave qui è piegare il controllo del flag di stop nel predicato della condition variable — se alzate solo il flag e dimenticate di notificare, un thread in attesa non si sveglierà mai.
7. Preoccupazioni specifiche di Windows — il confine con l’API Win32
7.1. Scegliere tra libreria standard e oggetti di sincronizzazione Win32
La documentazione Microsoft raccomanda std::mutex / std::shared_mutex per il codice C++ che privilegia la portabilità, e colloca gli oggetti di sincronizzazione Win32 come «quando serve un’API di attesa Win32» e «sincronizzazione interprocesso».8
| Situazione | Scelta |
|---|---|
| Esclusione intra-processo ordinaria | std::mutex + RAII (default) |
| Molte letture, rare scritture | std::shared_mutex |
Attendere più oggetti insieme con WaitForMultipleObjects |
Oggetti kernel Win32 come event e mutex |
| Esclusione / notifica interprocesso | Mutex, event, semafori con nome |
| Lock intra-processo usando direttamente l’API Win32 | SRW lock (CRITICAL_SECTION solo quando serve la ricorsione)8 |
Per il progetto concreto dell’esclusione dell’accesso alla memoria condivisa tra processi, si veda «Memoria condivisa: trappole e best practice pratiche».
7.2. Non toccate i thread dentro DllMain
Un vincolo serio quando si scrive una DLL è il loader lock. DllMain viene chiamato mentre il loader lock è tenuto, quindi operazioni al suo interno come sincronizzarsi con un altro thread, aspettare la fine di un thread, o chiamare LoadLibrary causano deadlock o comportamento imprevedibile. Spostate qualsiasi inizializzazione che avvii o faccia join di thread fuori da DllMain, in una funzione di inizializzazione esplicita.13
7.3. Il thread UI e gli apartment COM
Le applicazioni desktop Windows hanno un vincolo forte che vale indipendentemente dal linguaggio: solo il thread che ha creato una finestra o un controllo — il thread UI — può toccarlo. Windows consegna i messaggi di finestra alla coda messaggi del thread che ha creato quella finestra, quindi creazione e manipolazione dell’UI devono essere concentrate su quel thread. Quando volete aggiornare lo schermo da un thread worker, non toccatelo direttamente — chiedete al thread UI con PostMessage (asincrono), e gestitelo nella window procedure dal lato del thread UI. Chiamare la forma sincrona, SendMessage, mentre il thread UI sta aspettando che quel worker finisca causa un deadlock in cui ciascuno aspetta l’altro, quindi fate della forma asincrona il default per le notifiche da un worker. STA/MTA, dove entra COM, è trattato in «Fondamenti STA/MTA di COM - Modelli di threading e come evitare blocchi». Notate anche che nel codice C++/CLI compilato con /clr, gli header standard dei thread come <thread> e <mutex> sono bloccati.14
8. Verifica e debug — prepararsi nell’assunto che non si riprodurrà
Non potete affidarvi ai test per trovare i bug da race, perché un test ordinario conta come successo un’esecuzione che «per caso non ha fatto race». Pensate la preparazione in tre strati.
La prima linea di difesa sono i principi di progetto coperti fin qui, esattamente come sono. In review, confermate con una tabella: quali dati mutabili sono condivisi, quale mutex protegge ciascun pezzo, se l’ordine di acquisizione di più lock è unico (o vengono presi insieme con scoped_lock), e dov’è il percorso di stop. Un progetto per cui non potete scrivere questa tabella non è finito, per quanto bene giri al momento.
Secondo, rendete osservabili gli stati anormali invece di nasconderli. Attaccate un timeout con try_lock_for di timed_mutex o wait_for di condition_variable a ogni lock che non dovrebbe mai fallire l’acquisizione, e registrate un timeout come anomalia — questo trasforma un hang eterno in un fallimento rilevabile. Registrate sempre le eccezioni catturate nel try/catch al confine del thread (sezione 6). Quando in campo avviene un hang o un crash, catturate un dump, controllate lo stack di ogni thread, e cercate se le loro attese di lock formano un ciclo. L’allestimento di dump e logging è trattato in «Progettare logging e cattura dump per i crash delle app Windows».
Terzo, scuotete le cose sotto carico. Girare a lungo con più parallelismo di quanti core avete, randomizzare l’ordine di elaborazione e iniettare ritardi artificiali sono tecniche pratiche di stress-testing che rendono più facile colpire un «jackpot» da race su una macchina di sviluppo. I bug che spariscono in una build debug spesso si riproducono volentieri in una build release ottimizzata sotto carico pesante.
9. Sintesi — la checklist C++
Sovrapponete controlli specifici di C++ ai principi comuni a ogni linguaggio: non creare thread direttamente, minimizzare lo stato mutabile condiviso, corrispondenza uno-a-uno tra lock e dati, e stop cooperativo.
std::threadviene usato nudo (potrebbe essere unjthread? Il join è garantito anche sul percorso di eccezione?)detach()non viene usato?- Le catture delle lambda sono esplicite, e ogni variabile catturata per riferimento sopravvive al thread?
- Potete dire con fiducia che non c’è nemmeno un accesso mutabile condiviso non sincronizzato (= comportamento indefinito) da nessuna parte?
- Non c’è
lock()/unlock()scritto a mano, e i lock multipli vengono presi insieme conscoped_lock? - Ogni
condition_variable::waitè usato con un predicato? volatilenon viene usato per un flag condiviso (èstd::atomical suo posto)?- Il percorso di stop è progettato intorno a
stop_token(o un flag atomic più notifica), con il completamento del join che conferma il rendezvous? - Il
futuredistd::asyncnon viene scartato? DllMainè libero da avvio, sincronizzazione o join di thread?
Il C++ multithread è un lavoro fatto camminando proprio accanto al ciglio del comportamento indefinito, ma giratelo e significa che aderire onestamente a RAII e alle convenzioni della libreria standard mette vera distanza tra voi e quel ciglio. jthread, scoped_lock, wait in forma di predicato, atomic — scegliere i default giusti tra questi strumenti è, in C++, la pratica stessa dei principi di progetto.
Articoli correlati
- Best practice pratiche sul multithreading: edizione .NET — Cosa decidere prima di aggiungere altri thread
- Best practice pratiche sul multithreading: edizione C — scrivere in sicurezza alla maniera dell’API Win32
- Best practice pratiche sul multithreading: edizione Java — convenzioni per l’era dei thread virtuali
- Chiamare DLL native da C#: wrapper C++/CLI vs P/Invoke
- Memoria condivisa: trappole e best practice pratiche
- Fondamenti STA/MTA di COM - Modelli di threading e come evitare blocchi
- Le profondità dell’I/O di Windows (parte 2) — I/O sincrono e asincrono: cosa significa davvero OVERLAPPED
Aree di consulenza correlate
KomuraSoft LLC si occupa di review di progettazione multithread per applicazioni e DLL C++, di indagine sulle cause (analisi dump) di bug da race condition come «crasha di tanto in tanto» o «si comporta male solo in build release», e di consulenza sulla migrazione di codice a thread legacy verso C++ moderno.
- Consulenza tecnica e revisione del progetto
- Analisi dei bug e delle cause
- Sviluppo di applicazioni Windows
- Contattaci
Riferimenti
-
ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. Sul fatto che CP.1 (assumete che il vostro codice girerà come parte di un programma multithread) e CP.2 (evitate le data race) siano enunciate come regole di apertura del capitolo su concorrenza e parallelismo; sul fatto che nessuna garanzia regga più una volta che esiste una data race; e sul fatto che le regole di progetto per il codice concorrente — l’ambito in cui si tengono i lock, l’uso di RAII, e così via — vi siano sistematizzate. ↩ ↩2
-
cppreference.com, std::jthread. Sul fatto che jthread di C++20 differisca da std::thread in quanto il suo distruttore chiama automaticamente request_stop() e poi fa join; sul fatto di poter ricevere uno std::stop_token come argomento iniziale della funzione del thread; e sul fatto che questo garantisca sia il join sia la richiesta di stop anche quando viene lanciata un’eccezione. ↩ ↩2
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Sul fatto che P0660R10 (<stop_token> e jthread) e P1135R6 (la libreria di sincronizzazione C++20) siano supportati da Visual Studio 2019 16.9; e sullo stato di supporto versione per versione delle funzionalità della libreria standard C++. ↩ ↩2 ↩3
-
Microsoft Learn, scoped_lock Class. Sul fatto che scoped_lock di C++17 acquisisca uno o più mutex alla costruzione e li rilasci nel distruttore; sul fatto che più mutex, quando passati insieme, vengano acquisiti con un algoritmo di evitamento dei deadlock equivalente a std::lock; sul fatto che rilasci in modo affidabile anche se viene lanciata un’eccezione; e sul fatto che lock_guard/unique_lock siano anch’essi un’opzione quando è coinvolto un solo mutex. ↩ ↩2 ↩3
-
Microsoft Learn, <atomic>. Sul fatto che le operazioni atomiche siano indivisibili, così gli altri thread possono osservare solo lo stato prima o dopo l’operazione; sul fatto di stabilire, in base all’argomento memory_order, requisiti di ordinamento sulla visibilità di altre operazioni atomiche, e di sopprimere le ottimizzazioni del compilatore che le violerebbero; sul fatto che atomic_flag sia sempre lock-free; e sul fatto che questo header sia bloccato sotto /clr:pure. ↩ ↩2
-
Microsoft Learn, <condition_variable>. Sul fatto che aspettare su una condition variable richieda un mutex, con il lock rilasciato per la durata dell’attesa; sul fatto che esistano spurious wakeup — risveglio senza notifica — quindi il lato in attesa dovrebbe ricontrollare esplicitamente la condizione al ritorno, e la forma a predicato wait(lock, pred) esegue quel ciclo per voi; e sul fatto che condition_variable_any sia combinabile con qualsiasi tipo di mutex. ↩ ↩2
-
Microsoft Learn, <future>. Sul fatto che i distruttori di future e shared_future di regola non si blocchino, con la sola eccezione che il future (o l’ultimo shared_future) legato a un task lanciato con std::async si blocca finché lo stato condiviso diventa ready se il suo distruttore gira mentre il task è ancora incompleto — un comportamento esplicitamente annotato nello standard. ↩ ↩2
-
Microsoft Learn, About Synchronization. Sulle linee guida per scegliere i primitivi di sincronizzazione Win32: std::mutex / std::shared_mutex e RAII sono raccomandati per il codice C++ che privilegia la portabilità; gli oggetti di sincronizzazione Win32 si usano quando serve un’API di attesa Win32 o una sincronizzazione interprocesso; il default per il codice intra-processo nuovo è un SRW lock, con CRITICAL_SECTION riservato a quando serve l’acquisizione ricorsiva; e usare Mutex per la sincronizzazione intra-processo è un «errore comune» perché implica sempre una transizione nel kernel. ↩ ↩2 ↩3
-
cppreference.com, std::thread::~thread. Sul fatto che il distruttore di std::thread chiami std::terminate se viene chiamato mentre il thread è ancora joinable (né joined né detached) — cioè, sul fatto che la decisione di fare join o detach debba essere presa prima che l’oggetto thread venga distrutto, senza eccezioni. ↩
-
Microsoft Learn, Best Practices in the Parallel Patterns Library. Sul fatto che il parallelismo andrebbe espresso idealmente al livello più alto possibile (il ciclo esterno); sul fatto che l’overhead di scheduling fork/join possa superare i guadagni dell’esecuzione parallela in cicli paralleli in cui il lavoro di ciascuna iterazione è piccolo o sbilanciato; e sul fatto che quella tendenza si rafforzi al crescere del numero di processori. ↩
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Sul fatto che la libreria degli algoritmi paralleli C++17 sia completa, mentre «completa» non significa che ogni algoritmo sia parallelizzato in ogni caso; sulla policy di implementazione di parallelizzare gli algoritmi più importanti fornendo comunque le firme con policy di esecuzione per quelli che non lo sono. ↩
-
Microsoft Learn, C++ standard library header files. Sul fatto che gli header standard relativi al multithreading siano disposti come <atomic> (C++11), <mutex> (C++11), <shared_mutex> (C++14), <condition_variable> (C++11), <future> (C++11), <stop_token> / <semaphore> / <latch> / <barrier> (C++20), e <thread> (C++11). ↩
-
Microsoft Learn, Dynamic-Link Library Best Practices. Sul fatto che DllMain venga chiamato mentre è tenuto il loader lock, imponendo vincoli seri su quali API possa chiamare; sul fatto che sincronizzarsi con un altro thread dentro DllMain possa andare in deadlock; sul fatto che chiamare LoadLibrary o aspettare la fine di un thread siano azioni tipicamente proibite; sul fatto che l’inizializzazione andrebbe idealmente differita il più possibile e spostata fuori da DllMain; e sul fatto di definire una gerarchia di lock con il loader lock in cima. ↩
-
Microsoft Learn, <thread>. Sul fatto che l’header <thread> definisca la classe thread e funzioni di aiuto come sleep_for; sul fatto che questo header sia bloccato nel codice compilato con /clr; e sul fatto che la macro STDCPP_THREADS permetta di determinare se il supporto ai thread è presente. ↩
Articoli correlati
Articoli recenti con gli stessi tag per approfondire argomenti vicini.
Best practice di multithreading in pratica: edizione C — Scrivere in sicurezza alla maniera delle API Win32
L'approccio consolidato al multithreading in C con Win32 è la creazione dei thread con _beginthreadex, i lock SRW e le variabili di condi...
L'API thread pool Win32 — Concorrenza senza creare thread, tramite CreateThreadpoolWork
State spargendo chiamate CreateThread per tutto il codice nativo? Questo articolo spiega l'API thread pool Win32 ridisegnata in Vista — i...
DllMain e il loader lock — Il vero motivo per cui vi dicono di «non fare niente nell'inizializzazione della DLL»
Perché non dovete chiamare LoadLibrary o sincronizzarvi con altri thread da DllMain. A partire dalle fonti primarie, l'articolo spiega co...
Sveglie spurie — Perché le condition variable si svegliano «senza essere notificate» e come attendere correttamente su Windows
L'attesa di una condition variable può ritornare anche quando non è arrivata nessuna notifica (una sveglia spuria). L'articolo spiega, a ...
Buone pratiche di multithreading in pratica: edizione Java — convenzioni dell'era dei thread virtuali
In Java la pratica consolidata del multithreading è non creare mai i thread direttamente, ma basarsi su ExecutorService e thread virtuali...
Argomenti correlati
Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.
Argomenti tecnici Windows
Portale su sviluppo Windows, analisi dei problemi e valorizzazione delle risorse esistenti.
Analisi dei bug e guasti di lunga durata
Errori intermittenti, diagnosi delle comunicazioni, crash prolungati e test dei percorsi di errore.
Servizi collegati all’argomento
L’articolo è direttamente collegato ai servizi seguenti.
Sviluppo di applicazioni Windows
Applicazioni aziendali, integrazione di dispositivi e strumenti di comunicazione, dai requisiti allo sviluppo.
Domande frequenti
Domande che ricorrono nelle consulenze sull’argomento dell’articolo.
- Come scegliere tra std::mutex e CRITICAL_SECTION / SRW lock di Win32?
- Per il codice C++ ordinario che privilegia la portabilità, std::mutex / std::shared_mutex insieme ai wrapper RAII (lock_guard / scoped_lock) sono la prima scelta. Si ricorre agli oggetti di sincronizzazione Win32 quando occorre combinarli con un'API di attesa Win32 come WaitForMultipleObjects, o quando serve una sincronizzazione interprocesso tramite un oggetto con nome. Se usate l'API Win32 direttamente all'interno di un processo, il default per il codice nuovo è un SRW lock, e CRITICAL_SECTION solo quando lo stesso thread deve acquisirlo in modo ricorsivo. Usare un Mutex Win32 per l'esclusione intra-processo è un errore classico: implica sempre una transizione nel kernel ed è di conseguenza lento.
- Va bene usare detach() di std::thread?
- Di regola, evitatelo. Un thread detached perde ogni mezzo per essere raggiunto con join, e perdete il controllo su se sia ancora in esecuzione all'uscita del processo. È un incidente classico: un thread detached continua a girare dopo che le variabili statiche o l'heap sono stati distrutti, e provoca un crash in chiusura. Potere aspettare la fine di un thread è un requisito di base della progettazione a thread, quindi usate jthread (che fa join automaticamente) o, se usate thread, strutturate il codice perché faccia sempre join prima della fine dello scope. detach è ammissibile solo nella stretta situazione in cui il thread può condividere il destino del processo e potete garantire che non tocchi affatto stato condiviso.
- Si può usare volatile per la sincronizzazione tra thread in C++?
- No. Il volatile di C++ è un qualificatore per letture e scritture che non volete vengano ottimizzate via dal compilatore — ad esempio I/O mappato in memoria — e non garantisce visibilità né ordinamento tra thread. Se più thread accedono alla stessa variabile senza sincronizzazione, è una data race, e questo è comportamento indefinito. Usate std::atomic per flag e contatori condivisi tra thread, e std::mutex quando dovete proteggere insieme più variabili. std::atomic fornisce sia l'atomicità dell'operazione sia l'ordinamento basato su memory_order.
- std::async sembra comodo, ma ci sono trappole?
- La trappola più grande è il distruttore del future. Il future (o l'ultimo shared_future) legato a un task lanciato con std::async si blocca fino al completamento se il suo distruttore gira mentre il task è ancora incompleto. Se scartate il future restituito senza trattenerlo, diventa equivalente a un'esecuzione sincrona lì sul posto — un incidente in cui intendevate andare in asincrono e finite in seriale. Inoltre, se non specificate una policy di lancio, se il lavoro giri davvero su un thread separato è lasciato alla discrezione dell'implementazione. Se lo usate, gestite esplicitamente la vita del future e specificate std::launch::async ovunque dobbiate garantire l'esecuzione concorrente.
Profilo dell’autore
Pagina di presentazione dell’autore dell’articolo.
Go Komura
Rappresentante di KomuraSoft LLC
Specializzato nello sviluppo di software Windows, nella consulenza tecnica e nell’analisi dei malfunzionamenti, soprattutto nei progetti con sistemi esistenti e guasti difficili da riprodurre.