CodeGym /Corsi /C++ SELF /std::atomic: flag e contatori + scelta di stop_token

std::atomic: flag e contatori + scelta di stop_token

C++ SELF
Livello 71, Lezione 4
Disponibile

1. Dalle data race a std::atomic

Se scrivete codice single-thread, una variabile int counter è solo un numero, e bool stop è semplicemente un flag. Ma non appena compare un secondo thread, queste «semplici variabili» diventano fonte di misteri tipo «perché si blocca solo il venerdì dopo pranzo?». Il punto è che due thread possono leggere/scrivere la stessa memoria contemporaneamente e senza disciplina questo porta a una data race, che in C++ è considerata Undefined Behavior: cioè «può funzionare, può non funzionare, può sembrare che funzioni».

Immaginate il tipico naive stop-flag:

#include <chrono>
#include <iostream>
#include <thread>

int main() {
    bool stop = false;

    std::thread worker([&] {
        while (!stop) { /* work */ }
    });

    std::this_thread::sleep_for(std::chrono::milliseconds{10});
    stop = true;

    worker.join();
    std::cout << "done\n"; // done
}

Apparentemente tutto ha senso: il thread principale imposta stop = true, il worker dovrebbe accorgersene e terminare. Però in realtà compilatore e CPU possono riorganizzare e ottimizzare le operazioni in modo che il worker «non veda» l'aggiornamento in tempo (o mai, se l'ottimizzazione decide che stop non cambia). Non entreremo oggi nei dettagli del modello di memoria (è un argomento a parte); il risultato pratico è: se una variabile è usata da più thread — deve essere protetta o da un mutex, o da un atomic, o da un altro meccanismo corretto.

std::atomic<T> come contratto per una singola variabile

Quando in un progetto compare std::atomic<T>, non è un «acceleratore», né «magia» né un «mutex per poveri». È un modo per dire al compilatore e al lettore del codice: «questa variabile è condivisa tra thread, l'accesso deve essere thread-safe». E qui il codice diventa più onesto: esprimiamo esplicitamente l'intento, invece di sperare che «tanto andrà bene».

Pensiero utile (e un po' triste): std::atomic funziona bene finché avete esattamente una cella di stato indipendente. Per esempio, «fermarsi/non fermarsi», «quante task completate», «risultato pronto». Appena compare un invariante su più variabili (per esempio, size deve corrispondere alla reale dimensione della coda e bisogna muovere elementi in modo sicuro), l'atomic non basta. Serve un mutex (o strutture più complesse, che qui non tocchiamo).

La sintassi minima è:

#include <atomic>

int main() {
    std::atomic<bool> ready{false};
    std::atomic<int>  progress{0};
}

Quasi sempre, negli esempi didattici (e in molti casi pratici) usiamo atomic con ordering di memoria predefinito (in pratica: «fallo in modo sicuro e prevedibile»). Non è l'opzione più veloce, ma è la più chiara — e ora ci interessa soprattutto evitare che il codice diventi meccanica quantistica sotto scadenza.

2. Operazioni minime dell'atomic

Quando vedete per la prima volta std::atomic<int>, viene voglia di trattarlo come un normale int: scrivere x++, if (x), x = 5. Spesso funziona, ma a fini didattici è utile partire dalle operazioni esplicite, perché mostrano chiaramente l'intento: lettura, scrittura, incremento atomico, sostituzione atomica.

Di seguito il «vocabolario del giorno». È praticamente il set con cui potete risolvere la maggior parte dei casi flag/contatore.

Operazione Cosa fa Cosa ritorna Senso tipico
a.load()
legge atomically il valore
T
«controllare il flag / leggere il contatore»
a.store(v)
scrive atomically il valore
void
«impostare il flag / memorizzare un nuovo valore»
a.fetch_add(k)
aggiunge atomically k vecchio valore T «incrementare il contatore di progresso»
a.exchange(v)
sostituisce atomically con v vecchio valore T «eseguire un'azione esattamente una volta»

load() e store()

Iniziamo con calma: lettura e scrittura.

#include <atomic>
#include <iostream>

int main() {
    std::atomic<bool> ready{false};

    ready.store(true);
    bool v = ready.load();

    std::cout << std::boolalpha << v << '\n'; // true
}

Qui conta meno il risultato (banale) e più il contratto: entrambi i thread possono fare load/store senza data race.

fetch_add() per un contatore

Ora un esempio più utile — il contatore.

#include <atomic>
#include <iostream>

int main() {
    std::atomic<int> counter{0};

    counter.fetch_add(1);
    counter.fetch_add(1);

    std::cout << counter.load() << '\n'; // 2
}

La differenza principale rispetto a un semplice counter = counter + 1 è che fetch_add è un'operazione atomica singola, non «leggi → aggiungi → scrivi» in tre passi che due thread possono facilmente intrecciare.

exchange() come «chi prima arriva, meglio alloggia»

A volte serve che un pezzo di codice venga eseguito esattamente una volta (per esempio, «inizializzazione una tantum», «stampa di un messaggio una sola volta»). In questi casi exchange è molto comodo.

#include <atomic>
#include <iostream>

int main() {
    std::atomic<bool> started{false};

    if (!started.exchange(true)) {
        std::cout << "first\n";   // first
    } else {
        std::cout << "already\n";
    }
}

exchange(true) imposta atomically il flag e ritorna il valore precedente. Se il precedente era false, siete il primo. Se era true, qualcuno è arrivato prima.

3. std::atomic<bool>: stop-flag e readiness

atomic<bool> è una specie di semaforo minimal: rosso/verde. È molto apprezzato perché è leggibile e sembra più amichevole del combinato mutex + condition_variable. Ma attenzione a non confondere: atomic riguarda l'accesso corretto, non l'attesa.

Quindi atomic<bool> risponde bene alla domanda «si può procedere?» o «è ora di fermarsi?», ma da solo non sa «sospendere un thread fino a un evento». Se fate un while (!flag.load()) {} otterrete busy-wait: il thread consumerà CPU come uno studente affamato davanti a pizza gratis.

Stop-flag semplice

Schema di base: un thread lavora, l'altro chiede di fermarsi.

#include <atomic>
#include <chrono>
#include <thread>

int main() {
    std::atomic<bool> stop{false};

    std::jthread worker([&] {
        while (!stop.load()) {
            std::this_thread::sleep_for(std::chrono::milliseconds{10});
        }
    });

    std::this_thread::sleep_for(std::chrono::milliseconds{50});
    stop.store(true);
}

Notate sleep_for: non è lì per bellezza, ma per evitare che il ciclo diventi un «grill per la CPU». È un pattern didattico pratico; in sistemi reali è meglio attendere tramite condition_variable, oppure usare stop_token con jthread, o meccanismi più avanzati.

Readiness del risultato

Un altro scenario classico: un thread prepara un risultato, l'altro aspetta che sia pronto.

#include <atomic>
#include <chrono>
#include <thread>

int main() {
    std::atomic<bool> ready{false};

    std::jthread producer([&] {
        std::this_thread::sleep_for(std::chrono::milliseconds{30});
        ready.store(true);
    });

    while (!ready.load()) {
        std::this_thread::sleep_for(std::chrono::milliseconds{5});
    }
}

Questa è una dimostrazione dell'idea, non dell'architettura ideale per l'attesa. Se l'attesa è parte cruciale della logica, condition_variable è spesso preferibile perché sa «dormire fino alla notifica», invece di «risvegliarsi ogni 5 ms e chiedere».

4. std::atomic<int>: progresso e contatori

Se atomic<bool> è il semaforo, atomic<int> è il contatore al tornello: quante persone sono passate. È utile quando thread diversi fanno lavori indipendenti e dovete solo contare eventi: «quante task completate», «quanti byte scaricati», «quanti errori».

Incremento concorrente del contatore

Dimostrazione classica: diversi thread incrementano lo stesso contatore e alla fine verifichiamo che non si perda nulla.

#include <atomic>
#include <thread>
#include <vector>
#include <iostream>

int main() {
    std::atomic<int> counter{0};

    {
        std::vector<std::jthread> threads;
        for (int i = 0; i < 4; ++i) {
            threads.emplace_back([&] {
                for (int n = 0; n < 1000; ++n) {
                    counter.fetch_add(1);
                }
            });
        }
    } // jthread si unisce automaticamente

    std::cout << counter.load() << '\n'; // 4000
}

Il punto chiave: abbiamo protetto esattamente una variabile, e questo è sufficiente. Se al posto di fetch_add avessimo scritto counter = counter + 1, il risultato avrebbe potuto essere inferiore a 4000, perché le operazioni «leggi/aggiungi/scrivi» si sovrappongono.

Perché atomic non sostituisce il mutex

Supponiamo di voler aggiornare simultaneamente due variabili: doneTasks e doneBytes, e di avere il vincolo «se doneTasks aumenta, doneBytes deve aumentare anch'esso». Questo è un invariante su più valori. Con atomic separati non garantite la consistenza della coppia come stato unico: un altro thread può vedere «nuovo doneTasks, vecchio doneBytes». Se questo è inaccettabile — serve un mutex attorno alla «transazione» di modifica condivisa.

5. Coda di task e progresso: mischiamo mutex, cv e atomic

Finora abbiamo trattato atomic come esempi isolati. Ora uniamo gli strumenti per lo scopo reale: implementare correttamente producer–consumer. Abbiamo la coda di task, il worker, l'attesa con condition_variable, e la terminazione corretta. Gli atomic li usiamo per ciò in cui sono ideali: progresso e flag semplici letti senza lock.

Immaginate la nostra piccola applicazione console: il thread principale mette numeri in una coda, il worker «li processa» (per esempio calcola il quadrato), e il main a volte vuole mostrare il progresso: quante task sono già state fatte.

Bozza dello stato condiviso

#include <atomic>
#include <condition_variable>
#include <mutex>
#include <queue>

struct SharedState {
    std::mutex m;
    std::condition_variable cv;
    std::queue<int> tasks;

    std::atomic<int> done{0};
    std::atomic<bool> stop{false};
};

Qui la coda è protetta dal mutex (perché è uno stato complesso), mentre done/stop sono atomic, essendo variabili indipendenti che vogliamo leggere senza lock.

Worker: aspetta, prendi, processa, incrementa il contatore

#include <chrono>
#include <thread>

void workerLoop(SharedState& s) {
    while (!s.stop.load()) {
        int x = 0;

        {   // sezione critica: solo per la coda
            std::unique_lock lk{s.m};
            s.cv.wait(lk, [&] { return s.stop.load() || !s.tasks.empty(); });

            if (s.stop.load()) return;

            x = s.tasks.front();
            s.tasks.pop();
        }

        std::this_thread::sleep_for(std::chrono::milliseconds{5}); // "lavoro"
        s.done.fetch_add(1);
    }
}

Notate la composizione importante: la condition_variable attende con un predicate che considera sia «la coda non è vuota» sia «è arrivata la stop». Non facciamo una verifica separata di stop per evitare rare latenze alla terminazione.

Main: aggiungiamo task e osserviamo il progresso

#include <iostream>
#include <thread>

int main() {
    SharedState s;

    std::jthread w([&] { workerLoop(s); });

    {   // mettiamo 20 task
        std::lock_guard lg{s.m};
        for (int i = 0; i < 20; ++i) s.tasks.push(i);
    }
    s.cv.notify_one();

    while (s.done.load() < 20) {
        std::cout << "done = " << s.done.load() << '\n'; // per esempio: done = 7
        std::this_thread::sleep_for(std::chrono::milliseconds{20});
    }

    s.stop.store(true);
    s.cv.notify_one();
}

Questo frammento mostra l'idea: il progresso si legge senza mutex. La coda rimane protetta dal mutex, mentre done si legge con load().

Sì, la stampa su console ha anche le sue sottigliezze in ambiente multithread, ma qui stampa solo il thread principale, quindi evitiamo sincronizzazioni extra per semplicità d'esempio.

6. Come scegliere: stop_token, condition_variable o atomic<bool>

Quando in testa avete tre meccanismi diversi per «stop/segnale», è facile confondersi e scegliere per «quello che si scrive più corto». In multithreading è meglio scegliere in base al ruolo, altrimenti otterrete presto o busy-wait affamato o il classico «perché non si risveglia».

Di seguito una mappa pratica di scelta. Leggetela non come «tre modi per fare la stessa cosa», ma come «tre strumenti per compiti diversi».

Strumento Miglior uso Cosa fornisce in modo fondamentale Tipico anti-caso
std::stop_token (di solito con std::jthread) Terminazione cooperativa di lavori a lunga durata Protocollo standardizzato di «richiesta di stop», leggibile nell'API Usarlo come «coda di eventi» o «segnale di readiness dei dati»
mutex + condition_variable Aspettare «finché non cambia uno stato complesso» (coda, più campi, invarianti) Aspettativa bloccante senza consumo di CPU + protezione dello stato complesso Sostituirli con «while(!flag) sleep» dove c'è una coda o stato reale
std::atomic<bool> Flag molto semplice thread-safe «sì/no» Lettura/scrittura economica di una variabile senza mutex Usarlo per aspettare eventi con tight loop (busy-wait) o per «proteggere un container»

Quasi in parole umane: stop_token è per «chiedere gentilmente di fermarsi»; condition_variable è per «dormire finché non arriva lavoro/evento»; atomic<bool> è per «mantenere correttamente un flag condiviso», ma non necessariamente per l'attesa.

Un altro criterio importante: se dovete proteggere più valori correlati o una struttura dati (coda, vector, più campi che devono cambiare insieme) — scegliete mutex. Gli atomic sono ottimi quando la variabile è autonoma e non serve una consistenza complessa.

7. Errori comuni con std::atomic

Errore №1: mescolare accessi atomici e non atomici alla stessa variabile.
Trappola molto comune: «qui leggo con flag.load(), lì scrivo semplicemente flag = true, sono la stessa cosa». Nella pratica questo rompe il contratto e riporta alla data race. Se una variabile è diventata atomic — tutti gli accessi devono passare da operazioni atomiche (o da operatori sovraccaricati corretti, ma è meglio abituarsi a load/store).

Errore №2: cercare di «proteggere un container con un atomic».
A volte si tenta di avere std::atomic<int> size e pensare che la coda/vector siano ora «quasi thread-safe». Non è così. Un container è tanti campi e invarianti; atomic protegge una variabile, non uno stato complesso. Per i container serve ancora mutex, ed è giusto così: è uno strumento chiaro e onesto.

Errore №3: trasformare atomic<bool> in busy-wait.
Codice come while (!ready.load()) {} sembra minimalista ma in realtà grava la CPU e in programmi reali diventa «perché il mio portatile si scalda mentre aspetto?». Se state veramente aspettando un evento — di solito serve una condition_variable per lasciare il thread in sleep e risvegliarlo su notifica.

Errore №4: aspettarsi che atomic « renda l'intero programma thread-safe ».
Atomic non è acqua santa da spruzzare sul progetto. Risolve un problema concreto: operazioni corrette su una variabile specifica. Se ci sono altri dati condivisi (coda, stringa, vector, più campi), servono altre sincronie. Accettare questo rende il codice più calmo e prevedibile.

Errore №5: dimenticare che gli atomic di solito non si copiano.
std::atomic<T> è un tipo speciale: la copia spesso è proibita (o limitata) intenzionalmente. Quindi se cercate di «passare l'atomic per valore» o «metterlo in una struct che poi copiate», il compilatore può restituire errore. In questi casi si passa di solito per riferimento (std::atomic<int>&) o si mantiene l'atomic in un luogo unico e si forniscono riferimenti/puntatori secondo contratto.

1
Compito
C++ SELF, livello 71, lezione 4
Bloccato
Interruttore atomico
Interruttore atomico
1
Compito
C++ SELF, livello 71, lezione 4
Bloccato
Contachilometri atomico
Contachilometri atomico
1
Compito
C++ SELF, livello 71, lezione 4
Bloccato
Incremente a squadra
Incremente a squadra
1
Compito
C++ SELF, livello 71, lezione 4
Bloccato
Queue con progresso
Queue con progresso
1
Sondaggio/quiz
async/future/promise, livello 71, lezione 4
Non disponibile
async/future/promise
async/future/promise
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION