list :: empty () comportamento multi-thread?


9

Ho un elenco da cui voglio thread diversi per prendere elementi. Per evitare di bloccare il mutex a guardia dell'elenco quando è vuoto, controllo empty()prima del blocco.

Va bene se la chiamata a list::empty()non è corretta al 100% delle volte. Voglio solo evitare arresti anomali o interruzioni simultanee list::push()e list::pop()chiamate.

Sono sicuro di presumere che VC ++ e Gnu GCC a volte empty()sbaglieranno e niente di peggio?

if(list.empty() == false){ // unprotected by mutex, okay if incorrect sometimes
    mutex.lock();
    if(list.empty() == false){ // check again while locked to be certain
         element = list.back();
         list.pop_back();
    }
    mutex.unlock();
}

1
No, non puoi supporre questo. Potresti usare un contenitore concorrente come concurrent_queue
Panagiotis Kanavos

2
@Fureeish Questa dovrebbe essere una risposta. Aggiungo che std::list::sizeha garantito una complessità temporale costante, il che significa sostanzialmente che la dimensione (numero di nodi) deve essere memorizzata in una variabile separata; chiamiamolo size_. std::list::emptyquindi probabilmente restituisce qualcosa come size_ == 0, e la lettura e la scrittura simultanee size_causerebbero la corsa dei dati, quindi, UB.
Daniel Langr,

@DanielLangr Come viene misurato il "tempo costante"? È su una singola chiamata di funzione o sul programma completo?
curiousguy,

1
@curiousguy: DanielLangr ha risposto alla tua domanda "indipendentemente dal numero di nodi elenco", ovvero la definizione esatta di O (1) che significa che ogni chiamata viene eseguita in meno di un tempo costante, indipendentemente dal numero di elementi. en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions L'altra opzione (fino a C ++ 11) sarebbe lineare = O (n) che significa che quella dimensione dovrebbe contare gli elementi (elenco collegato), che sarebbe anche peggio per la concorrenza (corsa dei dati più ovvia della lettura / scrittura non atomica sul contatore).
firda,

1
@curiousguy: Prendendo il tuo esempio con dV, la complessità temporale è gli stessi limiti matematici. Tutte queste cose sono definite in modo ricorsivo o in una forma di "Esiste C tale che f (N) <C per ogni N" - ovvero la definizione di O (1) (per dato / ogni HW esiste una costante C tale che l'algo termina in meno di C-time su qualsiasi input). Ammortizzato significa in media , il che significa che alcuni input potrebbero richiedere più tempo per l'elaborazione (ad es. Re-hash / re-allocazione necessaria), ma è ancora costante in media (presupponendo tutti gli input possibili).
firda,

Risposte:


10

Va bene se la chiamata a list::empty()non è corretta al 100% delle volte.

No, non va bene. Se controlli se l'elenco è vuoto al di fuori di alcuni meccanismi di sincronizzazione (blocco del mutex), hai una corsa ai dati. Avere una gara di dati significa che hai un comportamento indefinito. Avere un comportamento indefinito significa che non possiamo più ragionare sul programma e qualsiasi output ottenuto è "corretto".

Se apprezzi la tua sanità mentale, subirai il colpo di prestazione e bloccherai il mutex prima di controllare. Detto questo, l'elenco potrebbe non essere nemmeno il contenitore giusto per te. Se puoi farci sapere esattamente cosa ci stai facendo, potremmo essere in grado di suggerire un contenitore migliore.


Prospettiva personale, la chiamata list::empty()è un'azione di lettura che non ha nulla a che fare conrace-condition
Ngọc Khánh Nguyễn

3
@ NgọcKhánhNguyễn Se stanno aggiungendo elementi all'elenco, sicuramente causa una corsa ai dati mentre stai scrivendo e leggendo le dimensioni allo stesso tempo.
NathanOliver,

6
@ NgọcKhánhNguyễn Questo è falso. Una condizione di gara è read-writeo write-write. Se non mi credi, dai una lettura alla sezione standard sulle gare di dati
NathanOliver,

1
@ NgọcKhánhNguyễn: poiché né la scrittura né la lettura sono garantite come atomiche, quindi possono essere eseguite simultaneamente, quindi la lettura può ottenere qualcosa di totalmente sbagliato (chiamata lettura strappata). Immagina di scrivere cambiando da 0x00FF a 0x0100 in MCU little endian a 8 bit, iniziando a riscrivere 0xFF basso a 0x00 e la lettura ora ottiene esattamente quello zero, leggendo entrambi i byte (scrittura del thread rallentata o sospesa), la scrittura continua aggiornando il byte alto su 0x01, ma il thread di lettura ha già ottenuto un valore errato (né 0x00FF, né 0x0100 ma inaspettato 0x0000).
firda,

1
@ NgọcKhánhNguyễn Potrebbe su alcune architetture ma la macchina virtuale C ++ non offre tale garanzia. Anche se l'hardware lo avesse fatto, sarebbe legale per il compilatore ottimizzare il codice in un modo in cui non si vedrebbe mai un cambiamento poiché, a meno che non ci sia la sincronizzazione dei thread, si può presumere che stia eseguendo solo un singolo thread e ottimizzare di conseguenza.
NathanOliver,

6

C'è una lettura e una scrittura (molto probabilmente per il sizemembro di std::list, se assumiamo che sia chiamato così) che non sono sincronizzate tra loro in reagard . Immagina che un thread chiama empty()(nel tuo esterno if()) mentre l'altro thread entra nell'interno if()ed esegue pop_back(). Stai quindi leggendo una variabile che, eventualmente, viene modificata. Questo è un comportamento indefinito.


2

Come esempio di come le cose potrebbero andare male:

Un compilatore sufficientemente intelligente potrebbe vedere che mutex.lock()non è possibile modificare il list.empty()valore restituito e quindi saltare ifcompletamente il controllo interno , portando infine a un pop_backin un elenco a cui è stato rimosso l'ultimo elemento dopo il primo if.

Perché può farlo? Non c'è sincronizzazione in list.empty(), quindi se fosse cambiato contemporaneamente ciò costituirebbe una corsa ai dati. Lo standard afferma che i programmi non devono avere gare di dati, quindi il compilatore lo darà per scontato (altrimenti non potrebbe eseguire quasi nessuna ottimizzazione). Quindi può assumere una prospettiva a thread singolo sul non sincronizzato list.empty()e concludere che deve rimanere costante.

Questa è solo una delle numerose ottimizzazioni (o comportamenti hardware) che potrebbero violare il codice.


I compilatori attuali non sembrano nemmeno voler ottimizzare a.load()+a.load()...
curiousguy,

1
@curiousguy Come vorresti che fosse ottimizzato? Richiedete la piena coerenza sequenziale lì, quindi otterrete che ...
Max Langhof

@MaxLanghof Non pensi che l'ottimizzazione a.load()*2sia ovvia? Nemmeno a.load(rel)+b.load(rel)-a.load(rel)è ottimizzato comunque. Niente è. Perché ti aspetti che i blocchi (che per lo più hanno una consistenza seq) siano più ottimizzati?
curiousguy,

@curiousguy Perché l'ordinamento della memoria degli accessi non atomici (qui prima e dopo il blocco) e l'atomica sono completamente diversi? Non mi aspetto che il blocco sia ottimizzato "di più", mi aspetto che gli accessi non sincronizzati siano ottimizzati più degli accessi coerenti in sequenza. La presenza della serratura è irrilevante per il mio punto. E no, il compilatore non è permesso di ottimizzare a.load() + a.load()al 2 * a.load(). Sentiti libero di fare una domanda al riguardo se vuoi saperne di più.
Max Langhof,

@MaxLanghof Non ho idea di cosa tu stia cercando di dire. I blocchi sono essenzialmente coerenti in sequenza. Perché l'implementazione dovrebbe provare a fare ottimizzazioni su alcuni threading primitivi (blocchi) e non su altri (atomici)? Prevedi che gli accessi non atomici siano ottimizzati in base agli usi dell'atomica?
curioso
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.