Ho sentito che i ++ non è thread-safe, ++ i thread-safe?


90

Ho sentito che i ++ non è un'istruzione thread-safe poiché in assembly si riduce a memorizzare il valore originale come temp da qualche parte, incrementandolo e quindi sostituendolo, il che potrebbe essere interrotto da un cambio di contesto.

Tuttavia, mi chiedo se ++ i. Per quanto ne so, questo si ridurrebbe a una singola istruzione di assemblaggio, come "aggiungi r1, r1, 1" e poiché è solo un'istruzione, sarebbe ininterrotta da un cambio di contesto.

Qualcuno può chiarire? Presumo che venga utilizzata una piattaforma x86.


Solo una domanda. Che tipo di scenari avrebbero bisogno per due (o più) thread che accedono a una variabile del genere? Sto chiedendo onestamente qui, non criticando. È solo a quest'ora, la mia testa non riesce a pensare a nessuno.
OscarRyz

5
Una variabile di classe in una classe C ++ che mantiene un conteggio di oggetti?
paxdiablo

1
Bel video sull'argomento che ho appena visto oggi perché un altro ragazzo me l'ha detto: youtube.com/watch?v=mrvAqvtWYb4
Johannes Schaub - litb

1
contrassegnato come C / C ++; Java non viene considerato qui, C # è simile, ma manca di tale semantica di memoria rigidamente definita.
Tim Williscroft

1
@Oscar Reyes Diciamo che hai due thread che utilizzano entrambi la variabile i. Se il thread uno aumenta il thread solo quando è a un certo punto e l'altro lo diminuisce solo quando è in un altro punto, dovresti preoccuparti della sicurezza del thread.
samoz

Risposte:


158

Hai sentito male. Potrebbe anche essere "i++"thread-safe per un compilatore specifico e un'architettura di processore specifica, ma non è affatto obbligatorio negli standard. In effetti, poiché il multi-threading non fa parte degli standard ISO C o C ++ (a) , non puoi considerare nulla come thread-safe in base a ciò che pensi che verrà compilato.

È abbastanza fattibile che ++ipossa compilare una sequenza arbitraria come:

load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory

che non sarebbe thread-safe sulla mia CPU (immaginaria) che non ha istruzioni di incremento della memoria. Oppure potrebbe essere intelligente e compilarlo in:

lock         ; disable task switching (interrupts)
load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory
unlock       ; enable task switching (interrupts)

dove lockdisabilita e unlockabilita gli interrupt. Ma, anche in questo caso, questo potrebbe non essere thread-safe in un'architettura che ha più di una di queste CPU che condividono la memoria ( lockpotrebbero disabilitare gli interrupt solo per una CPU).

Il linguaggio stesso (o le relative librerie, se non è integrato nel linguaggio) fornirà costrutti thread-safe e dovresti usarli piuttosto che dipendere dalla tua comprensione (o forse incomprensione) di quale codice macchina verrà generato.

Cose come Java synchronizede pthread_mutex_lock()(disponibile per C / C ++ in alcuni sistemi operativi) sono ciò che devi esaminare (a) .


(a) Questa domanda è stata posta prima del completamento degli standard C11 e C ++ 11. Queste iterazioni hanno ora introdotto il supporto del threading nelle specifiche del linguaggio, inclusi i tipi di dati atomici (sebbene essi, e i thread in generale, siano opzionali, almeno in C).


8
+1 per aver sottolineato che questo non è un problema specifico della piattaforma, per non parlare di una risposta chiara ...
RBerteig

2
Congratulazioni per il tuo distintivo d'argento C :)
Johannes Schaub - litb

Penso che dovresti precisare che nessun sistema operativo moderno autorizza i programmi in modalità utente a disattivare gli interrupt e pthread_mutex_lock () non fa parte di C.
Bastien Léonard

@Bastien, nessun sistema operativo moderno sarebbe in esecuzione su una CPU che non avesse un'istruzione per l'incremento della memoria :-) Ma il tuo punto è su C.
paxdiablo

5
@Bastien: Bull. I processori RISC generalmente non hanno istruzioni per l'incremento della memoria. Il triplet load / add / stor è il modo in cui lo fai, ad esempio, su PowerPC.
derobert

42

Non puoi fare una dichiarazione generale su ++ i o i ++. Perché? Prendi in considerazione l'incremento di un numero intero a 64 bit su un sistema a 32 bit. A meno che la macchina sottostante non disponga di un'istruzione "carica, incrementa, memorizza" a quattro parole, l'incremento di quel valore richiederà più istruzioni, ognuna delle quali può essere interrotta da un cambio di contesto del thread.

Inoltre, ++inon è sempre "aggiungere uno al valore". In un linguaggio come il C, l'incremento di un puntatore aggiunge effettivamente la dimensione della cosa puntata. Cioè, se iè un puntatore a una struttura a 32 byte, ++iaggiunge 32 byte. Mentre quasi tutte le piattaforme hanno un'istruzione "incrementa il valore all'indirizzo di memoria" che è atomica, non tutte hanno un'istruzione atomica "aggiungi valore arbitrario al valore all'indirizzo di memoria".


35
Ovviamente se non ti limiti a noiosi interi a 32 bit, in un linguaggio come C ++, ++ posso davvero essere una chiamata a un servizio web che aggiorna un valore in un database.
Eclipse

16

Entrambi non sono sicuri per i thread.

Una CPU non può eseguire calcoli direttamente con la memoria. Lo fa indirettamente caricando il valore dalla memoria e facendo i conti con i registri della CPU.

i ++

register int a1, a2;

a1 = *(&i) ; // One cpu instruction: LOAD from memory location identified by i;
a2 = a1;
a1 += 1; 
*(&i) = a1; 
return a2; // 4 cpu instructions

++ i

register int a1;

a1 = *(&i) ; 
a1 += 1; 
*(&i) = a1; 
return a1; // 3 cpu instructions

In entrambi i casi, esiste una condizione di competizione che si traduce in un valore i imprevedibile.

Ad esempio, supponiamo che ci siano due thread ++ i simultanei con ciascuno che utilizza rispettivamente i registri a1, b1. E, con il cambio di contesto eseguito come segue:

register int a1, b1;

a1 = *(&i);
a1 += 1;
b1 = *(&i);
b1 += 1;
*(&i) = a1;
*(&i) = b1;

Di conseguenza, i non diventa i + 2, diventa i + 1, il che non è corretto.

Per rimediare a ciò, le CPU moden forniscono una sorta di istruzioni LOCK, UNLOCK cpu durante l'intervallo in cui il cambio di contesto è disabilitato.

In Win32, utilizzare InterlockedIncrement () per eseguire i ++ per la sicurezza dei thread. È molto più veloce che affidarsi al mutex.


6
"Una CPU non può eseguire calcoli direttamente con la memoria" - Questo non è accurato. Ci sono CPU, dove puoi fare matematica "direttamente" sugli elementi della memoria, senza la necessità di caricarla prima in un registro. Per esempio. MC68000
darklon

1
Le istruzioni LOCK e UNLOCK CPU non hanno nulla a che fare con i cambi di contesto. Bloccano le linee della cache.
David Schwartz

11

Se condividi anche un int tra thread in un ambiente multi-core, hai bisogno di adeguate barriere di memoria. Ciò può significare l'utilizzo di istruzioni interlock (vedere InterlockedIncrement in win32 per esempio) o l'utilizzo di un linguaggio (o compilatore) che fornisce determinate garanzie thread-safe. Con il riordino delle istruzioni a livello di CPU, le cache e altri problemi, a meno che tu non abbia queste garanzie, non dare per scontato che nulla condiviso tra i thread sia sicuro.

Modifica: una cosa che puoi presumere con la maggior parte delle architetture è che se hai a che fare con singole parole correttamente allineate, non ti ritroverai con una singola parola contenente una combinazione di due valori che sono stati schiacciati insieme. Se due scritture si sovrappongono, una vincerà e l'altra verrà scartata. Se stai attento, puoi trarne vantaggio e vedere che ++ i o i ++ sono thread-safe nella situazione di scrittore singolo / lettore multiplo.


Di fatto sbagliato in ambienti in cui l'accesso int (lettura / scrittura) è atomico. Esistono algoritmi che possono funzionare in tali ambienti, anche se la mancanza di barriere di memoria può significare che a volte stai lavorando su dati obsoleti.
MSalters

2
Sto solo dicendo che l'atomicità non garantisce la sicurezza dei fili. Se sei abbastanza intelligente da progettare strutture dati o algoritmi privi di blocchi, allora vai avanti. Ma devi ancora sapere quali sono le garanzie che il tuo compilatore ti darà.
Eclipse

10

Se vuoi un incremento atomico in C ++ puoi usare le librerie C ++ 0x (il std::atomictipo di dati) o qualcosa come TBB.

C'è stato un tempo in cui le linee guida di codifica GNU dicevano che aggiornare i tipi di dati che rientrano in una parola era "generalmente sicuro", ma quel consiglio è sbagliato per le macchine SMP, sbagliato per alcune architetture e sbagliato quando si usa un compilatore di ottimizzazione.


Per chiarire il commento "aggiornamento del tipo di dati di una sola parola":

È possibile che due CPU su una macchina SMP scrivano nella stessa posizione di memoria nello stesso ciclo e quindi provino a propagare la modifica alle altre CPU e alla cache. Anche se viene scritta solo una parola di dati, quindi le scritture richiedono solo un ciclo per essere completate, avvengono anche simultaneamente, quindi non è possibile garantire quale scrittura abbia esito positivo. Non otterrai dati parzialmente aggiornati, ma una scrittura scomparirà perché non c'è altro modo per gestire questo caso.

Confronta e scambia correttamente le coordinate tra più CPU, ma non c'è motivo di credere che ogni assegnazione di variabile di tipi di dati di una parola utilizzerà confronta e scambia.

E mentre un compilatore di ottimizzazione non influisce sulla modalità di compilazione di un caricamento / archivio, può cambiare quando avviene il caricamento / archivio, causando seri problemi se ti aspetti che le tue letture e scritture avvengano nello stesso ordine in cui appaiono nel codice sorgente ( il più famoso è il blocco a doppio controllo non funziona in vanilla C ++).

NOTA La mia risposta originale diceva anche che l'architettura Intel a 64 bit era rotta nella gestione dei dati a 64 bit. Non è vero, quindi ho modificato la risposta, ma la mia modifica ha affermato che i chip PowerPC erano rotti. Ciò è vero quando si leggono valori immediati (cioè costanti) nei registri (vedere le due sezioni denominate "Caricamento dei puntatori" nell'elenco 2 e nell'elenco 4). Ma c'è un'istruzione per caricare i dati dalla memoria in un ciclo ( lmw), quindi ho rimosso quella parte della mia risposta.


Le letture e le scritture sono atomiche sulla maggior parte delle CPU moderne se i dati sono naturalmente allineati e della dimensione corretta, anche con SMP e compilatori ottimizzati. Tuttavia, ci sono molti avvertimenti, soprattutto con macchine a 64 bit, quindi può essere complicato garantire che i dati soddisfino i requisiti su ogni macchina.
Dan Olson

Grazie per l'aggiornamento. Corretto, le letture e le scritture sono atomiche poiché affermi che non possono essere completate a metà, ma il tuo commento evidenzia come affrontiamo questo fatto nella pratica. Lo stesso con le barriere di memoria, non influenzano la natura atomica dell'operazione, ma il modo in cui la affrontiamo nella pratica.
Dan Olson


4

Se il tuo linguaggio di programmazione non dice nulla sui thread, ma gira su una piattaforma multithread, come può un qualsiasi costrutto di linguaggio essere thread-safe?

Come altri hanno sottolineato: è necessario proteggere qualsiasi accesso multithread alle variabili tramite chiamate specifiche della piattaforma.

Ci sono librerie là fuori che astraggono la specificità della piattaforma e il prossimo standard C ++ ha adattato il suo modello di memoria per far fronte ai thread (e quindi può garantire la sicurezza dei thread).


4

Anche se viene ridotto a una singola istruzione di assembly, incrementando il valore direttamente in memoria, non è comunque thread-safe.

Quando si incrementa un valore in memoria, l'hardware esegue un'operazione di "lettura-modifica-scrittura": legge il valore dalla memoria, lo incrementa e lo riscrive in memoria. L'hardware x86 non ha modo di incrementare direttamente sulla memoria; la RAM (e le cache) è solo in grado di leggere e memorizzare valori, non di modificarli.

Supponiamo ora di avere due core separati, su socket separati o condividendo un singolo socket (con o senza una cache condivisa). Il primo processore legge il valore e, prima di poter riscrivere il valore aggiornato, il secondo processore lo legge. Dopo che entrambi i processori hanno riscritto il valore, sarà stato incrementato solo una volta, non due.

C'è un modo per evitare questo problema; I processori x86 (e la maggior parte dei processori multi-core che troverai) sono in grado di rilevare questo tipo di conflitto nell'hardware e sequenziarlo, in modo che l'intera sequenza di lettura-modifica-scrittura appaia atomica. Tuttavia, poiché questo è molto costoso, viene fatto solo quando richiesto dal codice, su x86 solitamente tramite il LOCKprefisso. Altre architetture possono farlo in altri modi, con risultati simili; per esempio, comparazione e scambio atomico collegato al carico / condizionale all'archivio (i recenti processori x86 hanno anche quest'ultimo).

Nota che l'uso volatilenon aiuta qui; dice solo al compilatore che la variabile potrebbe essere stata modificata esternamente e le letture su quella variabile non devono essere memorizzate nella cache in un registro o ottimizzate. Non fa in modo che il compilatore utilizzi primitive atomiche.

Il modo migliore è usare primitive atomiche (se il tuo compilatore o le tue librerie le hanno), o fare l'incremento direttamente in assembly (usando le istruzioni atomiche corrette).


2

Non dare mai per scontato che un incremento si compili fino a diventare un'operazione atomica. Usa InterlockedIncrement o qualsiasi altra funzione simile esistente sulla tua piattaforma di destinazione.

Modifica: ho appena cercato questa domanda specifica e l'incremento su X86 è atomico su sistemi a processore singolo, ma non su sistemi multiprocessore. L'uso del prefisso di blocco può renderlo atomico, ma è molto più portabile solo per usare InterlockedIncrement.


1
InterlockedIncrement () è una funzione di Windows; tutte le mie macchine Linux e le moderne macchine OS X sono basate su x64, quindi dire che InterlockedIncrement () è "molto più portabile" del codice x86 è piuttosto spurio.
Pete Kirkham

È molto più portabile nello stesso senso in cui C è molto più portabile dell'assemblaggio. L'obiettivo qui è isolarsi dal fare affidamento su un assemblaggio generato specifico per un processore specifico. Se altri sistemi operativi sono la tua preoccupazione, InterlockedIncrement è facilmente avvolgibile.
Dan Olson

2

Secondo questa lezione di assembly su x86, puoi aggiungere atomicamente un registro a una posizione di memoria , quindi potenzialmente il tuo codice potrebbe eseguire atomicamente '++ i' ou 'i ++'. Ma come detto in un altro post, il C ansi non applica l'atomicità all'operazione '++', quindi non puoi essere sicuro di cosa genererà il tuo compilatore.


1

Lo standard C ++ del 1998 non ha nulla da dire sui thread, sebbene lo standard successivo (previsto per quest'anno o il prossimo) lo faccia. Pertanto, non è possibile dire nulla di intelligente sulla sicurezza dei thread delle operazioni senza fare riferimento all'implementazione. Non è solo il processore utilizzato, ma la combinazione del compilatore, del sistema operativo e del modello di thread.

In assenza di documentazione contraria, non presumo che qualsiasi azione sia thread-safe, in particolare con processori multi-core (o sistemi multiprocessore). Né mi fiderei dei test, poiché è probabile che i problemi di sincronizzazione dei thread si presentino solo per caso.

Niente è thread-safe a meno che tu non abbia la documentazione che dice che è per il particolare sistema che stai utilizzando.


1

Getta i nella memoria locale del thread; non è atomico, ma allora non importa.


1

Per quanto ne so, secondo lo standard C ++, le operazioni di lettura / scrittura su un intsono atomiche.

Tuttavia, tutto ciò che fa è sbarazzarsi del comportamento indefinito associato a una corsa di dati.

Ma ci sarà ancora una corsa di dati se entrambi i thread tenteranno di aumentare i.

Immagina il seguente scenario:

Lasciate i = 0inizialmente:

Il thread A legge il valore dalla memoria e lo archivia nella propria cache. Il thread A incrementa il valore di 1.

Il thread B legge il valore dalla memoria e lo memorizza nella propria cache. Il thread B incrementa il valore di 1.

Se questo fosse tutto un singolo thread che avresti i = 2in memoria.

Ma con entrambi i thread, ogni thread scrive le proprie modifiche e quindi il thread A riscrive i = 1in memoria e il thread B scrive i = 1in memoria.

È ben definito, non c'è distruzione o costruzione parziale o alcun tipo di lacerazione di un oggetto, ma è comunque una corsa ai dati.

Per incrementare atomicamente ipuoi usare:

std::atomic<int>::fetch_add(1, std::memory_order_relaxed)

È possibile utilizzare un ordine rilassato perché non ci interessa dove si svolge questa operazione, tutto ciò che ci interessa è che l'operazione di incremento sia atomica.


0

Dici "è solo un'istruzione, sarebbe ininterrotta da un cambio di contesto". - va tutto bene per una singola CPU, ma per quanto riguarda una CPU dual core? Quindi puoi davvero avere due thread che accedono alla stessa variabile contemporaneamente senza alcun cambio di contesto.

Senza conoscere la lingua, la risposta è metterla alla prova.


4
Non si scopre se qualcosa è thread-safe testandolo: i problemi di threading possono essere uno su un milione di occorrenze. Lo cerchi nella tua documentazione. Se non è garantito threadsafe dalla tua documentazione, non lo è.
Eclipse

2
D'accordo con @Josh qui. Qualcosa è thread-safe solo se può essere dimostrato matematicamente attraverso un'analisi del codice sottostante. Nessuna quantità di test può iniziare ad avvicinarsi a questo.
Rex M

È stata un'ottima risposta fino all'ultima frase.
Rob K

0

Penso che se l'espressione "i ++" è l'unica in un'istruzione, è equivalente a "++ i", il compilatore è abbastanza intelligente da non mantenere un valore temporale, ecc. Quindi se puoi usarli in modo intercambiabile (altrimenti hai vinto non ti chiedo quale usare), non importa quale usi perché sono quasi uguali (tranne che per l'estetica).

Ad ogni modo, anche se l'operatore di incremento è atomico, ciò non garantisce che il resto del calcolo sarà coerente se non si utilizzano i lock corretti.

Se vuoi sperimentare da solo, scrivi un programma in cui N thread incrementano simultaneamente una variabile condivisa M volte ciascuno ... se il valore è inferiore a N * M, allora qualche incremento è stato sovrascritto. Provalo sia con preincremento che con postincremento e dicci ;-)


0

Per un contatore, consiglio di utilizzare l'idioma di confronto e scambio che è sia non bloccante che thread-safe.

Eccolo in Java:

public class IntCompareAndSwap {
    private int value = 0;

    public synchronized int get(){return value;}

    public synchronized int compareAndSwap(int p_expectedValue, int p_newValue){
        int oldValue = value;

        if (oldValue == p_expectedValue)
            value = p_newValue;

        return oldValue;
    }
}

public class IntCASCounter {

    public IntCASCounter(){
        m_value = new IntCompareAndSwap();
    }

    private IntCompareAndSwap m_value;

    public int getValue(){return m_value.get();}

    public void increment(){
        int temp;
        do {
            temp = m_value.get();
        } while (temp != m_value.compareAndSwap(temp, temp + 1));

    }

    public void decrement(){
        int temp;
        do {
            temp = m_value.get();
        } while (temp > 0 && temp != m_value.compareAndSwap(temp, temp - 1));

    }
}

Sembra simile a una funzione test_and_set.
samoz

1
Hai scritto "non blocco", ma "sincronizzato" non significa blocco?
Corey Trager
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.