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_cst
atomiche come segue:store
load
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-before
relazione, 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 set
e check
sono 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::mutex
thread si sta sbloccando e un altro è try_lock
ing; 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_fence
s 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 exchange
o viceversa.
Se fetch_add
è prima di exchange
allora la parte "release" di si fetch_add
sincronizza con la parte "acquisisci" di exchange
e 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_add
vedrà 1
e 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 atomic
s 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.load
nell'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.load
diventano 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 exchange
e 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.)