Puoi spiegare perché più thread hanno bisogno di blocchi su una CPU single-core?


18

Supponiamo che questi thread vengano eseguiti in CPU single core. Come CPU eseguire solo un'istruzione in un ciclo. Detto questo, anche se hanno condiviso la risorsa CPU. ma il computer assicura che una volta un'istruzione. Quindi il blocco non è necessario per il multiplethreading?


Perché la memoria transazionale del software non è ancora mainstream.
dan_waterworth,

@dan_waterworth Perché la memoria transazionale del software si guasta male a livelli di complessità non banali, vuoi dire? ;)
Mason Wheeler,

Scommetto che Rich Hickey non è d'accordo.
Robert Harvey,

@MasonWheeler, mentre il blocco non banale funziona incredibilmente bene e non è mai stato una fonte di bug sottili che sono difficili da rintracciare? STM funziona bene con livelli di complessità non banali, ma è problematico quando c'è contesa. In questi casi, qualcosa di simile a questo , che è una forma più restrittiva di STM è meglio. A proposito, con il cambio di titolo, ci ho messo un po 'a capire perché ho commentato come ho fatto.
dan_waterworth,

Risposte:


32

Questo è meglio illustrato con un esempio.

Supponiamo di avere un'attività semplice che vogliamo eseguire più volte in parallelo e di tenere traccia a livello globale del numero di volte in cui l'attività è stata eseguita, ad esempio contando gli hit su una pagina Web.

Quando ogni thread arriva al punto in cui sta incrementando il conteggio, la sua esecuzione sarà simile a questa:

  1. Leggere il numero di hit dalla memoria in un registro del processore
  2. Incrementa quel numero.
  3. Scrivi quel numero in memoria

Ricorda che ogni thread può sospendere in qualsiasi momento di questo processo. Quindi se il thread A esegue il passaggio 1 e quindi viene sospeso, seguendo il thread B eseguendo tutti e tre i passaggi, quando il thread A riprende, i suoi registri avranno il numero errato di hit: i suoi registri verranno ripristinati, aumenterà felicemente il vecchio numero di hit e memorizza quel numero incrementato.

Inoltre, qualsiasi numero di altri thread potrebbe essere stato eseguito durante il tempo in cui il thread A è stato sospeso, quindi il thread conteggio A che scrive alla fine potrebbe essere ben al di sotto del conteggio corretto.

Per tale motivo, è necessario assicurarsi che se un thread esegue il passaggio 1, deve eseguire il passaggio 3 prima che qualsiasi altro thread sia autorizzato a eseguire il passaggio 1, che può essere realizzato da tutti i thread in attesa di ottenere un singolo blocco prima di iniziare questo processo e liberare il blocco solo dopo il completamento del processo, in modo che questa "sezione critica" del codice non possa essere intercalata in modo errato, determinando un conteggio errato.

E se l'operazione fosse atomica?

Sì, nella terra degli unicorni magici e degli arcobaleni, dove l'operazione di incremento è atomica, non sarebbe necessario il bloccaggio per l'esempio sopra.

È importante rendersi conto, tuttavia, che trascorriamo pochissimo tempo nel mondo di magici unicorni e arcobaleni. In quasi tutti i linguaggi di programmazione, l'operazione di incremento è suddivisa nei tre passaggi precedenti. Questo perché, anche se il processore supporta un'operazione di incremento atomico, tale operazione è significativamente più costosa: deve leggere dalla memoria, modificare il numero e riscriverlo in memoria ... e di solito l'operazione di incremento atomico è un'operazione che può fallire, il che significa che la semplice sequenza sopra deve essere sostituita con un ciclo (come vedremo di seguito).

Poiché, anche nel codice multithread, molte variabili sono mantenute locali in un singolo thread, i programmi sono molto più efficienti se assumono che ogni variabile sia locale in un singolo thread e consentono ai programmatori di proteggere lo stato condiviso tra i thread. Soprattutto dato che le operazioni atomiche di solito non sono sufficienti per risolvere i problemi di threading, come vedremo più avanti.

Variabili volatili

Se volessimo evitare i blocchi per questo particolare problema, dobbiamo prima renderci conto che i passaggi illustrati nel nostro primo esempio non sono in realtà ciò che accade nel moderno codice compilato. Poiché i compilatori presuppongono che un solo thread stia modificando la variabile, ogni thread manterrà la propria copia cache della variabile, fino a quando il registro del processore non sarà necessario per qualcos'altro. Finché ha la copia memorizzata nella cache, si presuppone che non sia necessario tornare in memoria e rileggerlo (che sarebbe costoso). Inoltre non riscriveranno la variabile in memoria fintanto che è tenuta in un registro.

Possiamo tornare alla situazione che abbiamo dato nel primo esempio (con tutti gli stessi problemi di threading che abbiamo identificato sopra) contrassegnando la variabile come volatile , che dice al compilatore che questa variabile viene modificata da altri, e quindi deve essere letta da o scritto in memoria ogni volta che si accede o modificato.

Quindi una variabile contrassegnata come volatile non ci porterà nella terra delle operazioni di incremento atomico, ci avvicina solo quanto pensavamo di essere già.

Rendere atomico l'incremento

Una volta che utilizziamo una variabile volatile, possiamo rendere atomica la nostra operazione di incremento utilizzando un'operazione di set condizionale di basso livello supportata dalla maggior parte delle CPU moderne (spesso chiamata confronta e imposta o confronta e scambia ). Questo approccio è adottato, ad esempio, nella classe AtomicInteger di Java :

197       /**
198        * Atomically increments by one the current value.
199        *
200        * @return the updated value
201        */
202       public final int incrementAndGet() {
203           for (;;) {
204               int current = get();
205               int next = current + 1;
206               if (compareAndSet(current, next))
207                   return next;
208           }
209       }

Il ciclo precedente esegue ripetutamente i seguenti passaggi, fino a quando il passaggio 3 ha esito positivo:

  1. Leggi il valore di una variabile volatile direttamente dalla memoria.
  2. Incrementa quel valore.
  3. Cambia il valore (nella memoria principale) se e solo se il suo valore corrente nella memoria principale è lo stesso del valore che abbiamo letto inizialmente, usando una speciale operazione atomica.

Se il passaggio 3 non riesce (poiché il valore è stato modificato da un thread diverso dopo il passaggio 1), legge nuovamente la variabile direttamente dalla memoria principale e riprova.

Sebbene l'operazione di confronto e scambio sia costosa, è leggermente meglio che usare il blocco in questo caso, perché se un thread viene sospeso dopo il passaggio 1, altri thread che raggiungono il passaggio 1 non devono bloccare e attendere il primo thread, che può impedire costosi cambi di contesto. Quando il primo thread riprende, non riuscirà nel primo tentativo di scrivere la variabile, ma sarà in grado di continuare rileggendo la variabile, che di nuovo è probabilmente meno costosa dell'interruttore di contesto che sarebbe stato necessario con il blocco.

Quindi, possiamo arrivare alla terra degli incrementi atomici (o altre operazioni su una singola variabile) senza usare i blocchi effettivi, tramite compare e swap.

Quindi, quando è strettamente necessario il bloccaggio?

Se è necessario modificare più di una variabile in un'operazione atomica, sarà necessario il blocco, per questo non troverete istruzioni speciali per il processore.

Fintanto che stai lavorando su una singola variabile e sei pronto per qualsiasi lavoro che hai fatto per fallire e per dover leggere la variabile e ricominciare da capo, comparare e scambiare sarà comunque abbastanza buono.

Consideriamo un esempio in cui ogni thread aggiunge prima 2 a una variabile X, quindi moltiplica X per due.

Se X è inizialmente uno e vengono eseguiti due thread, ci aspettiamo che il risultato sia (((1 + 2) * 2) + 2) * 2 = 16.

Tuttavia, se i thread si interfogliano, potremmo, anche se tutte le operazioni sono atomiche, invece prima si verificherebbero entrambe le aggiunte e le moltiplicazioni vengono dopo, risultando in (1 + 2 + 2) * 2 * 2 = 20.

Ciò accade perché la moltiplicazione e l'aggiunta non sono operazioni commutative.

Quindi, le operazioni stesse essendo atomiche non sono sufficienti, dobbiamo rendere atomica la combinazione di operazioni.

Possiamo farlo usando il blocco per serializzare il processo, oppure potremmo usare una variabile locale per memorizzare il valore di X quando abbiamo iniziato il nostro calcolo, una seconda variabile locale per i passaggi intermedi e quindi usare compare-e-scambiare per impostare un nuovo valore solo se il valore corrente di X è uguale al valore originale di X. Se falliamo, dovremmo ricominciare da capo leggendo X ed eseguendo nuovamente i calcoli.

Esistono diversi compromessi: man mano che i calcoli diventano più lunghi, diventa molto più probabile che il thread in esecuzione venga sospeso e il valore verrà modificato da un altro thread prima di riprendere, il che significa che i guasti diventano molto più probabili, portando a uno spreco tempo del processore. Nel caso estremo di un numero elevato di thread con calcoli a esecuzione molto lunga, potremmo avere 100 thread che leggono la variabile ed essere impegnati in calcoli, nel qual caso solo il primo a finire riuscirà a scrivere il nuovo valore, l'altro 99 rimarrà comunque completa i loro calcoli, ma al termine scoprono che non possono aggiornare il valore ... a quel punto ciascuno leggeranno il valore e ricominceranno il calcolo. Probabilmente i rimanenti 99 thread ripeteranno lo stesso problema, sprecando enormi quantità di tempo del processore.

La serializzazione completa della sezione critica tramite blocchi sarebbe molto meglio in quella situazione: 99 thread si sospenderebbero se non ottenessero il blocco e avremmo eseguito ogni thread in ordine di arrivo nel punto di blocco.

Se la serializzazione non è critica (come nel nostro caso di incremento) e i calcoli che andrebbero persi se l'aggiornamento del numero fallisce sono minimi, potrebbe esserci un vantaggio significativo da ottenere utilizzando l'operazione di confronto e scambio, poiché tale operazione è meno costoso del bloccaggio.


ma cosa succede se il contro aumento è atomico, la serratura era necessaria?
Pythonee,

@pythonee: se l'incremento del contatore è atomico, probabilmente no. Ma in qualsiasi programma multithread di dimensioni ragionevoli avrai compiti non atomici da svolgere su una risorsa condivisa.
Doc Brown,

1
A meno che tu non stia usando un compilatore intrinseco per rendere atomico l'incremento, probabilmente non lo è.
Mike Larsen,

Sì, se la lettura / modifica (incremento) / scrittura è atomica, il blocco non è necessario per tale operazione. L'istruzione DEC-10 AOSE (aggiungi uno e salta se risultato == 0) è stata resa atomica in modo specifico in modo da poter essere utilizzata come semaforo test-and-set. Il manuale menziona che era abbastanza buono perché ci sarebbero voluti diversi giorni di conteggio continuo per far rotolare un registro a 36 bit fino in fondo. ORA, tuttavia, non tutto ciò che fai sarà "aggiungerne uno alla memoria".
John R. Strohm,

Ho aggiornato la mia risposta per affrontare alcune di queste preoccupazioni: sì, puoi rendere atomica l'operazione, ma no, anche su architetture che la supportano, non sarà atomica per impostazione predefinita e ci sono situazioni in cui l'atomicità non lo è è necessaria una serializzazione completa e completa. Il blocco è l'unico meccanismo di cui sono a conoscenza per ottenere la serializzazione completa.
Theodore Murdock,

4

Considera questa citazione:

Alcune persone, di fronte a un problema, pensano: "Lo so, userò i thread", e poi due hanno dei poblesmi

vedi, anche se 1 istruzione viene eseguita su una CPU in un dato momento, i programmi per computer comprendono molto più di semplici istruzioni di assemblaggio atomico. Quindi, ad esempio, scrivere sulla console (o su un file) significa che devi bloccare per assicurarti che funzioni come desideri.


Pensavo che la citazione fosse espressioni regolari, non discussioni?
user16764

3
La citazione sembra molto più applicabile per i thread per me (con le parole / i caratteri stampati fuori ordine a causa di problemi di threading). Ma c'è attualmente una "s" in più nell'output, il che suggerisce che il codice ha tre problemi.
Theodore Murdock,

1
è un effetto collaterale. Molto occasionalmente potresti aggiungere 1 più 1 e ottenere 4294967295 :)
gbjbaanb

3

Sembra che molte risposte abbiano tentato di spiegare il blocco, ma penso che ciò di cui l'OP ha bisogno sia una spiegazione di cosa sia realmente il multitasking.

Quando hai più di un thread in esecuzione su un sistema anche con una CPU, ci sono due metodologie principali che determinano la modalità di pianificazione di questi thread (ovvero posizionati per l'esecuzione nella tua CPU single-core):

  • Multitasking cooperativo : utilizzato in Win9x, ogni applicazione richiedeva esplicitamente il controllo. In questo caso, non dovrai preoccuparti del blocco poiché finché il thread A esegue un algoritmo, ti verrà garantito che non verrà mai interrotto
  • Multitasking preventivo - Utilizzato nella maggior parte dei sistemi operativi moderni (Win2k e successivi). Questo utilizza i tempi e interromperà i thread anche se stanno ancora lavorando. Questo è molto più robusto perché un singolo thread non può mai appendere l'intera macchina, il che era una possibilità reale con il multitasking cooperativo. D'altra parte, ora devi preoccuparti dei blocchi perché in qualsiasi momento, uno dei tuoi thread potrebbe essere interrotto (cioè preemptato) e il sistema operativo potrebbe pianificare l'esecuzione di un thread diverso. Quando codifichi applicazioni multithread con questo comportamento, DEVI considerare che tra ogni riga di codice (o anche ogni istruzione) potrebbe essere eseguito un thread diverso. Ora, anche con un singolo core, il blocco diventa molto importante per garantire uno stato coerente dei dati.

0

Il problema non riguarda le singole operazioni, ma i compiti più grandi che le operazioni svolgono.

Molti algoritmi sono scritti con il presupposto che abbiano il pieno controllo dello stato su cui operano. Con un modello di esecuzione ordinata interlacciato come quello che descrivi, le operazioni possono essere arbitrariamente intercalate tra loro e, se condividono lo stato, c'è il rischio che lo stato abbia una forma incoerente.

È possibile confrontarlo con funzioni che possono interrompere temporaneamente un invariante per fare ciò che fanno. Finché lo stato intermedio non è osservabile dall'esterno, possono fare tutto ciò che vogliono per svolgere il loro compito.

Quando si scrive un codice simultaneo, è necessario assicurarsi che lo stato conteso sia considerato non sicuro a meno che non si abbia un accesso esclusivo ad esso. Il modo comune per ottenere l'accesso esclusivo è la sincronizzazione su una primitiva di sincronizzazione, come tenere un lucchetto.

Un'altra cosa che le primitive di sincronizzazione tendono a provocare su alcune piattaforme è che emettono barriere di memoria, che assicurano la coerenza della memoria tra CPU.


0

Fatta eccezione per l'impostazione "bool", non vi è alcuna garanzia (almeno in c) che la lettura o la scrittura di una variabile richiede solo un'istruzione - o piuttosto non può essere interrotta nel mezzo della lettura / scrittura


quante istruzioni richiederebbe un numero intero a 32 bit?
DXM,

1
Puoi espandere un po 'la tua prima affermazione. Implichi che solo un bool può essere letto / scritto atomicamente, ma non ha senso. Un "bool" in realtà non esiste nell'hardware. Di solito è implementato come un byte o una parola, quindi come potrebbe boolavere solo questa proprietà? E stai parlando di caricare dalla memoria, alterare e tornare alla memoria, o stai parlando a livello di registro? Tutte le letture / scritture nei registri sono ininterrotte, ma il caricamento dei mem e lo store dei mem non lo sono (dato che solo 2 istruzioni, quindi almeno 1 in più per modificare il valore).
Corbin,

1
Il concetto di una singola istruzione in una CPU hyperhreaded / multicore / branch-prediction / multi-cache è un po 'complicato - ma lo standard dice che solo' bool 'deve essere sicuro contro un cambio di contesto nel mezzo di una lettura / scrittura di una singola variabile. C'è una spinta :: Atomic che avvolge il mutex attorno ad altri tipi e penso che il c ++ 11 aggiunga altre garanzie di threading
Martin Beckett,

La spiegazione the standard says that only 'bool' needs to be safe against a context switch in the middle of a read/write of a single variabledovrebbe davvero essere aggiunta alla risposta.
Wolf

0

Memoria condivisa.

È la definizione di ... thread : un insieme di processi simultanei, con memoria condivisa.

Se non c'è memoria condivisa, di solito vengono definiti processi UNIX di vecchia scuola .
Potrebbero aver bisogno di un lucchetto, di tanto in tanto, quando si accede a un file condiviso.

(la memoria condivisa in kernel simili a UNIX veniva infatti di solito implementata utilizzando un descrittore di file falso che rappresenta l'indirizzo di memoria condivisa)


0

Una CPU esegue un'istruzione alla volta, ma cosa succede se si dispone di due o più CPU?

Hai ragione nel dire che i blocchi non sono necessari, se puoi scrivere il programma in modo tale che tragga vantaggio dalle istruzioni atomiche: istruzioni la cui esecuzione non è interrompibile su un determinato processore e prive di interferenze da parte di altri processori.

I blocchi sono necessari quando diverse istruzioni devono essere protette dalle interferenze e non esiste un'istruzione atomica equivalente.

Ad esempio, l'inserimento di un nodo in un elenco doppiamente collegato richiede l'aggiornamento di diverse posizioni di memoria. Prima dell'inserimento e dopo l'inserzione, alcuni invarianti sostengono la struttura dell'elenco. Tuttavia, durante l'inserimento, quegli invarianti sono temporaneamente rotti: l'elenco è in uno stato "in costruzione".

Se un altro thread marcia attraverso l'elenco mentre gli invarianti, o tenta anche di modificarlo quando si trova in uno stato del genere, la struttura dei dati verrà probabilmente danneggiata e il comportamento sarà imprevedibile: forse il software si arresterà in modo anomalo o continuerà con risultati errati. È quindi necessario che i thread concordino in qualche modo di stare lontani gli uni dagli altri quando l'elenco viene aggiornato.

Gli elenchi opportunamente progettati possono essere manipolati con istruzioni atomiche, in modo che i blocchi non siano necessari. Gli algoritmi per questo sono chiamati "lock free". Tuttavia, si noti che le istruzioni atomiche sono in realtà una forma di blocco. Sono appositamente implementati nell'hardware e funzionano tramite la comunicazione tra i processori. Sono più costosi di istruzioni simili che non sono atomiche.

Sui multiprocessori privi del lusso delle istruzioni atomiche, le primitive per l'esclusione reciproca devono essere costruite con semplici accessi alla memoria e cicli di polling. Tali problemi sono stati risolti da artisti del calibro di Edsger Dijkstra e Leslie Lamport.


Cordiali saluti, ho letto degli algoritmi senza blocco per elaborare gli aggiornamenti degli elenchi doppiamente collegati utilizzando solo un singolo confronto e scambio. Inoltre, ho letto un white paper su una struttura che sembrerebbe molto più economica nell'hardware rispetto a un doppio confronto e scambio (che è stato implementato nel 68040 ma non è stato eseguito in altri processori 68xxx): estendere il carico -linked / store-conditional per consentire due carichi collegati e store condizionali, ma a condizione che un accesso che si verifica tra i due store non esegua il rollback del primo. È molto più facile da implementare di un doppio confronto e archiviazione ...
Supercat,

... ma offrirà vantaggi simili quando si tenta di gestire gli aggiornamenti dell'elenco a doppio collegamento. Per quanto ne so, il doppio carico collegato non ha preso piede, ma il costo dell'hardware sembrerebbe piuttosto economico se ci fosse una domanda.
supercat,
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.