Voglio scrivere un codice portatile (Intel, ARM, PowerPC ...) che risolva una variante di un problema classico:
Initially: X=Y=0
Thread A:
X=1
if(!Y){ do something }
Thread B:
Y=1
if(!X){ do something }
in cui l'obiettivo è quello di evitare una situazione in cui entrambi i thread stanno facendosomething . (Va bene se nessuna delle due cose funziona; questo non è un meccanismo run-esattamente-una volta.) Correggimi se vedi alcuni difetti nel mio ragionamento di seguito.
Sono consapevole che posso raggiungere l'obiettivo con le s memory_order_seq_cstatomiche come segue:storeload
std::atomic<int> x{0},y{0};
void thread_a(){
x.store(1);
if(!y.load()) foo();
}
void thread_b(){
y.store(1);
if(!x.load()) bar();
}
che raggiunge l'obiettivo, perché ci deve essere un singolo ordine totale sugli
{x.store(1), y.store(1), y.load(), x.load()}eventi, che deve concordare con gli "spigoli" dell'ordine del programma:
x.store(1)"in TO è prima"y.load()y.store(1)"in TO è prima"x.load()
e se è foo()stato chiamato, allora abbiamo un vantaggio aggiuntivo:
y.load()"legge il valore prima"y.store(1)
e se è bar()stato chiamato, allora abbiamo un vantaggio aggiuntivo:
x.load()"legge il valore prima"x.store(1)
e tutti questi bordi combinati insieme formerebbero un ciclo:
x.store(1)"in TO è prima" y.load()"legge il valore prima" y.store(1)"in TO è prima" x.load()"legge il valore prima"x.store(true)
il che viola il fatto che gli ordini non hanno cicli.
Uso intenzionalmente termini non standard "in TO is before" e "legge valore prima" in contrapposizione a termini standard come happens-before, perché desidero sollecitare un feedback sulla correttezza della mia ipotesi che questi limiti implicino effettivamente una happens-beforerelazione, che possono essere combinati insieme in un singolo grafico e il ciclo in tale grafico combinato è vietato. Non ne sono sicuro. Quello che so è che questo codice produce barriere corrette su Intel gcc & clang e su ARM gcc
Ora, il mio vero problema è un po 'più complicato, perché non ho alcun controllo su "X" - è nascosto dietro alcune macro, modelli ecc. E potrebbe essere più debole di seq_cst
Non so nemmeno se "X" è una singola variabile, o qualche altro concetto (ad esempio un semaforo leggero o mutex). Tutto quello che so è che ho due macro set()e check()tale che check()ritorna true"dopo" un altro thread chiamato set(). (Si è anche noto che sete checksono thread-safe e non può creare UB dati-gara.)
Quindi concettualmente set()è un po 'come "X = 1" ed check()è come "X", ma non ho alcun accesso diretto all'atomica, se presente.
void thread_a(){
set();
if(!y.load()) foo();
}
void thread_b(){
y.store(1);
if(!check()) bar();
}
Sono preoccupato, set()potrebbe essere implementato internamente come x.store(1,std::memory_order_release)e / o check()potrebbe essere x.load(std::memory_order_acquire). O ipoteticamente, un std::mutexthread si sta sbloccando e un altro è try_locking; nello standard ISO std::mutexè garantito solo l'acquisto e il rilascio degli ordini, non seq_cst.
Se questo è il caso, allora check()se il corpo può essere "riordinato" prima y.store(true)( vedi la risposta di Alex dove dimostrano che ciò accade su PowerPC ).
Questo sarebbe davvero brutto, poiché ora questa sequenza di eventi è possibile:
thread_b()prima carica il vecchio valore dix(0)thread_a()esegue tutto compresofoo()thread_b()esegue tutto compresobar()
Quindi, entrambi foo()e bar()sono stato chiamato, cosa che ho dovuto evitare. Quali sono le mie opzioni per impedirlo?
Opzione A
Prova a forzare la barriera Store-Load. Questo, in pratica, può essere raggiunto da std::atomic_thread_fence(std::memory_order_seq_cst);- come spiegato da Alex in una risposta diversa, tutti i compilatori testati hanno emesso un recinto completo:
- x86_64: MFENCE
- PowerPC: hwsync
- Itanuim: mf
- ARMv7 / ARMv8: dmb ish
- MIPS64: sincronizzazione
Il problema con questo approccio è che non ho trovato alcuna garanzia nelle regole C ++, che std::atomic_thread_fence(std::memory_order_seq_cst)deve tradursi in una barriera di memoria piena. In realtà, il concetto di atomic_thread_fences in C ++ sembra avere un diverso livello di astrazione rispetto al concetto di assemblaggio di barriere di memoria e si occupa più di cose come "quale operazione atomica si sincronizza con cosa". Esistono prove teoriche del fatto che la realizzazione di seguito raggiunge l'obiettivo?
void thread_a(){
set();
std::atomic_thread_fence(std::memory_order_seq_cst)
if(!y.load()) foo();
}
void thread_b(){
y.store(true);
std::atomic_thread_fence(std::memory_order_seq_cst)
if(!check()) bar();
}
Opzione B
Usa il controllo che abbiamo su Y per ottenere la sincronizzazione, usando le operazioni di lettura-modifica-scrittura memory_order_acq_rel su Y:
void thread_a(){
set();
if(!y.fetch_add(0,std::memory_order_acq_rel)) foo();
}
void thread_b(){
y.exchange(1,std::memory_order_acq_rel);
if(!check()) bar();
}
L'idea qui è che gli accessi a un singolo atomico ( y) debbano essere costituiti da un unico ordine su cui tutti gli osservatori concordano, quindi o fetch_addè prima exchangeo viceversa.
Se fetch_addè prima di exchangeallora la parte "release" di si fetch_addsincronizza con la parte "acquisisci" di exchangee quindi tutti gli effetti collaterali di set()devono essere visibili all'esecuzione del codice check(), quindi bar()non saranno chiamati.
Altrimenti, exchangeè prima fetch_add, quindi fetch_addvedrà 1e non chiamerà foo(). Quindi, è impossibile chiamare sia foo()e bar(). Questo ragionamento è corretto?
Opzione C
Usa atomici fittizi per introdurre "bordi" che impediscono il disastro. Prendi in considerazione il seguente approccio:
void thread_a(){
std::atomic<int> dummy1{};
set();
dummy1.store(13);
if(!y.load()) foo();
}
void thread_b(){
std::atomic<int> dummy2{};
y.store(1);
dummy2.load();
if(!check()) bar();
}
Se pensi che il problema qui sia atomics locale, allora immagina di spostarli in ambito globale, nel seguente ragionamento non mi sembra importante, e ho intenzionalmente scritto il codice in modo tale da esporre quanto sia divertente quel manichino1 e dummy2 sono completamente separati.
Perché sulla Terra questo potrebbe funzionare? Bene, ci deve essere un singolo ordine totale di {dummy1.store(13), y.load(), y.store(1), dummy2.load()}cui deve essere coerente con gli "spigoli" dell'ordine del programma:
dummy1.store(13)"in TO è prima"y.load()y.store(1)"in TO è prima"dummy2.load()
(Seq_cst store + load si spera formino l'equivalente in C ++ di una barriera di memoria completa incluso StoreLoad, come fanno in caso di ISA reali incluso AArch64 dove non sono richieste istruzioni di barriera separate.)
Ora, abbiamo due casi da considerare: o y.store(1)è prima y.load()o dopo nell'ordine totale.
Se y.store(1)è prima, y.load()allora foo()non verrà chiamato e siamo al sicuro.
Se y.load()è prima y.store(1), quindi combinandolo con i due bordi che abbiamo già nell'ordine del programma, deduciamo che:
dummy1.store(13)"in TO è prima"dummy2.load()
Ora, si dummy1.store(13)tratta di un'operazione di rilascio, che rilascia effetti di set(), ed dummy2.load()è un'operazione di acquisizione, quindi check()dovremmo vedere gli effetti di set()e quindi bar()non saranno chiamati e siamo al sicuro.
È corretto qui pensare che check()vedrà i risultati di set()? Posso combinare i "bordi" di vario tipo ("ordine di programma", noto anche come Sequenced Before, "ordine totale", "prima del rilascio", "dopo l'acquisizione") in questo modo? Ne dubito seriamente: le regole del C ++ sembrano parlare di relazioni "sincronizzate con" tra negozio e carico nella stessa posizione - qui non esiste una situazione del genere.
Tieni presente che siamo solo preoccupati per il caso in cui dumm1.storeè noto (tramite altri ragionamenti) prima dummy2.loadnell'ordine totale seq_cst. Quindi se avessero avuto accesso alla stessa variabile, il carico avrebbe visto il valore memorizzato e si sarebbe sincronizzato con esso.
(Il ragionamento della barriera di memoria / riordino delle implementazioni in cui i carichi atomici e gli archivi si compilano in almeno barriere di memoria a 1 via (e le operazioni seq_cst non possono essere riordinate: ad esempio un archivio seq_cst non può passare un carico seq_cst) è che qualsiasi carico / i negozi dopo dummy2.loaddiventano sicuramente visibili agli altri thread dopo y.store . E allo stesso modo per l'altro thread, ... prima y.load.)
Puoi giocare con la mia implementazione delle Opzioni A, B, C su https://godbolt.org/z/u3dTa8
foo()e bar()essere entrambi chiamati.
compare_exchange_*per eseguire un'operazione RMW su un bool atomico senza modificarne il valore (basta impostare lo stesso valore atteso e nuovo).
atomic<bool>ha exchangee compare_exchange_weak. Quest'ultimo può essere usato per fare un RMW fittizio (tentando di) CAS (vero, vero) o falso, falso. Non riesce o sostituisce atomicamente il valore con se stesso. (In x86-64 asm, il trucco lock cmpxchg16bè come si fa a garantire carichi atomici garantiti a 16 byte; inefficiente ma meno male che prendere un blocco separato.)
foo()né bar()sarà chiamato. Non volevo portare a molti elementi "reali" del codice, per evitare "pensi di avere il problema X ma hai il problema Y" tipo di risposte. Ma, se uno ha davvero bisogno di sapere qual è il piano di fondo: set()è davvero some_mutex_exit(), check()è try_enter_some_mutex(), yè "ci sono alcuni camerieri", foo()è "uscita senza svegliare nessuno", bar()è "aspetta il risveglio" ... Ma, mi rifiuto di discuti qui di questo disegno - non posso davvero cambiarlo.
std::atomic_thread_fence(std::memory_order_seq_cst)si compila in una barriera completa, ma poiché l'intero concetto è un dettaglio di implementazione che non troverai qualsiasi menzione nello standard. (Modelli di memoria CPU solito sono definiti in termini di ciò reorerings è consentito rispetto alla consistenza sequenziale es x86 è seq-cst + tampone negozio w / inoltro.)