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:
- Leggere il numero di hit dalla memoria in un registro del processore
- Incrementa quel numero.
- 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:
- Leggi il valore di una variabile volatile direttamente dalla memoria.
- Incrementa quel valore.
- 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.