Il programma multithreading è bloccato in modalità ottimizzata ma funziona normalmente in -O0


68

Ho scritto un semplice programma di multithreading come segue:

static bool finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Si comporta normalmente in modalità debug in Visual Studio o -O0in gc c e stampa il risultato dopo 1pochi secondi. Ma è bloccato e non stampa nulla in modalità di rilascio o -O1 -O2 -O3.


I commenti non sono per una discussione estesa; questa conversazione è stata spostata in chat .
Samuel Liew

Risposte:


100

Due thread, accedendo a una variabile non atomica, non protetta sono UB Questo riguarda finished. Si potrebbe fare finisheddi tipo std::atomic<bool>per risolvere questo problema.

La mia correzione:

#include <iostream>
#include <future>
#include <atomic>

static std::atomic<bool> finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Produzione:

result =1023045342
main thread id=140147660588864

Demo live su coliru


Qualcuno potrebbe pensare 'È un bool- probabilmente un po'. Come può essere non atomico? (L'ho fatto quando ho iniziato con il multi-thread me stesso.)

Ma nota che la mancanza di lacrimazione non è l'unica cosa che std::atomicti dà. Rende inoltre ben definito l'accesso simultaneo in lettura e scrittura da più thread, impedendo al compilatore di assumere che la rilettura della variabile visualizzi sempre lo stesso valore.

Rendere un boolnon custodito, non atomico può causare ulteriori problemi:

  • Il compilatore potrebbe decidere di ottimizzare la variabile in un registro o persino CSE in più accessi in uno e sollevare un carico da un ciclo.
  • La variabile potrebbe essere memorizzata nella cache per un core della CPU. (Nella vita reale, le CPU hanno cache coerenti . Questo non è un problema reale, ma lo standard C ++ è abbastanza lento da coprire ipotetiche implementazioni C ++ su memoria condivisa non coerente dove atomic<bool>con memory_order_relaxedarchivio / carico funzionerebbe, ma dove volatilenon funzionerebbe. volatile per questo sarebbe UB, anche se funziona in pratica su implementazioni C ++ reali.)

Per evitare che ciò accada, al compilatore deve essere esplicitamente detto di non farlo.


Sono un po 'sorpreso dall'evolversi della discussione sulla potenziale relazione di volatilequesto problema. Quindi, vorrei spendere i miei due centesimi:


4
Ho dato un'occhiata func()e ho pensato "Potrei ottimizzarlo" L'ottimizzatore non si preoccupa affatto dei thread e rileverà il loop infinito e lo trasformerà felicemente in un "while (True)" Se guardiamo godbolt .org / z / Tl44iN possiamo vedere questo. Se finito è Truerestituito. In caso contrario, rientra in un incondizionato ritorno a se stesso (un ciclo infinito) all'etichetta.L5
Baldrickk,


2
@val: fondamentalmente non c'è motivo di abusare volatilein C ++ 11 perché puoi ottenere lo stesso identico con atomic<T>e std::memory_order_relaxed. Funziona però su hardware reale: le cache sono coerenti, quindi un'istruzione di caricamento non può continuare a leggere un valore non aggiornato una volta che un archivio su un altro core si impegna a memorizzarlo nella cache. (MESI)
Peter Cordes,

5
Tuttavia, @PeterCordes Using volatileè ancora UB. Non dovresti mai presumere che qualcosa che sia sicuramente e chiaramente UB sia sicuro solo perché non riesci a pensare a un modo in cui potrebbe andare storto e ha funzionato quando l'hai provato. Ciò ha fatto bruciare le persone ancora e ancora.
David Schwartz,

2
@Damon I mutex hanno la semantica di rilascio / acquisizione. Al compilatore non è consentito ottimizzare la lettura se un mutex è stato bloccato in precedenza, quindi proteggendo finishedcon un'opera std::mutex(senza volatileo atomic). In effetti, puoi sostituire tutti gli atomici con un valore "semplice" + schema mutex; funzionerebbe ancora e sarebbe solo più lento. atomic<T>è consentito l'uso di un mutex interno; atomic_flagè garantito solo senza blocco.
Erlkoenig,

42

La risposta di Scheff descrive come riparare il tuo codice. Ho pensato di aggiungere alcune informazioni su ciò che sta realmente accadendo in questo caso.

Ho compilato il tuo codice su godbolt usando il livello di ottimizzazione 1 ( -O1). La tua funzione si compila così:

func():
  cmp BYTE PTR finished[rip], 0
  jne .L4
.L5:
  jmp .L5
.L4:
  mov eax, 0
  ret

Quindi, cosa sta succedendo qui? Innanzitutto, abbiamo un confronto: cmp BYTE PTR finished[rip], 0- questo controlla se finishedè falso o no.

Se è non è falso (aka vero) dobbiamo uscire dal ciclo alla prima esecuzione. Ciò ottiene jne .L4che j UMPS quando n ot e qual all'etichetta .L4cui il valore di i( 0) è memorizzato in un registro per un uso successivo e restituisce la funzione.

Se è falso, tuttavia, ci spostiamo a

.L5:
  jmp .L5

Questo è un salto incondizionato, per etichettare .L5che è proprio il comando di salto stesso.

In altre parole, il thread viene inserito in un loop occupato infinito.

Allora perché è successo?

Per quanto riguarda l'ottimizzatore, i thread non rientrano nella sua sfera di competenza. Presuppone che altri thread non leggano o scrivano variabili contemporaneamente (perché sarebbe UB data-race). Devi dirlo che non può ottimizzare gli accessi. È qui che arriva la risposta di Scheff. Non mi preoccuperò di ripeterlo.

Poiché all'ottimizzatore non viene detto che la finishedvariabile può potenzialmente cambiare durante l'esecuzione della funzione, vede che finishednon viene modificata dalla funzione stessa e presuppone che sia costante.

Il codice ottimizzato fornisce i due percorsi del codice che risulteranno dall'inserimento della funzione con un valore bool costante; o esegue il ciclo all'infinito o il ciclo non viene mai eseguito.

al -O0compilatore (come previsto) non ottimizza il corpo del loop e il confronto via:

func():
  push rbp
  mov rbp, rsp
  mov QWORD PTR [rbp-8], 0
.L148:
  movzx eax, BYTE PTR finished[rip]
  test al, al
  jne .L147
  add QWORD PTR [rbp-8], 1
  jmp .L148
.L147:
  mov rax, QWORD PTR [rbp-8]
  pop rbp
  ret

pertanto la funzione, quando non ottimizzata funziona, la mancanza di atomicità qui non è in genere un problema, poiché il codice e il tipo di dati sono semplici. Probabilmente il peggio che potremmo incontrare qui è un valore iche è fuori da quello che dovrebbe essere.

Un sistema più complesso con strutture di dati ha molte più probabilità di provocare dati danneggiati o un'esecuzione impropria.


3
C ++ 11 rende i thread e un modello di memoria sensibile al thread parte del linguaggio stesso. Ciò significa che i compilatori non possono inventare le scritture nemmeno in non atomicvariabili nel codice che non scrive quelle variabili. ad es. if (cond) foo=1;non può essere trasformato in asm, è come foo = cond ? 1 : foo;perché quel load + store (non un atomico RMW) potrebbe passare su una scrittura da un altro thread. I compilatori stavano già evitando cose del genere perché volevano essere utili per scrivere programmi multi-thread, ma C ++ 11 ha reso ufficiale che i compilatori non dovevano rompere il codice dove scrivono 2 thread a[1]ea[2]
Peter Cordes,

2
Ma sì, a parte questo sovrastima su come i compilatori non sono a conoscenza di fili a tutti , la risposta è corretta. Data-race UB è ciò che consente di sollevare carichi di variabili non atomiche inclusi i globuli e le altre ottimizzazioni aggressive che vogliamo per il codice a thread singolo. Programmazione MCU - L'ottimizzazione C ++ O2 si interrompe durante il loop su electronics.SE è la mia versione di questa spiegazione.
Peter Cordes,

1
@PeterCordes: Un vantaggio di Java che utilizza un GC è che la memoria per gli oggetti non verrà riciclata senza una barriera di memoria globale interposta tra il vecchio e il nuovo utilizzo, il che significa che qualsiasi core che esamina un oggetto vedrà sempre un valore che ha tenuto qualche tempo dopo la pubblicazione del riferimento. Sebbene le barriere di memoria globali possano essere molto costose se utilizzate frequentemente, possono ridurre notevolmente la necessità di barriere di memoria altrove anche se utilizzate con parsimonia.
supercat

1
Sì, sapevo che era quello che stavi cercando di dire, ma non credo che le tue parole al 100% significhino questo. Dire che l'ottimizzatore "li ignora completamente". non è del tutto corretto: è risaputo che ignorare veramente il threading durante l'ottimizzazione può comportare cose come il caricamento / modifica di parole in un archivio di parole / parole, che in pratica ha causato bug in cui un thread ha accesso a un carattere o passi di un campo di bit in un scrivere a un membro struct adiacente. Vedi lwn.net/Articles/478657 per la storia completa e come solo il modello di memoria C11 / C ++ 11 rende illegale tale ottimizzazione, non solo indesiderata nella pratica.
Peter Cordes,

1
No, va bene .. Grazie @PeterCordes. Apprezzo il miglioramento.
Baldrickk,

5

Per completezza nella curva di apprendimento; dovresti evitare di usare variabili globali. Hai fatto un buon lavoro rendendolo statico, quindi sarà locale all'unità di traduzione.

Ecco un esempio:

class ST {
public:
    int func()
    {
        size_t i = 0;
        while (!finished)
            ++i;
        return i;
    }
    void setFinished(bool val)
    {
        finished = val;
    }
private:
    std::atomic<bool> finished = false;
};

int main()
{
    ST st;
    auto result=std::async(std::launch::async, &ST::func, std::ref(st));
    std::this_thread::sleep_for(std::chrono::seconds(1));
    st.setFinished(true);
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Live su wandbox


1
Potrebbe anche dichiarare finishedcome staticall'interno del blocco funzione. Verrà comunque inizializzato solo una volta, e se è inizializzato su una costante, questo non richiede il blocco.
Davislor,

Gli accessi a finishedpotrebbero anche usare std::memory_order_relaxedcarichi e negozi più economici ; non è richiesto alcun ordine. altre variabili in entrambi i thread. Non sono sicuro che il suggerimento di @ Davislor di avere staticun senso, comunque; se avessi più thread di conteggio degli spin, non vorrai fermarli tutti con lo stesso flag. finishedTuttavia, si desidera scrivere l'inizializzazione in un modo che si compili in una semplice inizializzazione, non in un deposito atomico. (Come stai facendo con la finished = false;sintassi dell'inizializzatore predefinita C ++ 17. Godbolt.org/z/EjoKgq ).
Peter Cordes,

@PeterCordes Mettere il flag in un oggetto permette di essercene più di uno, per diversi pool di thread, come dici tu. Il design originale aveva una sola bandiera per tutti i thread, tuttavia.
Davislor,
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.