Ricerca…


Osservazioni

Diversi thread che tentano di accedere alla stessa posizione di memoria partecipano a una corsa di dati se almeno una delle operazioni è una modifica (nota anche come operazione di memorizzazione ). Queste razze di dati causano un comportamento indefinito . Per evitarli è necessario impedire a questi thread di eseguire contemporaneamente operazioni in conflitto.

I primitivi di sincronizzazione (mutex, sezione critica e simili) possono proteggere tali accessi. Il modello di memoria introdotto in C ++ 11 definisce due nuovi modi portatili per sincronizzare l'accesso alla memoria in ambiente multi-thread: operazioni atomiche e recinzioni.

Operazioni atomiche

È ora possibile leggere e scrivere in una determinata posizione di memoria mediante l'utilizzo di operazioni di caricamento atomico e di immagazzinamento atomico . Per comodità questi sono inclusi nella classe template std::atomic<t> . Questa classe racchiude un valore di tipo t ma questa volta i carichi e i depositi sull'oggetto sono atomici.

Il modello non è disponibile per tutti i tipi. I tipi disponibili sono specifici dell'implementazione, ma di solito includono la maggior parte (o tutti) i tipi integrali disponibili e i tipi di puntatore. Quindi std::atomic<unsigned> e std::atomic<std::vector<foo> *> dovrebbero essere disponibili, mentre std::atomic<std::pair<bool,char>> molto probabilmente non lo sarà.

Le operazioni atomiche hanno le seguenti proprietà:

  • Tutte le operazioni atomiche possono essere eseguite simultaneamente da più thread senza causare un comportamento indefinito.
  • Un carico atomico vedrà sia il valore iniziale con cui è stato costruito l'oggetto atomico, sia il valore scritto su di esso tramite un'operazione di immagazzinamento atomico .
  • Le memorie atomiche sullo stesso oggetto atomico vengono ordinate allo stesso modo in tutti i thread. Se un thread ha già visto il valore di un'operazione dell'archivio atomico , le successive operazioni di caricamento atomico vedranno lo stesso valore o il valore memorizzato dall'operazione successiva dell'archivio atomico .
  • Le operazioni atomiche di lettura-modifica-scrittura consentono al carico atomico e all'archivio atomico di accadere senza altri depositi atomici nel mezzo. Ad esempio si può incrementare atomicamente un contatore da più thread e nessun incremento andrà perso indipendentemente dalla contesa tra i thread.
  • Le operazioni atomiche ricevono un parametro facoltativo std::memory_order che definisce quali proprietà aggiuntive l'operazione ha rispetto ad altre posizioni di memoria.
std :: memory_order Senso
std::memory_order_relaxed senza ulteriori restrizioni
std::memory_order_releasestd::memory_order_acquire se load-acquire vede il valore memorizzato da store-release quindi memorizza il sequenziamento prima che lo store-release avvenga prima che i carichi siano sequenziati dopo che il carico acquisisce
std::memory_order_consume come memory_order_acquire ma solo per carichi dipendenti
std::memory_order_acq_rel combina load-acquire e store-release
std::memory_order_seq_cst consistenza sequenziale

Questi tag di ordine di memoria consentono tre diverse discipline di ordinamento della memoria: coerenza sequenziale , rilassato e acquisizione-acquisizione con il suo rilascio-consumo di fratello.

Consistenza sequenziale

Se non viene specificato un ordine di memoria per un'operazione atomica, l'ordine assume come impostazione predefinita la coerenza sequenziale . Questa modalità può anche essere selezionata in modo esplicito taggando l'operazione con std::memory_order_seq_cst .

Con questo ordine nessuna operazione di memoria può attraversare l'operazione atomica. Tutte le operazioni di memoria sequenziate prima dell'operazione atomica avvengono prima dell'operazione atomica e l'operazione atomica avviene prima di tutte le operazioni di memoria che vengono sequenziate dopo di essa. Questa modalità è probabilmente la più facile da ragionare, ma porta anche alla peggiore penalizzazione delle prestazioni. Inoltre impedisce tutte le ottimizzazioni del compilatore che potrebbero altrimenti tentare di riordinare le operazioni dopo l'operazione atomica.

Ordinamento rilassato

L'opposto alla coerenza sequenziale è l'ordine di memoria rilassato . È selezionato con il tag std::memory_order_relaxed . L'operazione atomica rilassata non imporrà restrizioni sulle altre operazioni di memoria. L'unico effetto che rimane è che l'operazione è di per sé ancora atomica.

Rilascio-Acquisisci ordini

Un'operazione di archivio atomico può essere contrassegnata con std::memory_order_release e un'operazione di caricamento atomico può essere contrassegnata con std::memory_order_acquire . La prima operazione è chiamata (atomica) store-release mentre la seconda è chiamata (atomic) load-acquire .

Quando load-acquire vede il valore scritto da un store-release accade quanto segue: tutte le operazioni di memorizzazione sequenziate prima che lo store-release diventino visibili a ( accadono prima ) le operazioni di caricamento che sono sequenziate dopo l' acquisizione del carico .

Le operazioni atomiche di lettura-modifica-scrittura possono anche ricevere il tag cumulativo std::memory_order_acq_rel . Questo rende la porzione carico atomica dell'operazione un atomico carico acquisiscono mentre la porzione negozio atomico diventa deposito release atomico.

Il compilatore non può spostare le operazioni del negozio dopo un'operazione di rilascio dello store atomico . Inoltre, non è consentito spostare le operazioni di carico prima che il carico atomico acquisisca (o carichi-consumi ).

Si noti inoltre che non esiste il rilascio del carico atomico o l'acquisizione del negozio atomico . Il tentativo di creare tali operazioni li rende operazioni rilassate .

Rilascio-Consuma ordine

Questa combinazione è simile a release-acquire , ma questa volta il carico atomico viene etichettato con std::memory_order_consume e diventa (atomico) carico-consumo . Questa modalità è la stessa di Release-Acquisisci con la sola differenza che tra le operazioni di caricamento in sequenza dopo il carico-consumano solo queste, in base al valore caricato dal carico-consumo, vengono ordinate.

Recinzioni

Le fence consentono inoltre di ordinare le operazioni di memoria tra i thread. Una recinzione è una barriera di rilascio o un recinto.

Se un recinto di rilascio si verifica prima di un recinto di acquisizione, memorizza i sequenziati prima che il recinto di rilascio sia visibile ai carichi sequenziati dopo il recinto di acquisizione. Per garantire che il recinto di rilascio avvenga prima del recinto di acquisizione, si possono usare altre primitive di sincronizzazione che includono operazioni atomiche rilassate.

Hai bisogno di un modello di memoria

int x, y;
bool ready = false;

void init()
{
  x = 2;
  y = 3;
  ready = true;
}
void use()
{
  if (ready)
    std::cout << x + y;
}

Un thread chiama la funzione init() mentre un altro thread (o gestore di segnale) chiama la funzione use() . Ci si potrebbe aspettare che la funzione use() stampi 5 o non faccia nulla. Questo potrebbe non essere sempre il caso per diversi motivi:

  • La CPU può riordinare le scritture che si verificano in init() modo che il codice che esegue effettivamente possa apparire come:

    void init()
    {
      ready = true;
      x = 2;
      y = 3;
    }
    
  • La CPU può riordinare le letture che si verificano in use() modo che il codice effettivamente eseguito possa diventare:

    void use()
    {
      int local_x = x;
      int local_y = y;
      if (ready)
        std::cout << local_x + local_y;
    }
    
  • Un compilatore C ++ ottimizzante può decidere di riordinare il programma in modo simile.

Tale riordino non può cambiare il comportamento di un programma in esecuzione in thread singolo perché un thread non può intercalare le chiamate a init() e use() . D'altra parte in una impostazione multi-threaded un thread può vedere parte delle scritture eseguite dall'altro thread dove può accadere che use() possa vedere ready==true e garbage in x o y o entrambi.

Il modello di memoria C ++ consente al programmatore di specificare quali operazioni di riordino sono consentite e quali no, in modo che anche un programma multi-thread possa comportarsi come previsto. L'esempio sopra può essere riscritto in modo thread-safe come questo:

int x, y;
std::atomic<bool> ready{false};

void init()
{
  x = 2;
  y = 3;
  ready.store(true, std::memory_order_release);
}
void use()
{
  if (ready.load(std::memory_order_acquire))
    std::cout << x + y;
}

Qui init() esegue un'operazione di rilascio dello store atomico . Questo non solo memorizza il valore true in ready , ma dice al compilatore che non può spostare questa operazione prima di scrivere operazioni sequenziate prima di esso.

La funzione use() esegue un'operazione di acquisizione del carico atomico . Legge il valore corrente di ready e proibisce inoltre al compilatore di posizionare le operazioni di lettura che vengono sequenziate dopo che si sono verificate prima dell'acquisizione del carico atomico .

Queste operazioni atomiche fanno sì che il compilatore metta tutte le istruzioni hardware necessarie per informare la CPU di astenersi dai riordini indesiderati.

Poiché il rilascio dell'archivio atomico si trova nella stessa posizione di memoria dell'acquisizione del carico atomico , il modello di memoria stabilisce che se l'operazione di acquisizione del carico vede il valore scritto dall'operazione di rilascio dello store , quindi tutte le scritture eseguite da init() ' Il thread di s prima di quello store-release sarà visibile ai carichi che il thread di use() esegue dopo il suo caricamento-acquisizione . Questo è se use() vede ready==true , allora è garantito vedere x==2 y==3 .

Si noti che il compilatore e la CPU possono ancora scrivere su y prima di scrivere in x , e allo stesso modo le letture di queste variabili in use() possono avvenire in qualsiasi ordine.

Esempio di recinzione

L'esempio sopra può anche essere implementato con recinzioni e operazioni atomiche rilassate:

int x, y;
std::atomic<bool> ready{false};

void init()
{
  x = 2;
  y = 3;
  atomic_thread_fence(std::memory_order_release);
  ready.store(true, std::memory_order_relaxed);
}
void use()
{
  if (ready.load(std::memory_order_relaxed))
  {
    atomic_thread_fence(std::memory_order_acquire);
    std::cout << x + y;
  }
}

Se l'operazione di caricamento atomica vede il valore scritto dal negozio atomico poi il negozio avviene prima del carico, e così anche le recinzioni: la recinzione rilascio avviene prima della recinzione acquisiscono rendendo le scritture nella x ed y che precedono la recinzione rilascio di diventare visibile alla dichiarazione std::cout che segue la recinzione acquisita.

Un recinto può essere utile se può ridurre il numero complessivo di operazioni di acquisizione, rilascio o altre operazioni di sincronizzazione. Per esempio:

void block_and_use()
{
  while (!ready.load(std::memory_order_relaxed))
    ;
  atomic_thread_fence(std::memory_order_acquire);
  std::cout << x + y;
}

La funzione block_and_use() gira fino a quando il flag di ready viene impostato con l'aiuto del carico atomico rilassato. Quindi viene utilizzata una singola fence per fornire l'ordine di memoria necessario.



Modified text is an extract of the original Stack Overflow Documentation
Autorizzato sotto CC BY-SA 3.0
Non affiliato con Stack Overflow