CodeGym /Corsi /C++ SELF /wait/notify su atomic e std::atomic_flag

wait/notify su atomic e std::atomic_flag

C++ SELF
Livello 72, Lezione 4
Disponibile

1. Perché atomic::wait, se esiste lo spin-wait

Se avete già provato a implementare l'attesa di un flag atomico con un ciclo del tipo while (!ready) { } — complimenti: avete scritto uno spin-wait. Funziona davvero… a volte… ma può bruciare un core CPU come se steste minando criptovalute su un portatile in biblioteca. Si vuole imparare ad aspettare un evento in modo che il thread dorma, invece di «sbattere nervosamente le palpebre».

Immaginiamo il tipico spin-wait "didattico":

#include <atomic>
#include <thread>

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

    while (!ready.load()) {
        std::this_thread::yield(); // "cedo", ma comunque continuiamo a girare
    }
}

Sembra che abbiamo aggiunto anche yield(), ma in sostanza il thread continua a controllare costantemente il flag. A volte questo è accettabile in scenari molto low‑level, ma per un'applicazione normale è quasi sempre uno spreco di risorse.

In C++20 gli atomic hanno ottenuto una via più civile: aspettare il cambiamento di valore tramite wait() e svegliare gli attender tramite notify_one()/notify_all(). L'idea è semplice: «finché il valore è X — dormi; quando non è più X — svegliati».

Una piccola tabella di riferimento:

Cosa facciamo Con cosa Percepito come
Aspettiamo in un ciclo spin-wait (while(load())) «La CPU si scalda, la ventola canta»
Aspettiamo una condizione complessa (più variabili) mutex + condition_variable «Affidabile, ma richiede disciplina con il mutex»
Aspettiamo il cambiamento di una sola variabile atomica atomic::wait + notify «Minimo codice, il thread è realmente bloccato»

2. Come funziona atomic::wait(expected) e notify_*

Con wait() spesso ci si confonde per una sottigliezza: esso non aspetta "che diventi expected". Esso aspetta «finché è uguale a expected». Cioè è letteralmente il comando: «Se ora il valore è uguale a expected, mi metto a dormire fino a quando non cambia».

Un esempio minimale per fissare il concetto:

#include <atomic>
#include <thread>

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

    state.wait(0);   // dormirà finché state == 0
    // qui arriviamo quando state != 0 (o in caso di false wakeup)
}

Come appare il "segnale" dall'altro thread: prima cambiamo il valore, poi svegliamo.

#include <atomic>

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

    ready.store(true);
    ready.notify_one(); // oppure notify_all()
}

Qui è importante la disciplina: prima cambiare lo stato, poi notify. notify_* non è un modo per «trasmettere magicamente il valore», ma piuttosto un «colpetto: svegliati e ricontrolla la condizione». Molto simile alla filosofia di condition_variable, e non è un caso.

Un piccolo diagramma di "come dovrebbe andare":

sequenceDiagram
    participant W as Worker
    participant A as atomic<T>
    participant M as Main

    M->>A: wait(expected)
    Note over M: dorme finché A == expected
    W->>A: store(new_value)
    W->>A: notify_one/notify_all
    M-->>A: svegliato e ha ricontrollato il valore

3. Pattern affidabile: wait() quasi sempre vive dentro un while

wait() rende l'attesa comoda, ma non annulla la vecchia regola: qualsiasi attesa deve ricontrollare la condizione. La ragione è la stessa di condition_variable: ci sono i "false wakeup", race temporali, e anche la situazione in cui il valore è già cambiato prima che abbiate chiamato wait(). Se fate una singola chiamata a wait() senza verifica, il codice può risultare fragile.

Il pattern affidabile è così: prima controlliamo la condizione con load(), e se non è ancora soddisfatta chiamiamo wait() con il valore atteso.

#include <atomic>

void wait_until_ready(std::atomic<bool>& ready) {
    while (!ready.load()) {
        ready.wait(false); // dormiamo finché ready == false
    }
}

Notate il "doppio passo": load() + wait(false). Sembra ridondante, ma è come la cintura di sicurezza. Se ready è già true, wait(false) non aspetterà (perché il valore non è false) — e usciamo rapidamente.

Quasi la stessa cosa si può fare con uno "status" in std::atomic<int>:

#include <atomic>

void wait_until_done(std::atomic<int>& state) {
    while (state.load() != 2) {
        state.wait(0); // aspettiamo finché state non è più 0 (o ci svegliamo per sbaglio)
    }
}

Sì, l'esempio è volutamente un po' "storto": aspettiamo che si esca da 0, e poi comunque controlliamo != 2. Questo mostra la realtà: wait() non è "aspettare esattamente il valore desiderato", ma "non consumare CPU finché il valore non cambia".

4. Mini‑esempio: job runner e readiness tramite wait/notify

Per non restare nella teoria, proseguiamo con una piccola "mini‑applicazione": un thread calcola il risultato, un altro aspetta che il risultato sia pronto e lo stampa. È vicino a scenari reali: «lavoro in background → segnale di readiness → UI/thread principale continua».

Prima definiamo la struttura condivisa. Importante: il risultato sum è un campo normale, non atomico. Quindi serve un protocollo di pubblicazione tramite il flag atomico ready.

#include <atomic>
#include <vector>

struct Job {
    std::vector<int> data;
    long long sum = 0;           // NON atomico
    std::atomic<bool> ready{false};
};

Ora il thread worker: calcola la somma, scrive sum, poi alza il flag e chiama notify_one(). Qui è utile ricordare acquire/release: «prima i dati → poi il flag».

#include <atomic>
#include <numeric>
#include <thread>

void run_job(Job& job) {
    job.sum = std::accumulate(job.data.begin(), job.data.end(), 0LL);
    job.ready.store(true, std::memory_order_release);
    job.ready.notify_one();
}

Il thread principale aspetta. Non facciamo un loop con while e yield, ma ci blocchiamo con wait(false).

#include <atomic>
#include <iostream>

void print_result(Job& job) {
    while (!job.ready.load(std::memory_order_acquire)) {
        job.ready.wait(false);
    }
    std::cout << job.sum << '\n'; // ad esempio: 15
}

E infine, componiamo tutto in main() — molto breve:

#include <thread>

int main() {
    Job job{{1, 2, 3, 4, 5}};

    std::jthread worker([&] { run_job(job); });
    print_result(job);
}

Qui otteniamo una composizione pulita di tre idee. Il risultato viene calcolato in background, la pubblicazione avviene tramite uno store con release sul flag, l'attesa non brucia CPU, e la lettura del risultato dopo un acquire-load cattura correttamente i dati scritti.

5. atomic<int>::wait: aspettare cambi di stato e progresso

A volte un flag booleano non basta: si vuole sapere in quale stato si trova il lavoro. Per questo è comodo usare std::atomic<int> o std::atomic<enum> (ma enum è un po' più complesso per i principianti), e aspettare che lo stato cambi.

Immaginiamo un semplice contratto di stati:

Valore state Senso
0 non ancora avviato
1 in esecuzione
2 completato

Il worker avanza lo stato a passi e sveglia gli attender:

#include <atomic>

void set_state(std::atomic<int>& state, int v) {
    state.store(v);
    state.notify_all();
}

L'attender può aspettare "finché non usciamo da 0", e poi "finché non diventa 2". Sì, sono due fasi — ma è leggibile.

#include <atomic>

void wait_started_then_done(std::atomic<int>& state) {
    while (state.load() == 0) state.wait(0);
    while (state.load() != 2) state.wait(1);
}

Nella pratica il secondo wait(1) non garantisce che il valore sia esattamente 1 durante l'attesa (può saltare rapidamente). Perciò qui torna utile ancora una volta il while: ricontrolliamo dopo ogni risveglio.

La stessa idea funziona per il progresso. Per esempio, abbiamo std::atomic<int> progress{0}; (in percentuale). Il thread principale vuole stampare il progresso solo quando cambia, ma senza loop attivo.

#include <atomic>
#include <iostream>

void print_progress(std::atomic<int>& progress) {
    int last = progress.load();
    while (last < 100) {
        progress.wait(last);
        last = progress.load();
        std::cout << "progress=" << last << "%\n"; // progress=30%
    }
}

Questo assomiglia a una "sottoscrizione al cambiamento di valore": aspettare che cambi rispetto al passato e poi stampare il nuovo valore.

6. std::atomic_flag e limiti d'uso di wait/notify

std::atomic_flag: flag minimalista e scenario «fare una volta»

std::atomic_flag è il flag atomico più semplice nella libreria standard. Non è come un "bool con lettura e scrittura" nel senso abituale: il suo metodo principale è test_and_set(), che imposta sempre il flag e ritorna "cosa c'era prima". Questo è molto comodo quando si vuole implementare il comportamento "chi arriva primo esegue, gli altri passano silenziosamente".

atomic_flag ha la reputazione di essere "minimale e spesso lock‑free", e negli standard recenti ha ricevuto un'interfaccia di sincronizzazione più ricca.

Il classico do once:

#include <atomic>
#include <iostream>

int main() {
    std::atomic_flag once = ATOMIC_FLAG_INIT;

    if (!once.test_and_set()) {
        std::cout << "init only once\n"; // init only once
    }
}

Se eseguite questo frammento da due thread (per esempio entrambi provano a "inizializzare il log" o a "mostrare un warning una sola volta"), esattamente un thread vedrà false ed eseguirà l'azione. Gli altri otterranno true e salteranno.

È importante capire psicologicamente: test_and_set() non è "guardare il flag". È "guardare e contemporaneamente impostarlo". Se vi serve solo verificare "il flag è già impostato?", atomic_flag non è lo strumento più comodo (per quello è più usuale std::atomic<bool>).

Possiamo mostrare questo con un esempio un po' più "vivo": vogliamo stampare una volta l'help "job started", anche se più thread ci provano.

#include <atomic>
#include <iostream>

void print_started_once(std::atomic_flag& started_once) {
    if (!started_once.test_and_set()) {
        std::cout << "job started\n"; // job started
    }
}

Se serve "riabilitare" la possibilità, usate clear():

#include <atomic>

int main() {
    std::atomic_flag f = ATOMIC_FLAG_INIT;

    f.test_and_set(); // ora true
    f.clear();        // ora di nuovo false
}

Quando atomic::wait/notify non sostituisce condition_variable

È tentante, dopo aver conosciuto wait/notify, cominciare a sostituire tutto con questi strumenti, perché "meno codice = meglio". Ma bisogna trattenersi. atomic::wait è ideale quando la condizione di attesa si descrive con una singola variabile: "flag di readiness", "status", "versione", "progresso".

Appena la condizione diventa composta (per esempio: "la coda non è vuota OPPURE è arrivato un segnale di stop"), vi troverete rapidamente in un mondo dove una singola variabile atomica non esprime più tutta la logica. Sì, si può codificare tutto in un singolo int con bit, si possono inventare protocolli complessi — ma è la strada verso diventare il proprio committee standard.

In quei casi mutex + condition_variable restano più semplici: proteggete più campi con un mutex, e wait(lock, predicate) fa quel che bisogna—"attesa corretta con ricontrollo"—in un unico pattern. atomic::wait non è il "killer della condition_variable", ma uno strumento leggero per condizioni leggere.

7. Errori tipici con atomic::wait/notify e std::atomic_flag

Errore №1: confondere il significato di wait(expected) con "aspettare che diventi expected".
Se leggete mentalmente wait(false) come "aspettare che sia false", scriverete codice che si blocca proprio quando ve lo aspettate di meno. La lettura corretta: wait(false) significa "dormire finché il valore è false".

Errore №2: chiamare notify_*() prima di aver cambiato il valore.
Se prima "date un colpetto" agli attender e poi cambiate il valore, possono svegliarsi, vedere il vecchio valore, riaddormentarsi — e il segnale è perso. La regola è semplice e quasi noiosa: prima store/exchange/fetch_* (cioè cambiate lo stato), poi notify_one/notify_all.

Errore №3: chiamare un singolo wait() senza il ciclo di ricontrollo.
Anche se pensate che "i false wakeup non ci saranno", la vita trova sempre il modo di sorprendervi. Il pattern affidabile è while (condizione_non_ancora_vera) atomic.wait(expected); — e solo dopo agite.

Errore №4: provare a usare atomic::wait per condizioni complesse (coda, più campi).
Di solito questo finisce con l'introduzione di una variabile "segnale" aggiuntiva o con la costruzione di invarianti complicate, e dopo una settimana nemmeno voi capirete perché a volte si blocca. Se la condizione non è esprimibile con una singola variabile — prendete mutex + condition_variable.

Errore №5: trattare std::atomic_flag come un "bool normale" e tentare di "solo leggere".
atomic_flag::test_and_set() cambia sempre lo stato (imposta true). Se lo chiamate per "controllare", voi stessi impostate il flag e mutate il comportamento del programma. Per un flag "normale" con lettura/scrittura è spesso meglio std::atomic<bool>.

1
Compito
C++ SELF, livello 72, lezione 4
Bloccato
Segnale di prontezza
Segnale di prontezza
1
Compito
C++ SELF, livello 72, lezione 4
Bloccato
Due fasi
Due fasi
1
Compito
C++ SELF, livello 72, lezione 4
Bloccato
Progresso con conferma
Progresso con conferma
1
Compito
C++ SELF, livello 72, lezione 4
Bloccato
Partenza sincronizzata
Partenza sincronizzata
1
Sondaggio/quiz
Approfondimento sugli atomici, livello 72, lezione 4
Non disponibile
Approfondimento sugli atomici
Approfondimento sugli atomici
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION