Una singola istruzione può sembrare un'azione indivisibile mentre non lo è affatto. Prendi l'aggiunta di cinque a un valore in memoria:
add qword [rel counter], 5
Sotto il cofano, il processore non ha un modo per sommare direttamente in memoria. Suddivide questa singola istruzione in tre passaggi più piccoli, chiamati micro-operazioni:
mov rax, qword [rel counter] ; 1. load the current value
add rax, 5 ; 2. add five
mov qword [rel counter], rax ; 3. store the result back
Questo è un pattern comune chiamato read-modify-write (RMW).
Nota che la lettura (o load) e la scrittura (o store) sono eventi separati: c'è quindi una finestra temporale tra l'una e l'altra.
Di solito non ce ne accorgiamo perché su un singolo core ogni istruzione ha la garanzia di produrre il suo effetto per intero prima della successiva.
Quella finestra è quindi invisibile e add si comporta come un'unica unità.
Tuttavia, le CPU moderne raramente hanno un solo core, e le applicazioni spesso girano su molti core contemporaneamente, senza alcun ordine tra loro.
Con diversi core in esecuzione nello stesso istante, un altro core può leggere o scrivere counter dentro quella finestra, dopo il load di questo core e prima del suo store.
Due thread caricano ciascuno lo stesso vecchio valore, ciascuno aggiunge cinque e ciascuno memorizza il proprio risultato.
Sono avvenute due addizioni, ma il valore è avanzato solo di cinque.
Un aggiornamento è andato perso in silenzio.
Questa è una data race, un problema comune nel codice multi-thread.
Non ogni valore è esposto in questo modo.
Ogni thread ha i propri registri e il proprio stack, quindi un valore contenuto in un registro, o una variabile locale sullo stack di un thread, è esclusivo di quel thread e non può entrare in una data race.
Solo la memoria condivisa tra i thread, come counter qui sopra, ha bisogno di protezione.
x86-64 offre una serie di istruzioni per risolvere questo problema, rendendo un'istruzione indivisibile. Questa si comporta come un'unica unità non solo per il core che la esegue, ma anche per ogni altro core. Un'operazione che resta compatta, che nessun altro core può dividere, si dice atomica.
È comune chiamare multi-thread le applicazioni che girano su molti core. Tuttavia, un thread non è la stessa cosa di un core. Due thread possono essere eseguiti in modo concorrente sullo stesso core, interlacciandosi tra loro, oppure in parallelo su core diversi.
Thread interlacciati possono già entrare in una data race su un read-modify-write se questo è suddiviso in più istruzioni, dato che il sistema operativo può passare da un thread all'altro tra due istruzioni qualsiasi. La finestra interna a una singola istruzione, invece, è esposta solo da codice davvero parallelo. Poiché il sistema operativo cambia thread solo tra un'istruzione e l'altra, mai dentro una, una singola istruzione è intrinsecamente sicura su un singolo core.
La vera atomicità su più core è ciò che forniscono le istruzioni che seguono.
L'istruzione xchg scambia due operandi.
L'operando di destinazione diventa uguale al valore precedente dell'operando sorgente, mentre l'operando sorgente diventa uguale al valore precedente dell'operando di destinazione.
Concettualmente, la si può pensare come due istruzioni mov che avvengono nello stesso momento.
Come al solito, la si può usare con due operandi registro oppure con un operando in memoria e uno in registro:
mov eax, 1
xchg dword [rdi], eax ; [rdi] = 1, eax = the old value of [rdi]
mov ecx, 2
mov edx, 3
xchg edx, ecx ; edx = 2, ecx = 3
Quando viene usata con un operando in memoria, xchg è sempre atomica.
xchg è automaticamente atomica quando uno degli operandi è una locazione di memoria.
Questo significa anche che l'operazione è molto più lenta in quella situazione.
Se non ti serve l'atomicità, esegui lo scambio passando per un registro libero con semplici istruzioni mov.
Il modo più comune per rendere atomica un'istruzione in x86-64 è aggiungere il prefisso lock.
Esso fonde la lettura, la modifica e la scrittura in un unico passaggio indivisibile.
Questo significa che il core mantiene la memoria in esclusiva per tutto il processo, così nessun altro core può leggere o scrivere quella locazione nel frattempo.
lock add qword [rel counter], 5 ; the read, the modify, and the write are one step
lock funziona solo quando la destinazione è in memoria, e solo su istruzioni che eseguono un read-modify-write su quella memoria:
add, sub, inc, dec, neg;and, or, xor, not;bts, btr, btc;xadd e cmpxchg, descritte più avanti.Tenere una locazione in esclusiva e impedire l'accesso a ogni altro core non è gratis.
Un'operazione con il prefisso lock è decisamente più lenta della sua forma semplice, e ancora più lenta quando diversi core si contendono la stessa locazione.
Questo prefisso va riservato alla memoria che ci si aspetta venga modificata da più di un thread. Evitalo se la memoria non è condivisa o se viene soltanto letta.
Un semplice lock add aggiorna la memoria ma butta via il vecchio valore.
Spesso il vecchio valore è proprio quello che serve, per esempio per assegnare a ogni thread un numero di ticket distinto.
L'istruzione xadd (x sta per exchange) restituisce il valore precedente mentre somma.
Scrive la somma nella destinazione e lascia il valore originale della destinazione nel registro sorgente.
mov rax, 1
lock xadd qword [rdi], rax ; [rdi] = [rdi] + rax = [rdi] + 1
; rax = the old value of [rdi]
Con il prefisso lock questa è una fetch-and-add atomica.
Eseguita da molti thread sullo stesso contatore, ogni chiamata restituisce un vecchio valore diverso.
Come con lock add, il contatore finisce esattamente al numero di chiamate.
A differenza di lock add, però, anche ogni valore intermedio viene restituito, uno a ciascun chiamante.
xadd somma e xchg sovrascrive, ma nessuna delle due può far dipendere il nuovo valore da quello corrente e applicarlo solo se nulla è cambiato nel frattempo.
È questo aggiornamento condizionale che offre cmpxchg, compare-and-exchange, la più generale di queste primitive.
cmpxchg dest, src usa rax come accumulatore implicito e lo confronta con dest:
dest == rax, allora dest = src e ZF = 1.dest != rax, allora rax = dest e ZF = 0.Nota che dest viene aggiornato solo quando è uguale al valore atteso, caricato in precedenza in rax.
Questa uguaglianza garantisce che dest contenga ancora il valore da cui è stato calcolato quello nuovo, così un aggiornamento basato su una lettura obsoleta non viene mai applicato.
Questo rende cmpxchg il mattone su cui costruire un aggiornamento atomico, noto anche come compare-and-swap (CAS):
mov rax, qword [rdi] ; rax = the value we expect to find
.retry:
lea rcx, [rax + 10] ; rcx = the new value we want to install
lock cmpxchg qword [rdi], rcx ; if [rdi] still equals rax, store rcx and set ZF
; otherwise reload rax with the current value, clear ZF
jnz .retry ; ZF is cleared, so another thread won the race. Recompute and retry
Questo ciclo di retry è il cuore degli aggiornamenti lock-free.
La finestra tra la lettura e il compare-and-exchange è esattamente il momento in cui un altro thread potrebbe intromettersi, e cmpxchg se ne accorge rifiutandosi di memorizzare un valore calcolato da una lettura obsoleta.
Ogni operazione vista finora ha toccato una sola locazione. Quando i thread si coordinano attraverso più di una locazione, compare una nuova domanda: in quale ordine le scritture di un thread diventano visibili a un altro. Le regole che rispondono a questa domanda costituiscono l'ordinamento della memoria del processore.
Il concetto di codice senza rami ha introdotto l'idea che un core moderno non procede un'istruzione alla volta. Ne tiene molte in volo contemporaneamente e corre avanti dove può. Questo significa che una scrittura può diventare visibile agli altri core più tardi di quanto il programma lasci intendere, mentre le istruzioni successive sono già andate avanti.
x86-64 mantiene un ordinamento della memoria forte tra normali load e store, così che su ciascun core:
L'unico riordino possibile è uno store che sembra completarsi dopo un load successivo di un indirizzo diverso.
Un'istruzione con il prefisso lock, o una xchg con un operando in memoria, è una barriera completa: nulla sembra attraversarla in nessuna delle due direzioni.
Ecco perché bastano a garantire l'ordinamento completo nella maggior parte delle situazioni.
pause
Le istruzioni che impostano un flag restituendo anche il suo stato precedente sono note come test-and-set. Possono essere usate come base di uno spinlock, garantendo a un core l'accesso esclusivo a una parte del codice.
Questo è l'algoritmo generale, usando l'istruzione xchg con un flag binario:
0.1.1, significa che il lock è occupato da un altro core.
Il core corrente allora aspetta e prova di nuovo ad acquisire il lock.0, significa che il lock era libero.
Ora xchg lo ha impostato a 1, e gli altri core aspetteranno finché questo core non lo rilascia.0 rilascia il lock.acquire:
mov eax, 1
xchg dword [rdi], eax ; try to take the lock; eax = its old value
test eax, eax
jnz .held ; old value was 1: someone else holds it
ret ; old value was 0: the lock is ours
.held:
pause ; wait before trying again
jmp acquire
L'istruzione pause nel ciclo di attesa non cambia ciò che il codice calcola.
Suggerisce al processore che si tratta di un'attesa attiva.
La CPU può così ridurre il consumo di energia del thread in attesa e cedere il passo a un thread fratello che condivide lo stesso core.
Un ciclo di spin senza pause è comunque corretto, solo dispendioso.
Quando il thread ha finito il suo lavoro, può rilasciare il lock con un semplice store di 0 usando mov.
Non serve nulla più di un mov, perché su x86-64 i load e gli store non vengono mai riordinati dopo uno store successivo.
Uno store che non supera mai gli accessi che lo precedono si dice che ha ordinamento di rilascio, e su x86 ogni store semplice lo possiede.
Qualsiasi istruzione che verifichi e imposti atomicamente una locazione di memoria può essere usata per uno spinlock.
Per esempio, si può usare lock bts invece di xchg, per impostare uno specifico bit verificando al contempo se era già impostato.
Nota che i flag, come il CF modificato da bts, fanno parte di rflags, un registro.
Questo significa che sono esclusivi di ciascun thread.
È la serata di apertura al Grand Marquee e tutto il personale è al lavoro nello stesso momento. La folla entra da ogni porta, diverse biglietterie vendono dallo stesso rotolo di biglietti, la prima è a un passo dal tutto esaurito e lassù, in cabina di proiezione, le bobine devono continuare a girare.
Tutto ciò che il personale condivide vive a un indirizzo di memoria, e i test chiamano le tue funzioni da diversi thread nello stesso momento. Un valore letto, modificato e riscritto in passaggi separati perderà degli aggiornamenti sotto quel carico, quindi ogni funzione deve rendere atomico il proprio aggiornamento.
In questo esercizio tutti i valori condivisi sono numeri interi con segno a 64 bit.
Hai cinque compiti.
Gli aggiornamenti in questo esercizio vanno eseguiti con istruzioni atomiche. I test eseguono le tue funzioni in concorrenza e verificano che nessun aggiornamento vada perso, quindi un'operazione non atomica di lettura, modifica e scrittura molto probabilmente li farà fallire.
Le porte sono aperte e a ognuna di esse una maschera sta strappando i biglietti. Ogni gruppo fatto passare viene contato in un unico conteggio condiviso degli ingressi e, con ogni porta che conta nello stesso momento, il totale della sala deve comunque risultare esatto.
Implementa la funzione admit_group, che aggiunge atomicamente la dimensione di un gruppo al conteggio.
Questa funzione accetta come argomenti, in quest'ordine:
admissions: indirizzo di memoria del conteggio condiviso degli ingressi, un numero intero con segno a 64 bit.group_size: il numero di persone nel gruppo, un numero intero con segno a 64 bit, sempre maggiore di 0.admissions = 120, group_size = 4
// admissions is now 124
I test fanno entrare gruppi da diverse porte contemporaneamente. Il conteggio finale deve essere uguale alla somma di tutti i gruppi fatti passare.
Questa funzione non restituisce alcun valore. Il conteggio va aggiornato sul posto.
Il proiettore monta una sola bobina. Montare la bobina successiva e togliere quella corrente deve essere un unico movimento: con due proiezionisti alla macchina, due passaggi separati potrebbero consegnare la stessa bobina a entrambi, o farne cadere una a terra.
Implementa la funzione change_reel, che monta atomicamente la bobina successiva e restituisce quella che era montata.
Questa funzione accetta come argomenti, in quest'ordine:
mounted: indirizzo di memoria dell'identificatore della bobina montata, un numero intero con segno a 64 bit.next_reel: l'identificatore della bobina da montare, un numero intero con segno a 64 bit.mounted = 7, next_reel = 9
// => 7, and mounted is now 9
Questa funzione restituisce l'identificatore della bobina montata in precedenza come numero intero con segno a 64 bit. I test fanno scambiare bobine a diversi thread attraverso lo stesso supporto contemporaneamente e verificano che ogni bobina consegnata ritorni fuori esattamente una volta.
Ogni biglietteria vende da un unico conteggio condiviso dei biglietti venduti. Aggiungere a un conteggio, come fanno le porte, qui non basta: ogni vendita deve anche sapere il numero che le è stato assegnato. Due vendite non possono condividere lo stesso numero e, dopo la ressa, il conteggio deve essere esattamente uguale al numero di biglietti venduti.
Implementa la funzione sell_ticket, che aggiunge atomicamente uno al conteggio e restituisce il numero del biglietto appena venduto.
Il numero del biglietto è il conteggio dopo questa vendita: quando il conteggio era 41, questa vendita è il biglietto 42.
Questa funzione accetta come argomento:
tickets_sold: indirizzo di memoria del conteggio condiviso dei biglietti venduti, un numero intero con segno a 64 bit.tickets_sold = 0
// => 1, and tickets_sold is now 1
tickets_sold = 41
// => 42, and tickets_sold is now 42
Questa funzione restituisce il numero del biglietto come numero intero con segno a 64 bit. I test vendono da diverse biglietterie contemporaneamente: ogni numero restituito deve essere distinto e il conteggio finale deve essere uguale al numero di vendite.
La prima ha un numero fisso di posti e ogni biglietteria la sta vendendo. Una vendita può andare a buon fine solo finché resta un posto e la proiezione non deve mai andare oltre i posti disponibili, nemmeno quando ogni biglietteria cerca di prendere l'ultimo posto nello stesso istante. Controllare il conteggio e poi aggiungere ad esso in due passaggi separati lascia una finestra temporale in mezzo, quindi il controllo e l'aggiornamento devono riuscire o fallire insieme.
Implementa la funzione claim_seat, che prenota atomicamente il posto successivo quando ce n'è ancora uno libero.
Quando un posto viene prenotato, la funzione restituisce il numero del posto, cioè il conteggio dei posti occupati dopo questa prenotazione.
Quando la proiezione è esaurita, restituisce 0 e lascia il conteggio invariato.
Questa funzione accetta come argomenti, in quest'ordine:
seats_taken: indirizzo di memoria del conteggio dei posti già occupati, un numero intero con segno a 64 bit, mai superiore a capacity.capacity: il numero di posti della proiezione, un numero intero con segno a 64 bit, sempre maggiore di 0.seats_taken = 0, capacity = 150
// => 1, and seats_taken is now 1
seats_taken = 82, capacity = 150
// => 83, and seats_taken is now 83
seats_taken = 149, capacity = 150
// => 150, and seats_taken is now 150
seats_taken = 150, capacity = 150
// => 0, and seats_taken stays 150
Questa funzione restituisce il numero del posto, oppure 0 quando la proiezione è esaurita, come numero intero con segno a 64 bit.
La cabina di proiezione può contenere una sola persona.
Una parola in memoria dice se è occupata: 0 per libera, 1 per occupata.
Il personale passa tutta la notte, ognuno con un compito da svolgere all'interno: firmare il registro, caricare il proiettore, archiviare una bobina.
Qualunque sia il compito, la regola della cabina è sempre la stessa e farla rispettare tocca a te: una persona dentro alla volta, dall'inizio del suo compito fino alla fine.
Implementa la funzione visit_booth, che:
Finché la cabina è occupata da qualcun altro, deve attendere con educazione con pause.
Questa funzione accetta come argomenti, in quest'ordine:
booth: indirizzo di memoria della parola di occupazione, un numero intero con segno a 64 bit che vale 0 oppure 1.task: il compito del visitatore, una funzione che non accetta parametri e non restituisce alcun valore.Nota che i test eseguono compiti che osservano la cabina e un registro condiviso. Questo significa che la cabina deve restare occupata per tutta la durata del compito, ogni visita deve eseguire il proprio compito esattamente una volta e, con diversi visitatori insieme, il registro deve risultare esatto.
Il compito del visitatore è una funzione ordinaria che segue la convenzione di chiamata:
La violazione di una delle due regole può manifestarsi come un crash invece che come un controllo fallito.
Iscriviti a Exercism per imparare e padroneggiare x86-64 Assembly con 22 concetti130 esercizi e il mentoring di persone reali, tutto gratis.