1. Cosa cambia quando i thread diventano due
Iniziamo dalla motivazione, perché altrimenti la multithreading sembra un gioco strano «complichiamo la vita». I programmi reali spesso fanno più cose contemporaneamente: leggono dati, calcolano qualcosa di pesante, mostrano il progresso, rispondono all'utente, scrivono log. Se tutto viene fatto in un unico thread, il programma può «congelarsi» e sembrare bloccato — anche se sta lavorando onestamente.
Immaginiamo la nostra applicazione console didattica (diciamo che sia il semplice task manager TaskBook). Ha comandi come add, list, done, e a volte un'operazione pesante: per esempio ricalcolare statistiche su grandi dati o importare una lista di task. Idealmente vogliamo che durante l'operazione pesante l'interfaccia non «muoia», ma possa almeno stampare il progresso.
Ecco il pensiero chiave della lezione di oggi: non appena compare la concorrenza, l'ordine degli eventi smette di essere garantito. E il vostro principale nemico non è la «sintassi complessa dei thread», ma gli errori di pensiero: quando costruite la logica come se l'ordine fosse comunque fissato.
Flusso di esecuzione e memoria condivisa
Quando diciamo «thread di esecuzione», intendiamo una linea separata di esecuzione delle istruzioni all'interno di un singolo processo. Un processo può avere più thread, e di solito condividono la stessa memoria: variabili globali, dati sull'heap, oggetti in main(), container e così via. Cioè «vivono nello stesso appartamento», solo che ciascuno percorre le stanze al proprio ritmo.
È importante non confondere «concorrenza» e «parallelismo». Per il nostro scopo ora basta un'idea semplice: anche se avete un solo processore fisico, i thread possono essere eseguiti «mescolati» (il sistema operativo li switcha molto velocemente). E se i processori sono più di uno — possono essere eseguiti veramente contemporaneamente. In entrambi i casi l'effetto per il programmatore è lo stesso: l'ordine dei passi tra i thread è sconosciuto a priori.
È utile tenere a mente questo schema:
flowchart TD
A["Un processo (il vostro programma)"] --> B["Thread main"]
A --> C["Thread worker"]
B --> D["Memoria condivisa: variabili, container, heap"]
C --> D
Entrambi i thread possono accedere alla memoria condivisa. E se non vi siete messi d'accordo «chi tocca cosa e quando», iniziano le race.
Nondeterminismo: lo stesso codice — ordini di eventi diversi
La prima cosa che notano i principianti con i thread: l'output su console può arrivare in ordine diverso. E non è un «bug del compilatore», ma un comportamento normale: lo scheduler del SO può dare la CPU ora a un thread, ora a un altro.
Mini-demo (qui usiamo std::thread, ma per ora senza entrare nei dettagli — solo come «modo per lanciare una seconda linea di esecuzione»):
#include <iostream>
#include <thread>
int main() {
std::thread t([] { std::cout << "worker\n"; });
std::cout << "main\n";
t.join();
}
Una esecuzione potrebbe stampare:
// main
// worker
Un'altra esecuzione potrebbe stampare:
// worker
// main
E entrambe le opzioni sono corrette. Il punto è: tra i thread non esiste la promessa «chi prima», finché non costruite voi stessi quella regola.
Un'altra sfumatura: std::cout è anch'esso una risorsa condivisa. Anche se le righe sembrano stampate «più o meno bene», possono mescolarsi se stampate a pezzi. Oggi questo sarà per noi un «sintomo di concorrenza»: vedete confusione in console — ricordate che i thread si mescolano.
Race condition: gara logica
Race condition è un'errore logico, quando la correttezza del risultato dipende dall'ordine in cui i thread hanno fatto i loro passi. Importante: una race condition può esserci anche in un programma dove «formalmente tutto è protetto» (per esempio, ci sono dei lock) — semplicemente la logica continua a dipendere dall'ordine.
Immaginate un'analogia quotidiana: due persone modificano contemporaneamente lo stesso documento. Entrambi fanno cambiamenti corretti, non strappano la carta, ma il risultato dipende da chi ha cliccato «salva» per ultimo. Il documento non è «rotto» fisicamente — ma il risultato può non essere quello atteso.
Facciamo un esempio più vicino al nostro TaskBook. Supponiamo di voler aggiornare una riga di stato: un thread scrive «Import…», un altro thread scrive «Fatto». Se contiamo che «Fatto» sarà sempre l'ultimo, siamo già in una zona di rischio.
#include <iostream>
#include <string>
#include <thread>
int main() {
std::string status = "starting";
std::thread t1([&] { status = "importing"; });
std::thread t2([&] { status = "done"; });
t1.join();
t2.join();
std::cout << status << '\n'; // importing O done
}
Anche se il programma non crasha, il risultato è nondeterministico: non potete garantire che vedrete done. La logica «ma t2 è più in basso nel codice, quindi viene dopo» — non funziona più. I thread non leggono il vostro codice come un libro.
Per chiarire la differenza di termini, è comodo confrontarli in una tabella:
| Concetto | Cos'è | Principale sintomo | «Quanto è grave» |
|---|---|---|---|
| Race condition | Errore logico dovuto all'ordine imprevedibile degli eventi | A volte «risultato sbagliato», a volte «ramo di esecuzione sbagliato» | Brutto: bug intermittente e difficile da riprodurre |
| Data race | Accesso concorrente alla memoria non corretto (c'è una scrittura senza protocollo concordato) | Può «funzionare», può rompersi, può dare valori strani | Molto grave: UB, cioè il programma è formalmente non corretto |
Arriveremo alla data race tra qualche minuto — e sarà chiaro perché è una categoria a parte di «orrori».
Data race: accesso concorrente alla memoria + scrittura → Undefined Behavior
Se la race condition è «la logica è andata», la data race è «abbiamo violato le regole del linguaggio». In C++ una data race si verifica quando due thread accedono contemporaneamente alla stessa area di memoria, e almeno un thread scrive, e tra questi accessi non esiste un protocollo concordato di accesso.
La parte più insidiosa: molti pensano che ++counter sia «un'operazione unica». A livello testuale — sì. A livello di esecuzione — no. Di solito sono almeno tre passi: leggere, incrementare, scrivere. E se due thread fanno questo contemporaneamente — possono sovrascriversi a vicenda.
Ecco l'esempio classico (sì, è intenzionalmente «sbagliato»):
#include <iostream>
#include <thread>
int main() {
int counter = 0;
auto inc = [&] {
for (int i = 0; i < 100000; ++i) {
++counter; // SCORRETTO: data race (UB)
}
};
std::thread t1(inc);
std::thread t2(inc);
t1.join();
t2.join();
std::cout << counter << '\n'; // non necessariamente 200000
}
Potreste aspettarvi 200000. A volte vedrete 200000. Altre volte — meno. E talvolta (in una giornata particolarmente «fortunata») il programma può comportarsi in modo molto strano, perché Undefined Behavior significa: lo standard non è obbligato a dire cosa succede.
Qui è importante accettare mentalmente una cosa spiacevole: non si può considerare l'UB come un «bug raro». UB è piuttosto «siete usciti dalle regole del gioco», e da lì in poi compilatore e runtime possono fare qualunque cosa.
A proposito, il fatto che la parola race sia percepita come problema serio si vede anche dalle formulazioni nella documentazione della standard library: compaiono frasi tipo «promise::set_value() and promise::get_future() should not race». È un buon indicatore: le race non sono una «paura da laboratorio», ma un reale dolore ingegneristico, per il quale gli standard e le specifiche stanno attenti.
Disciplina minima «sicura» per oggi
Vorrei dire ora: «Ok, mettiamo i lock e siamo a posto». Ma questo sarà l'argomento della prossima parte del corso. Oggi il nostro obiettivo è più semplice e onesto: imparare a pensare in modo da non progettare race fin dall'inizio.
La disciplina più semplice per un principiante è questa: finché non sapete sincronizzare l'accesso condiviso, non fate accesso condiviso a dati modificabili. Suona quasi come il consiglio «non toccare la pentola bollente», ma funziona davvero.
Ci sono due pattern sicuri che sono già alla nostra portata.
Il primo pattern — «un thread fa il lavoro, il thread principale legge il risultato solo dopo la terminazione». In pratica significa: un thread scrive in una variabile, e l'altro la legge solo dopo join() (di join() parleremo nella prossima lezione, per oggi intendetelo come «aspettare che il thread finisca»).
#include <iostream>
#include <thread>
int main() {
int result = 0;
std::thread t([&] {
result = 42; // scrittura nel worker
});
t.join(); // aspettato la fine del worker
std::cout << result << '\n'; // 42
}
Qui non c'è accesso simultaneo a result: mentre il thread lavora, main() non legge la variabile. E quando main() legge — il thread è già terminato.
Il secondo pattern — «ogni thread ha il suo». Cioè invece di una variabile condivisa ne facciamo due indipendenti, e ogni thread scrive solo nella propria. Poi, quando entrambi hanno terminato, sommiamo.
#include <iostream>
#include <thread>
int main() {
int a = 0;
int b = 0;
std::thread t1([&] { a = 10; });
std::thread t2([&] { b = 32; });
t1.join();
t2.join();
std::cout << (a + b) << '\n'; // 42
}
Sembra ancora «troppo semplice», ma proprio da questi semplici contratti si costruisce un design sicuro: decidete in anticipo chi possiede quali dati e quando è sicuro leggerli.
Se proviamo ad applicare questo al nostro TaskBook, il thread in background potrebbe per esempio calcolare le statistiche (quanti task completati), e il thread principale mostrare l'interfaccia. Ma la statistica deve essere aggiornata o in punti ben definiti, o in un'area dati separata, o tramite un protocollo (che affronteremo più avanti nel corso). Oggi è sufficiente ricordare: l'ordine non è garantito, e l'accesso condiviso senza regole è pericoloso.
Dove si nasconde la race
È utile saper «vedere» la race con gli occhi. Ecco la situazione tipica per ++counter, scomposta nei passi:
Thread 1: read(counter) -> tmp1
Thread 1: tmp1 = tmp1 + 1
Thread 1: write(counter, tmp1)
Thread 2: read(counter) -> tmp2
Thread 2: tmp2 = tmp2 + 1
Thread 2: write(counter, tmp2)
E ora immaginate che questi passi si mescolino:
counter = 5
t1: read -> tmp1 = 5
t2: read -> tmp2 = 5
t1: write 6
t2: write 6 (sovrascritto il risultato di t1)
Alla fine due incrementi hanno prodotto +1 invece di +2. Questo è già un problema visibile. Ma in C++ la seccatura è più profonda: il compilatore può ottimizzare il codice assumendo che non ci siano data race (perché le regole del linguaggio lo impongono). E allora il comportamento può diventare ancora più inaspettato.
3. Errori tipici
Errore №1: pensare che i thread vengano eseguiti «come righe di codice».
Abitudine molto umana: se in main() prima si crea il thread t1, poi t2, allora t1 è «prima» e t2 è «dopo». In realtà entrambi i thread possono partire quasi contemporaneamente, e chi esegue davvero il proprio codice per primo lo decide lo scheduler del SO. Quindi qualsiasi ragionamento «lo metto più in basso — quindi verrà dopo» non vale nella multithreading.
Errore №2: curare le race con dei ritardi sleep_for().
Un ritardo a volte «aiuta» nel senso che il bug smette di manifestarsi. Ma non è una cura — è una mascheratura. Avete solo aggiustato l'ordine dei thread così che il problema si presenti meno spesso. Su un altro computer, con un carico diverso o con altre ottimizzazioni del compilatore tutto tornerà. sleep_for() può essere usato per dimostrazioni didattiche del nondeterminismo, ma non come meccanismo di sincronizzazione.
Errore №3: considerare ++counter un'operazione atomica.
Psicologicamente ++ sembra «un operatore», quindi «un passo». Ma in pratica è un'operazione di tipo read-modify-write. In un solo thread è sicura, in più thread no, se non avete costruito un protocollo di accesso. Per questo i contatori sono l'esempio didattico più comune delle race: ingannano l'occhio con la loro semplicità.
Errore №4: confondere race condition e data race.
Race condition riguarda la logica e l'ordine degli eventi: «chi arriva prima» e perché questo cambia il risultato. Data race riguarda la memoria e le regole del linguaggio: «accesso simultaneo con scrittura senza protocollo concordato», che porta a Undefined Behavior. Si può scrivere un programma senza data race ma con race condition (la logica dipende comunque dall'ordine). E viceversa, si può ottenere una data race dove la logica sembra «ovvia».
Errore №5: ignorare che std::cout è una risorsa condivisa.
Quando le righe in console si mescolano, il principiante spesso conclude: «Ah, quindi ho un bug in cout». In realtà è un segnale utile: se l'output si è già mescolato, molto probabilmente potete altrettanto facilmente mescolare l'accesso ai dati condivisi. Oggi usiamo cout come indicatore di concorrenza: vedete caos — ricordate che l'ordine non è garantito.
GO TO FULL VERSION