CodeGym /Corsi /C++ SELF /std::memory_order: seq_cst come impostazione predefinita

std::memory_order: seq_cst come impostazione predefinita

C++ SELF
Livello 72, Lezione 2
Disponibile

1. Perché esiste memory_order: atomicità ≠ ordine

Se il mondo fosse giusto, std::atomic farebbe automaticamente tutto correttamente, sempre e ovunque. Ma allora metà dei libri sulla concorrenza perderebbero senso, e gli autori di quei libri inizierebbero a scrivere d'altro. Nel C++ reale gli atomic dividono due idee: atomicità e ordinamento, e memory_order controlla principalmente la seconda.

L'atomicità riguarda l'«operazione indivisibile»: nessuno vedrà una «metà» di una scrittura, e due thread non «strapperanno» il valore in pezzi. L'ordinamento riguarda altro: quali operazioni il compilatore/processore può riallocare nell'ordine, e quali modifiche un thread è obbligato a vedere «insieme» ad altre modifiche.

Immaginate di avere "dati" e un "flag di prontezza". Volete fare così: prima scrivete i dati, poi alzate il flag, e un altro thread vede il flag e comincia a leggere i dati. Suona logico. Il problema è che senza le garanzie di ordinamento necessarie l'altro thread può vedere il «flag già alzato», ma i dati ancora «apparire vecchi». E questo potrebbe non essere un bug dell'hardware — potrebbe essere un'ottimizzazione legale.

In forma di diagramma (molto semplificato) l'idea è questa:

flowchart LR
  A[Thread 1: scrive dati] --> B[Thread 1: imposta il flag]
  C[Thread 2: vede il flag] --> D[Thread 2: legge i dati]
  note1[[Se scegliete il memory_order sbagliato, il thread 2 può vedere il flag ma non i dati "correlati".]]
  B -.-> note1

Importante: oggi non costruiamo protocolli complessi "dati + flag". Il nostro obiettivo è più semplice e pragmatico: capire perché per default è sensato vivere in seq_cst, e perché relaxed va bene solo dove siete certi di non usare l'atomic come «segnale» per altri dati.

2. Dove si trova memory_order nell'API e cosa significa «predefinito»

Quando vedete per la prima volta memory_order, sembra un «parametro superfluo per i pignoli». In pratica è una scelta altrettanto importante di quella tra «mutex o niente mutex», solo più sottile. La buona notizia: la libreria standard ha scelto per default l'opzione più sicura — e dovreste usarla finché non avete grande fiducia nel vostro protocollo.

std::memory_order è un enum (nelle versioni moderne di C++ è uno scoped enum, quindi i valori si scrivono come std::memory_order::seq_cst, std::memory_order::relaxed ecc.).

Quasi tutte le operazioni atomiche hanno un overload che accetta l'ordine di memoria. Per esempio (semplificando il senso):

  • x.store(value) e x.store(value, order)
  • x.load() e x.load(order)
  • x.fetch_add(1) e x.fetch_add(1, order)

E qui il punto chiave della lezione: se non avete specificato order, viene usata la modalità più rigorosa e intuitiva — seq_cst.

Visivamente:

#include <atomic>

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

    x.store(10);                       // equivalente a store(10, seq_cst)
    int v = x.load();                  // equivalente a load(seq_cst)

    (void)v;
}

Questa "impostazione predefinita" non è casuale. È letteralmente un salvagente incorporato per i principianti e per il codice applicativo "ordinario", dove la correttezza e la leggibilità sono più importanti che spremere l'ultimo percento di performance dal protocollo di sincronizzazione.

3. memory_order::seq_cst: la modalità più intuitiva

Se avete mai visto codice con memory_order::relaxed ovunque, sappiate: da qualche parte un sanitizer sta piangendo. seq_cst (sequentially consistent) è la modalità che offre il modello più semplice: le operazioni atomiche appaiono come se fossero ordinate in un unico ordine globale, comprensibile da tutti i thread.

La parola "come se" è importante: nella realtà l'hardware può fare cose astute, ma lo standard vi garantisce un modello che rende il ragionamento il più lineare possibile. Per l'apprendimento e per la maggior parte del codice applicativo questo è un grande vantaggio: meno magia — meno «perché si rompe solo il venerdì».

Guardiamo un esempio semplice: due thread incrementano lo stesso contatore. Con std::atomic<int> questo è corretto, mentre con un normale int si ha data race e comportamento indefinito.

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

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

    std::jthread t1([&] { x.fetch_add(1); });  // seq_cst per default
    std::jthread t2([&] { x.fetch_add(1); });  // seq_cst per default

    std::cout << x.load() << '\n';             // nella maggior parte dei casi: 2  (e formalmente corretto)
}

Perché qui è "formalmente corretto"? Perché fetch_add è un'operazione atomica RMW. E dato che non abbiamo specificato l'ordine, si usa seq_cst, cioè la modalità più "forte" e più semplice da ragionare.

Ora una riflessione pratica: quasi mai perderete qualcosa iniziando con seq_cst. Perderete (in termini di tempo e nervi) se iniziate con relaxed e poi risulterà che avete usato quell'atomic come parte di un protocollo (per esempio, come segnale "dati pronti").

4. memory_order::relaxed: quando basta "solo atomicità"

memory_order::relaxed suona allettante, specialmente quando siete stanchi e volete "rilassarvi". Ma in C++ non è psicologia, è garanzie: relaxed dà atomicità dell'operazione su quella variabile, ma quasi non dà garanzie sull'ordine rispetto ad altri letture/scritture (incluse variabili non atomiche).

Per non fraintendere, tenete a mente questa frase: relaxed = "onestamente atomico, ma senza promessa che gli altri eventi del mondo si allineino in modo bello".

Lo scenario classico per relaxed sono i contatori statistici. Per esempio, "quante richieste sono state processate", "quanti task eseguiti", "quante volte l'utente ha premuto un bottone". In questi casi solitamente non serve che la lettura del contatore "garantisca la visibilità" di altri dati. Serve solo che gli incrementi non vadano persi e non ci sia UB.

Esempio minimo:

#include <atomic>

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

    hits.fetch_add(1, std::memory_order::relaxed);
    hits.fetch_add(1, std::memory_order::relaxed);

    // Importante: hits aumenta correttamente, ma non è un "segnale" per altri dati.
}

Notate che qui diciamo consapevolmente: "mi serve solo un incremento concorrente corretto". Se nella vostra testa c'è anche un piccolo "e poi da questo flag capisco che posso leggere i dati" — è già una bandiera rossa: relaxed probabilmente non vi va bene.

5. Esempio pratico: mini‑TaskRunner

Per non rimanere astratti, proseguiamo la nostra linea didattica "l'app cresce durante il corso". Prima apparivano i thread, jthread, attese, e piano piano imparavamo a costruire un piccolo "executor di task". Oggi non costruiamo una coda lock‑free né giochiamo con protocolli di pubblicazione dati — aggiungeremo semplicemente delle statistiche che si possono aggiornare concorrentemente e a basso costo.

Partiamo dall'idea: abbiamo un worker thread che "processa task" (per esempio — semplicemente scorre un loop). Dobbiamo contare quante task sono state processate. Questo contatore è una metrica tipica, e per esso relaxed è spesso sufficiente.

Prima dichiariamo il contatore atomico:

#include <atomic>

struct Stats {
    std::atomic<int> processed{0};  // quante "task" eseguite
};

Ora la funzione worker: fa lavoro e incrementa il contatore. Notate: scegliamo relaxed proprio perché il contatore non è un segnale di prontezza dei dati, è solo un numero.

#include <atomic>
#include <thread>

void do_work(Stats& stats) {
    for (int i = 0; i < 1'000; ++i) {
        // ... simulazione di lavoro ...
        stats.processed.fetch_add(1, std::memory_order::relaxed);
    }
}

E ora il thread principale vuole ogni tanto "sbirciare" le statistiche e stamparle. Poiché è solo osservazione di una metrica, anche la lettura può essere relaxed:

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

void print_stats(const Stats& stats) {
    using namespace std::chrono_literals;

    int v = stats.processed.load(std::memory_order::relaxed);
    std::cout << "processed=" << v << '\n';  // per esempio: processed=1000
    std::this_thread::sleep_for(50ms);
}

Infine, mettiamo insieme il piccolo main. Qui è importante che non costruiamo un protocollo complesso di stop tramite atomic. Lanciamo il thread, aspettiamo la terminazione (RAII di std::jthread ci aiuta) e alla fine stampiamo il risultato.

#include <iostream>
#include <thread>

int main() {
    Stats stats;

    std::jthread worker([&] { do_work(stats); });

    // Mentre worker lavora, possiamo leggere la metrica qualche volta.
    print_stats(stats);

    // Quando main finisce, jthread aspetterà la terminazione di worker.
    std::cout << "final=" << stats.processed.load() << '\n'; // final=1000
}

Cosa è importante capire dall'esempio. Noi non usiamo processed come "flag": non diciamo "se processed > 0 allora i dati sono pronti". Non leggiamo strutture che l'altro thread modifica senza sincronizzazione. Contiamo solo. Perciò relaxed qui è una scelta sensata.

Se invece voleste fare: "quando il contatore raggiunge 1000 allora è sicuro leggere l'array dei risultati che il worker ha riempito" — questo è un altro tipo di problema. In tali casi un singolo relaxed di solito non basta, e nel corso suggeriremmo di scegliere una sincronizzazione più adatta (per esempio mutex/condition_variable) o un altro protocollo. Qui ne indichiamo solo la porta, senza aprirla.

Mini‑tabella di scelta: seq_cst vs relaxed

In un progetto reale memory_order spesso diventa una «religione»: qualcuno mette relaxed per velocità, qualcuno proibisce tutto tranne seq_cst per dormire sonni tranquilli. A livello didattico è più utile non la religione ma una piccola mappa per non perdersi.

Ordine di memoria Cosa garantisce (in parole semplici) Scenario tipico Rischio principale
std::memory_order::seq_cst
«Il modello più rigoroso e comprensibile»: gli atomic si comportano nel modo più prevedibile tra i thread; usato per default se l'ordine non è specificato. Flag di stato, contatori, sincronizzazione semplice "solo per essere corretti" Di solito non è un rischio, ma può avere un costo in performance e/o essere ridondante
std::memory_order::relaxed
«Solo atomicità di quella variabile», senza promesse sull'ordine rispetto ad altri dati Metriche, statistiche, contatori "quante volte è successo" Usarlo come segnale di prontezza dei dati e incorrere in bug rari, cattivi e difficili da riprodurre

Se non avete una ragione chiara per scegliere relaxed, la ragione è quasi sempre: "l'ho visto su Internet". Questo, detto piano, non è la miglior fonte per decisioni progettuali.

6. Errori tipici

Errore n.1: mettere relaxed "perché è più veloce", senza capire il protocollo.
Questa è la storia più comune. Il codice comincia a sembrare "professionale", ma diventa imprevedibile a livello professionale. Se non riuscite a spiegare perché vi basta solo atomicità senza ordinamento, più sicuro è lasciare l'ordine di default (seq_cst) e avere codice che si legge senza riti propiziatori.

Errore n.2: usare relaxed per un flag "pronto" e poi leggere dati normali.
I principianti spesso pensano: "il flag è atomico, quindi tutto ciò che è stato scritto prima di lui sarà visto". Questo ragionamento è sbagliato: relaxed non garantisce la "pubblicazione" di altri dati. Ne risulta un bug che può manifestarsi solo su alcune architetture, solo con certe ottimizzazioni, o solo dopo il rilascio in produzione.

Errore n.3: cercare di "risolvere" il problema con dei sleep_for().
Quando qualcosa non è sincronizzato, la tentazione è "dare tempo al thread" e inserire dei ritardi. Questo crea l'illusione di ordine, ma non dà garanzie. Una delay non trasforma un protocollo scorretto in uno corretto — rende solo il bug meno evidente.

Errore n.4: mescolare diversi memory_order su uno stesso atomic senza disciplina rigorosa.
Anche se sapete quello che fate, codice con ordini diversi su load()/store() diventa rapidamente illeggibile. Per il corso, una buona disciplina è: o vivete ovunque nel default, oppure usate relaxed solo in "isolotti di statistica" dove non esistono protocolli di pubblicazione dati.

Errore n.5: pensare che "default" sia qualcosa di debole.
In C++ con gli atomic è il contrario: il comportamento predefinito è il più rigoroso. Quindi se scrivete x.load() senza specificare l'ordine, non è "più o meno", ma un contratto preciso e forte. Questo è fatto apposta, così potete scrivere codice multithread corretto senza diventare esperti del modello di memoria al primo giorno.

1
Compito
C++ SELF, livello 72, lezione 2
Bloccato
Due protocolli
Due protocolli
1
Compito
C++ SELF, livello 72, lezione 2
Bloccato
Metrica delle richieste
Metrica delle richieste
1
Compito
C++ SELF, livello 72, lezione 2
Bloccato
Scanner dei log
Scanner dei log
1
Compito
C++ SELF, livello 72, lezione 2
Bloccato
Due modalità
Due modalità
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION