Perché il codice che muta una variabile condivisa tra i thread apparentemente NON soffre di una condizione di competizione?


107

Sto usando Cygwin GCC ed eseguo questo codice:

#include <iostream>
#include <thread>
#include <vector>
using namespace std;

unsigned u = 0;

void foo()
{
    u++;
}

int main()
{
    vector<thread> threads;
    for(int i = 0; i < 1000; i++) {
        threads.push_back (thread (foo));
    }
    for (auto& t : threads) t.join();

    cout << u << endl;
    return 0;
}

Compilato con la linea: g++ -Wall -fexceptions -g -std=c++14 -c main.cpp -o main.o.

Stampa 1000, che è corretto. Tuttavia, mi aspettavo un numero inferiore a causa dei thread che sovrascrivono un valore precedentemente incrementato. Perché questo codice non soffre di accesso reciproco?

La mia macchina di prova ha 4 core e non metto restrizioni al programma che conosco.

Il problema persiste quando si sostituisce il contenuto del condiviso foocon qualcosa di più complesso, es

if (u % 3 == 0) {
    u += 4;
} else {
    u -= 1;
}

66
Le CPU Intel hanno una straordinaria logica interna di "abbattimento" per preservare la compatibilità con le primissime CPU x86 utilizzate nei sistemi SMP (come le macchine Pentium Pro doppie). Molte delle condizioni di errore che ci vengono insegnate sono possibili quasi mai si verificano effettivamente su macchine x86. Quindi supponiamo che un core vada a riscrivere uin memoria. La CPU in realtà farà cose sorprendenti come notare che la linea di memoria per unon è nella cache della CPU e riavvierà l'operazione di incremento. Questo è il motivo per cui passare da x86 ad altre architetture può essere un'esperienza che apre gli occhi!
David Schwartz

1
Forse ancora troppo veloce. È necessario aggiungere codice per garantire che il thread ceda prima di fare qualsiasi cosa per garantire che gli altri thread vengano avviati prima del completamento.
Rob K

1
Come è stato notato altrove, il codice del thread è così breve che potrebbe essere eseguito prima che il thread successivo venga messo in coda. Che ne dici di 10 thread che inseriscono u ++ in un ciclo di 100 count. E un breve ritardo entro l'inizio del ciclo (o un flag "go" globale per avviarli tutti allo stesso tempo)
RufusVS

5
In realtà, la generazione ripetuta del programma in un ciclo alla fine mostra che si rompe: qualcosa come while true; do res=$(./a.out); if [[ $res != 1000 ]]; then echo $res; break; fi; done;stampa 999 o 998 sul mio sistema.
Daniel Kamil Kozar

Risposte:


266

foo()è così breve che probabilmente ogni thread finisce prima che venga generato il successivo. Se aggiungi un sonno per un tempo casuale foo()prima del u++, potresti iniziare a vedere cosa ti aspetti.


51
Ciò ha effettivamente modificato l'output nel modo previsto.
mafu

49
Vorrei notare che questa è in generale una buona strategia per esibire le condizioni di gara. Dovresti essere in grado di inserire una pausa tra due operazioni qualsiasi; in caso contrario, c'è una condizione di gara.
Matthieu M.

Abbiamo avuto solo questo problema con C # di recente. Di solito il codice non fallisce quasi mai, ma la recente aggiunta di una chiamata API nel mezzo ha introdotto un ritardo sufficiente per modificarlo in modo coerente.
Obsidian Phoenix,

@MatthieuM. Microsoft non dispone di uno strumento automatizzato che fa esattamente questo, sia come metodo per rilevare le condizioni di gara sia per renderle riproducibili in modo affidabile?
Mason Wheeler

1
@ MasonWheeler: Lavoro quasi esclusivamente su Linux, quindi ... non so :(
Matthieu M.

59

È importante capire che una condizione di competizione non garantisce che il codice verrà eseguito in modo errato, ma semplicemente che potrebbe fare qualsiasi cosa, poiché è un comportamento indefinito. Compreso l'esecuzione come previsto.

Soprattutto su macchine X86 e AMD64 le condizioni di gara in alcuni casi raramente causano problemi poiché molte delle istruzioni sono atomiche e le garanzie di coerenza sono molto alte. Queste garanzie sono in qualche modo ridotte sui sistemi multiprocessore in cui il prefisso di blocco è necessario affinché molte istruzioni siano atomiche.

Se sulla tua macchina l'incremento è un'operazione atomica, probabilmente funzionerà correttamente anche se secondo lo standard del linguaggio è un comportamento indefinito.

In particolare, mi aspetto che in questo caso il codice possa essere compilato in un Fetch and Add atomico (ADD o XADD nell'assembly X86) che è effettivamente atomica nei sistemi a processore singolo, tuttavia su sistemi multiprocessore non è garantito che sia atomica e un blocco sarebbe necessario per farlo. Se si esegue su un sistema multiprocessore, ci sarà una finestra in cui i thread potrebbero interferire e produrre risultati errati.

Nello specifico ho compilato il tuo codice per l'assemblaggio usando https://godbolt.org/ e foo()compila per:

foo():
        add     DWORD PTR u[rip], 1
        ret

Ciò significa che sta eseguendo solo un'istruzione di aggiunta che per un singolo processore sarà atomica (sebbene come menzionato sopra non sia così per un sistema multi processore).


41
È importante ricordare che "correre come previsto" è un risultato ammissibile di un comportamento indefinito.
Marco

3
Come hai indicato, questa istruzione non è atomica su una macchina SMP (come sono tutti i sistemi moderni). Anche inc [u]non è atomico. Il LOCKprefisso è necessario per rendere un'istruzione veramente atomica. L'OP sta semplicemente diventando fortunato. Ricorda che anche se stai dicendo alla CPU "aggiungi 1 alla parola a questo indirizzo", la CPU deve ancora recuperare, incrementare, memorizzare quel valore e un'altra CPU può fare la stessa cosa contemporaneamente, causando un risultato errato.
Jonathon Reinhart

2
Ho votato negativamente, ma poi ho riletto la tua domanda e mi sono reso conto che le tue dichiarazioni di atomicità presumevano una singola CPU. Se modifichi la tua domanda per renderlo più chiaro (quando dici "atomico", sii chiaro che questo è il caso solo su una singola CPU), allora sarò in grado di rimuovere il mio voto negativo.
Jonathon Reinhart,

3
Downvoted, trovo questa affermazione un po 'meh "Soprattutto su macchine X86 e AMD64 le condizioni di gara in alcuni casi raramente causano problemi poiché molte delle istruzioni sono atomiche e le garanzie di coerenza sono molto alte." Il paragrafo dovrebbe iniziare a supporre esplicitamente che ti stai concentrando su un singolo core. Anche così, le architetture multi-core sono oggigiorno uno standard de facto nei dispositivi di consumo che lo considero un caso d'angolo da spiegare per ultimo, piuttosto che per primo.
Patrick Trentin

3
Oh, decisamente. x86 ha tonnellate di retrocompatibilità ... cose per assicurarsi che il codice scritto in modo errato funzioni il più possibile. È stato davvero un grosso problema quando il Pentium Pro ha introdotto l'esecuzione fuori ordine. Intel voleva assicurarsi che la base di codice installata funzionasse senza bisogno di essere ricompilata appositamente per il loro nuovo chip. x86 è iniziato come un core CISC, ma si è evoluto internamente in un core RISC, sebbene si presenti e si comporti ancora in molti modi come CISC dal punto di vista di un programmatore. Per ulteriori informazioni, vedere la risposta di Peter Cordes qui .
Cody Grey

20

Penso che non sia tanto la cosa se metti un sonno prima o dopo il u++. È piuttosto che l'operazione si u++traduce in codice che, rispetto al sovraccarico di spawn dei thread che chiamano foo, viene eseguito molto rapidamente in modo tale che è improbabile che venga intercettato. Tuttavia, se "prolunghi" l'operazione u++, la race condition diventerà molto più probabile:

void foo()
{
    unsigned i = u;
    for (int s=0;s<10000;s++);
    u = i+1;
}

risultato: 694


BTW: ho anche provato

if (u % 2) {
    u += 2;
} else {
    u -= 1;
}

e mi ha dato la maggior parte delle volte 1997, ma a volte 1995.


1
Mi aspetterei che su qualsiasi compilatore vagamente sano di mente l'intera funzione sarebbe ottimizzata per la stessa cosa. Sono sorpreso che non sia stato così. Grazie per l'interessante risultato.
Vality

Questo è esattamente corretto. Molte migliaia di istruzioni devono essere eseguite prima che il thread successivo inizi a eseguire la piccola funzione in questione. Quando si rende il tempo di esecuzione nella funzione più vicino all'overhead di creazione del thread, si vede l'impatto della race condition.
Jonathon Reinhart

@Vality: mi aspettavo anche che eliminasse il ciclo for spurio con l'ottimizzazione O3. Non è vero?
user21820

Come potrebbe else u -= 1mai essere giustiziato? Anche in un ambiente parallelo il valore non dovrebbe mai non adattarsi %2, vero?
mafu

2
dall'output, sembra che else u -= 1venga eseguito una volta, la prima volta che viene chiamato foo (), quando u == 0. Le restanti 999 volte u sono dispari e u += 2vengono eseguite con u = -1 + 999 * 2 = 1997; cioè l'uscita corretta. Una condizione di competizione a volte fa sì che uno dei + = 2 venga sovrascritto da un thread parallelo e ottieni 1995.
Luca

7

Soffre di una condizione di gara. Metti usleep(1000);prima u++;in fooe vedo output diverso (<1000) di volta in volta.


6
  1. La risposta probabilmente il motivo per cui la condizione di competizione non ha manifestato per voi, anche se non esiste, è che foo()è così veloce, rispetto al tempo necessario per avviare un thread, che ogni thread termina prima del prossimo può anche iniziare. Ma...

  2. Anche con la versione originale, il risultato varia a seconda del sistema: l'ho provato a modo tuo su un Macbook (quad-core) e in dieci esecuzioni ho ottenuto 1000 tre volte, 999 sei volte e 998 una volta. Quindi la gara è piuttosto rara, ma chiaramente presente.

  3. Hai compilato con '-g', che ha un modo per far sparire i bug. Ho ricompilato il tuo codice, ancora invariato ma senza il '-g', e la corsa è diventata molto più pronunciata: ho ottenuto 1000 una volta, 999 tre volte, 998 due volte, 997 due volte, 996 una volta e 992 una volta.

  4. Ri. il suggerimento di aggiungere un sonno - che aiuta, ma (a) un tempo di sonno fisso lascia i thread ancora distorti dall'ora di inizio (soggetto alla risoluzione del timer) e (b) un sonno casuale li distribuisce quando ciò che vogliamo è avvicinarli insieme. Invece, li codificherei per attendere un segnale di avvio, così posso crearli tutti prima di lasciarli lavorare. Con questa versione (con o senza '-g'), ottengo risultati ovunque, a partire da 974 e non superiori a 998:

    #include <iostream>
    #include <thread>
    #include <vector>
    using namespace std;
    
    unsigned u = 0;
    bool start = false;
    
    void foo()
    {
        while (!start) {
            std::this_thread::yield();
        }
        u++;
    }
    
    int main()
    {
        vector<thread> threads;
        for(int i = 0; i < 1000; i++) {
            threads.push_back (thread (foo));
        }
        start = true;
        for (auto& t : threads) t.join();
    
        cout << u << endl;
        return 0;
    }

Solo una nota. La -gbandiera non "fa sparire i bug" in alcun modo. Il -gflag su entrambi i compilatori GNU e Clang aggiunge semplicemente simboli di debug al binario compilato. Ciò ti consente di eseguire strumenti diagnostici come GDB e Memcheck sui tuoi programmi con un output leggibile dall'uomo. Ad esempio, quando Memcheck viene eseguito su un programma con una perdita di memoria, non ti dirà il numero di riga a meno che il programma non sia stato creato utilizzando il -gflag.
MS-DDOS

Certo, i bug che si nascondono dal debugger di solito sono più una questione di ottimizzazione del compilatore; Avrei dovuto provare e dire "usando -O2 invece di -g". Ma detto questo, se non hai mai avuto la gioia di cacciare un bug che si manifesterebbe solo se compilato senza -g , considerati fortunato. Esso può accadere, con alcuni tra i più brutte di bug aliasing sottili. Io ho visto, anche se non di recente, e riuscivo a credere che forse era un capriccio di un vecchio compilatore proprietaria, quindi mi credo, in via provvisoria, sulle versioni moderne di GNU e Clang.
dgould

-gnon ti impedisce di utilizzare le ottimizzazioni. es. gcc -O3 -gcrea lo stesso asm di gcc -O3, ma con i metadati di debug. Tuttavia, gdb dirà "ottimizzato" se provi a stampare alcune variabili. -gpotrebbe forse cambiare le posizioni relative di alcune cose in memoria, se qualcuna delle cose che aggiunge fa parte della .textsezione. Prende sicuramente spazio nel file oggetto, ma penso che dopo averlo collegato tutto finisca a un'estremità del segmento di testo (non sezione), o non fa affatto parte di un segmento. Forse potrebbe influenzare dove vengono mappate le cose per le librerie dinamiche.
Peter Cordes
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.