CodeGym /Corsi /C++ SELF /Flusso di esecuzione e rischi della concorrenza

Flusso di esecuzione e rischi della concorrenza

C++ SELF
Livello 68, Lezione 0
Disponibile

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.

1
Compito
C++ SELF, livello 68, lezione 0
Bloccato
Due segnali
Due segnali
1
Compito
C++ SELF, livello 68, lezione 0
Bloccato
Due contatori
Due contatori
1
Compito
C++ SELF, livello 68, lezione 0
Bloccato
Log ordinato
Log ordinato
1
Compito
C++ SELF, livello 68, lezione 0
Bloccato
Due segmenti
Due segmenti
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION