Best practice pratiche sul multithreading: edizione C++ — eliminare gli incidenti strutturalmente con RAII e jthread

· · 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::thread nudo. Se il distruttore di un std::thread gira mentre il thread è ancora joinable, std::terminate uccide il processo all’istante. Il std::jthread di 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(); usate lock_guard / scoped_lock. Il distruttore rilascia il lock in modo affidabile anche se viene lanciata un’eccezione. Quando si acquisiscono più lock insieme, scoped_lock se ne occupa con un algoritmo di evitamento dei deadlock.4
  • volatile non è uno strumento di sincronizzazione. Usate std::atomic per flag e contatori condivisi, e std::mutex per proteggere insieme più variabili. std::atomic fornisce sia atomicità sia ordinamento basato su memory_order.5
  • Fate l’attesa di rendezvous con la forma a predicato di wait di condition_variable. Le condition variable sono soggette a spurious wakeup (risveglio senza notifica), quindi chiamare wait senza 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 con std::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 future può bloccarsi prima di usare std::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.

Thread BVariabile condivisa countThread AThread BVariabile condivisa countThread Acount = 10Due incrementi sono avvenuti,eppure count = 11 — l'addizione del Thread A è andata persaLettura (10)Lettura (10)Addizione locale (11)Addizione locale (11)Riscrittura (11)Riscrittura (11)

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.

in attesa di acquisire il lock 2in attesa di acquisire il lock 1Thread Atiene il lock 1Thread Btiene il lock 2

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

std::threadné joined né detachedstd::threadgià joinedstd::jthread - C++20Il thread è stato avviatoCosa succedequando lo scope esce?std::terminateil processo muore all'istanteJoin in sicurezzarequest_stop + join automaticisicuro 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: il future (o l’ultimo shared_future) legato a un task lanciato via std::async si blocca fino al completamento se il suo distruttore gira mentre il task è ancora incompleto.7 Per il lavoro effettivamente lanciato con std::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 scegliere deferred (esecuzione pigra) per default, nel qual caso, se nessuno chiama get() / wait(), il lavoro non viene eseguito affatto e sparisce in silenzio. Se volete garantire l’esecuzione concorrente, specificate std::launch::async esplicitamente, 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 chiamato std::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_;
};
richiesta di stopIl lato che ferma- distruttore di jthread, o request_stopstop_tokenCiclo di calcolo:interroga stop_requested()Thread in attesa:condition_variable_any::wait(lock, st, pred)si sveglia immediatamentePulisce e ritorna da solojoin completa il rendezvoussolo 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.

  1. std::thread viene usato nudo (potrebbe essere un jthread? Il join è garantito anche sul percorso di eccezione?)
  2. detach() non viene usato?
  3. Le catture delle lambda sono esplicite, e ogni variabile catturata per riferimento sopravvive al thread?
  4. Potete dire con fiducia che non c’è nemmeno un accesso mutabile condiviso non sincronizzato (= comportamento indefinito) da nessuna parte?
  5. Non c’è lock() / unlock() scritto a mano, e i lock multipli vengono presi insieme con scoped_lock?
  6. Ogni condition_variable::wait è usato con un predicato?
  7. volatile non viene usato per un flag condiviso (è std::atomic al suo posto)?
  8. Il percorso di stop è progettato intorno a stop_token (o un flag atomic più notifica), con il completamento del join che conferma il rendezvous?
  9. Il future di std::async non viene scartato?
  10. 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

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.

Riferimenti

  1. 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

  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

  3. 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

  4. 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

  5. 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

  6. 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

  7. 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

  8. 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

  9. 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. 

  10. 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. 

  11. 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. 

  12. 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). 

  13. 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. 

  14. 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 recenti con gli stessi tag per approfondire argomenti vicini.

Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.

L’articolo è direttamente collegato ai servizi seguenti.

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.

Torna al blog