Egyetlen utasítás úgy tűnhet, mintha egyetlen oszthatatlan művelet lenne, pedig közel sem az. Vegyük például, amikor ötöt adunk egy memóriában lévő értékhez:
add qword [rel counter], 5
A háttérben a processzornak nincs módja arra, hogy közvetlenül a memóriához adjon. Ezt az egyetlen utasítást három kisebb lépésre bontja, amelyeket mikroműveleteknek nevezünk:
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
Ez egy gyakori minta, amelyet olvasás-módosítás-írás (RMW) néven ismerünk.
Figyeld meg, hogy az olvasás (vagy betöltés) és az írás (vagy tárolás) külön események, így időablak van közöttük.
Ezt általában nem vesszük észre, mert egyetlen magon az utasítások garantáltan teljesen kifejtik a hatásukat, mielőtt a következő sorra kerülne.
Ez az ablak tehát láthatatlan, és az add úgy viselkedik, mintha egyetlen egység lenne.
A modern processzoroknak azonban ritkán van csak egyetlen magjuk, és az alkalmazások gyakran egyszerre több magon futnak, amelyek között nincs semmilyen sorrendiség.
Mivel több mag fut ugyanabban a pillanatban, egy másik mag beolvashatja vagy írhatja a counter értékét az ablakon belül, miután ez a mag betöltötte, de még mielőtt eltárolná.
Két szál ugyanazt a régi értéket tölti be, mindkettő hozzáad ötöt, és mindkettő eltárolja az eredményét.
Két összeadás történt, az érték mégis csak öttel nőtt.
Az egyik frissítés csendben elveszett.
Ez egy adatverseny, és gyakori probléma a többszálú kódban.
Nem minden érték van azonban így kitéve ennek.
Minden szálnak saját regiszterei és saját verme van, így a regiszterben tartott érték, illetve a szál vermén lévő lokális változó kizárólag az adott szálhoz tartozik, és nem kerülhet versenyhelyzetbe.
Csak a szálak által megosztott memóriát, például a fenti counter értékét kell védeni.
Az x86-64 egy sor utasítást kínál erre a problémára, amelyek oszthatatlanná teszik az utasítást. Az ilyen művelet nemcsak a feldolgozó mag számára viselkedik egyetlen egységként, hanem minden más mag számára is. Az olyan műveletet, amely egyben marad, és amelyet egyetlen másik mag sem tud kettéválasztani, atomikusnak nevezzük.
Gyakori, hogy a több magon futó alkalmazásokat többszálúnak nevezzük. A szál azonban nem ugyanaz, mint a mag. Két szál futhat egyszerre ugyanazon a magon, egymásba fonódva, vagy párhuzamosan különböző magokon.
Az egymásba fonódó szálak már egy olvasás-módosítás-írás során is versenyhelyzetbe kerülhetnek, ha az több utasításra bomlik, mert az operációs rendszer bármely két utasítás között válthat a szálak között. Egy egyetlen utasításon belüli ablakot viszont csak az igazán párhuzamos kód tár fel. Mivel az operációs rendszer csak utasítások között vált szálat, egy utasításon belül soha, egyetlen utasítás eleve biztonságos egyetlen magon.
A több magon átnyúló valódi atomikusságot biztosítják az alábbi utasítások.
Az xchg utasítás két operandus értékét cseréli fel.
A céloperandus a forrásoperandus korábbi értékével lesz egyenlő, a forrásoperandus pedig a céloperandus korábbi értékével.
Fogalmilag úgy képzelhetjük el, mintha két mov utasítás egyszerre hajtódna végre.
A szokásos módon használható két regiszteroperandussal, vagy egy memóriaoperandussal és egy regiszteroperandussal:
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
Ha memóriaoperandussal használjuk, az xchg mindig atomikus.
Az xchg automatikusan atomikus, ha az egyik operandus memóriabeli hely.
Ez azt is jelenti, hogy a művelet ilyenkor sokkal lassabb.
Ha nincs szükséged atomikusságra, végezd a cserét egy szabad regiszteren keresztül egyszerű mov utasításokkal.
lock előtagAz x86-64-ben a leggyakoribb módja annak, hogy egy utasítást atomikussá tegyünk, a lock előtag hozzáadása.
Ez egyetlen oszthatatlan lépéssé olvasztja össze az olvasást, a módosítást és az írást.
Ez azt jelenti, hogy a mag a teljes folyamat alatt kizárólagosan birtokolja a memóriát, így közben egyetlen másik mag sem olvashatja vagy írhatja azt a helyet.
lock add qword [rel counter], 5 ; the read, the modify, and the write are one step
A lock csak akkor működik, ha a cél a memória, és csak azokon az utasításokon, amelyek olvasás-módosítás-írás műveletet végeznek ezen a memórián:
add, sub, inc, dec, neg;and, or, xor, not;bts, btr, btc bitműveletek;xadd és a cmpxchg, amelyeket alább ismertetünk.Nem ingyen van, ha egy helyet kizárólagosan birtokolunk, és minden más magot kizárunk.
A lock előtaggal ellátott művelet érezhetően lassabb az egyszerű változatánál, és még lassabb, ha több mag verseng ugyanazért a helyért.
Ezt az előtagot olyan memóriára érdemes fenntartani, amelyet várhatóan több szál is módosítani fog. Kerüld a használatát, ha a memória nem megosztott, vagy ha kizárólag olvassák.
Az egyszerű lock add frissíti a memóriát, de a régi értéket eldobja.
Gyakran épp a régi értékre van szükség, például hogy minden szál külön sorszámot kapjon.
Az xadd utasítás (x az exchange, azaz a csere rövidítése) visszaadja a korábbi értéket, miközben összead.
Az összeget a céloperandusba írja, az eredeti célértéket pedig a forrásregiszterben hagyja.
mov rax, 1
lock xadd qword [rdi], rax ; [rdi] = [rdi] + rax = [rdi] + 1
; rax = the old value of [rdi]
A lock előtaggal ez egy atomikus fetch-and-add.
Ha sok szál futtatja ugyanazon a számlálón, minden hívás más régi értéket ad vissza.
A lock add-hoz hasonlóan a számláló pontosan a hívások számánál végződik.
A lock add-tól eltérően azonban minden köztes érték is visszaadódik, mindegyik hívónak egy.
Az xadd összead, az xchg felülír, de egyik sem tudja elérni, hogy az új érték a jelenlegitől függjön, és csak akkor kerüljön alkalmazásra, ha a háttérben semmi sem változott.
Ezt a feltételes frissítést biztosítja a cmpxchg (compare-and-exchange, azaz összehasonlítás és csere), és ez a legáltalánosabb ezek közül a primitívek közül.
A cmpxchg dest, src implicit akkumulátorként használja a rax-et, és összehasonlítja a dest-tel:
dest == rax, akkor dest = src és ZF = 1.dest != rax, akkor rax = dest és ZF = 0.Figyeld meg, hogy a dest csak akkor frissül, ha egyenlő a várt értékkel, amelyet korábban a rax-be töltöttünk.
Ez az egyenlőség biztosítja, hogy a dest még mindig azt az értéket tartalmazza, amelyből az új értéket számítottuk, így egy elavult olvasáson alapuló frissítés soha nem kerül alkalmazásra.
Ez teszi a cmpxchg-t az atomikus frissítés építőelemévé, amelyet compare-and-swap (CAS) néven is ismerünk:
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
Ez az újrapróbálkozó ciklus a lock-free frissítések lelke.
Az olvasás és az összehasonlítás-csere közötti ablak pontosan az, amikor egy másik szál közbeavatkozhat, és a cmpxchg ezt úgy kapja el, hogy megtagadja egy elavult olvasásból számított érték eltárolását.
Az eddigi műveletek mind egyetlen helyet érintettek. Amikor a szálak egynél több helyen keresztül hangolják össze a működésüket, új kérdés merül fel: milyen sorrendben válnak láthatóvá az egyik szál írásai a másik számára. Az erre a kérdésre választ adó szabályok alkotják a processzor memóriasorrendjét.
Az elágazás nélküli kód fogalma mutatta be azt a gondolatot, hogy a modern mag nem egyesével araszol végig az utasításokon. Egyszerre többet is úton tart, és ahol lehet, előre szalad. Ez azt jelenti, hogy egy írás később válhat láthatóvá a többi mag számára, mint amit a program sugall, miközben a mögötte lévő utasítások már előrehaladtak.
Az x86-64 erős memóriasorrendet tart fenn a hétköznapi betöltések és tárolások között, így minden magon:
Az egyetlen lehetséges átrendezés az, hogy egy tárolás úgy tűnik, egy másik cím későbbi betöltése után fejeződik be.
A lock előtaggal ellátott utasítás, illetve a memóriaoperandusú xchg teljes korlát: semmi sem tűnik úgy, hogy átlép rajta bármelyik irányban.
Ezért elegendőek ahhoz, hogy a legtöbb helyzetben biztosítsák a teljes sorrendet.
pause
Azokat az utasításokat, amelyek beállítanak egy jelzőt, miközben visszaadják annak korábbi állapotát, test-and-set néven ismerjük. Ezek használhatók egy spinlock alapjaként, biztosítva, hogy egy mag kizárólagos hozzáféréssel bírjon a kód egy részéhez.
Ez a teljes algoritmus, az xchg utasítást használva egy bináris jelzővel:
0.1-re cseréli a jelző értékét.1, az azt jelenti, hogy a zárat egy másik mag birtokolja.
Az aktuális mag ilyenkor vár, és újra megpróbálja megszerezni a zárat.0, az azt jelenti, hogy a zár szabad volt.
Az xchg most 1-re állította, és a többi mag addig vár, amíg ez a mag el nem engedi.0-ra frissítése elengedi a zárat.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
A várakozó ciklusban lévő pause utasítás nem változtatja meg azt, amit a kód kiszámít.
Csak jelzi a processzornak, hogy ez egy spin-wait.
A processzor így csökkentheti a várakozó szál energiafogyasztását, és átadhatja a helyét egy ugyanazon a magon osztozó testvérszálnak.
A pause nélküli pörgő ciklus is helyes, csak pazarló.
Amint a szál befejezi a munkáját, elengedheti a zárat egy egyszerű mov-mal elvégzett 0-tárolással.
Nem kell több egy mov-nál, mert az x86-64-en a betöltések és a tárolások soha nem rendeződnek át egy későbbi tárolás mögé.
Egy tárolásról, amely soha nem előzi meg a korábbi hozzáféréseket, azt mondjuk, hogy release ordering jellemzi, és az x86-on minden egyszerű tárolás rendelkezik ezzel.
Bármely utasítás, amely atomikusan tesztel és állít be egy memóriabeli helyet, használható spinlockhoz.
Például a lock bts használható az xchg helyett, hogy beállítson egy adott bitet, miközben ellenőrzi, hogy már be volt-e állítva.
Figyeld meg, hogy a jelzők, például a bts által módosított CF, az rflags nevű regiszter részét képezik.
Ez azt jelenti, hogy minden szálhoz kizárólagosan tartoznak.
Nyitóestét tart a Grand Marquee, és az egész személyzet egyszerre dolgozik. A tömeg minden ajtón beözönlik, több pénztári ablak ad el ugyanarról a jegytömbről, a bemutató majdnem telt házas, odafent pedig a vetítőfülkében folyamatosan pörögniük kell a tekercseknek.
A személyzet minden közös adata egy memóriacímen él, és a tesztek egyszerre több szálból hívják meg a függvényeidet. Egy érték, amelyet külön lépésekben olvasnak ki, módosítanak és írnak vissza, ekkora terhelés alatt elveszíti a frissítéseket, ezért minden függvénynek atomivá kell tennie a frissítését.
Ebben a feladatban minden közös érték 64 bites előjeles egész szám.
Öt részfeladat vár rád.
A frissítéseket ebben a feladatban atomikus utasításokkal kell végrehajtani. A tesztek párhuzamosan futtatják a függvényeidet, és ellenőrzik, hogy nem veszik-e el frissítés, így egy nem atomikus olvasás-módosítás-visszaírás nagy valószínűséggel megbukik rajtuk.
Nyitva vannak az ajtók, és mindegyiknél egy jegyszedő tépkedi a jegyeket. Minden beengedett csoport egy közös belépőszámlálóba számít bele, és bár minden ajtó egyszerre számol, a beléptetettek számának így is pontosnak kell lennie.
Valósítsd meg az admit_group függvényt, amely atomikusan hozzáadja a számlálóhoz egy csoport létszámát.
Ez a függvény a következő argumentumokat kapja, ebben a sorrendben:
admissions: a közös belépőszámláló memóriacíme, egy 64 bites előjeles egész szám.group_size: a csoport létszáma, egy 64 bites előjeles egész szám, mindig nagyobb, mint 0.admissions = 120, group_size = 4
// admissions is now 124
A tesztek egyszerre több ajtón engednek be csoportokat. A végső számlálónak meg kell egyeznie az összes beengedett csoport összegével.
Ennek a függvénynek nincs visszatérési értéke. A számlálót helyben kell frissíteni.
A vetítőgépre pontosan egy tekercs kerül fel. A következő tekercs felhelyezése és a jelenlegi kivétele egyetlen mozdulat kell legyen: ha két vetítő áll a gépnél, két külön lépés ugyanazt a tekercset adhatná mindkettőjüknek, vagy leejthetne egyet a földre.
Valósítsd meg a change_reel függvényt, amely atomikusan felhelyezi a következő tekercset, és visszaadja a korábban felhelyezettet.
Ez a függvény a következő argumentumokat kapja, ebben a sorrendben:
mounted: a felhelyezett tekercs azonosítójának memóriacíme, egy 64 bites előjeles egész szám.next_reel: a felhelyezendő tekercs azonosítója, egy 64 bites előjeles egész szám.mounted = 7, next_reel = 9
// => 7, and mounted is now 9
Ez a függvény a korábban felhelyezett tekercs azonosítóját adja vissza 64 bites előjeles egészként. A tesztekben több szál cserél tekercset egyszerre ugyanazon a foglalaton, és ellenőrzik, hogy minden beadott tekercs pontosan egyszer kerül elő.
Minden ablak egy közös eladottjegy-számlálóból ad el. A számláló növelése, ahogy az ajtók teszik, itt nem elég: minden eladásnak meg kell tudnia azt a számot is, amelyet kapott. Két eladás nem kaphat ugyanolyan számot, és a roham után a számlálónak pontosan meg kell egyeznie az eladott jegyek számával.
Valósítsd meg a sell_ticket függvényt, amely atomikusan hozzáad egyet a számlálóhoz, és visszaadja az épp eladott jegy számát.
A jegyszám az eladás utáni számláló értéke: amikor a számláló 41 volt, ez az eladás a 42-es jegy.
Ez a függvény egy argumentumot kap:
tickets_sold: a közös eladottjegy-számláló memóriacíme, egy 64 bites előjeles egész szám.tickets_sold = 0
// => 1, and tickets_sold is now 1
tickets_sold = 41
// => 42, and tickets_sold is now 42
Ez a függvény a jegyszámot adja vissza 64 bites előjeles egészként. A tesztek egyszerre több ablakból adnak el: minden visszaadott számnak különbözőnek kell lennie, és a végső számlálónak meg kell egyeznie az eladások számával.
A bemutatónak rögzített számú ülőhelye van, és minden ablak arra ad el jegyet. Egy eladás csak akkor mehet át, amíg marad hely, és a vetítés soha nem adhat el a kapacitásánál több jegyet, még akkor sem, ha minden ablak ugyanabban a pillanatban kaparintja meg az utolsó helyet. A számláló ellenőrzése, majd növelése két külön lépésként rést hagy közéjük, ezért az ellenőrzésnek és a frissítésnek együtt kell sikerülnie vagy meghiúsulnia.
Valósítsd meg a claim_seat függvényt, amely atomikusan lefoglalja a következő helyet, amikor még van szabad.
Amikor egy helyet lefoglalnak, a függvény visszaadja a hely számát, amely a foglalás utáni elfoglalt helyek száma.
Amikor a vetítés telt házas, 0-t ad vissza, és a számlálót változatlanul hagyja.
Ez a függvény a következő argumentumokat kapja, ebben a sorrendben:
seats_taken: az elfoglalt helyek számának memóriacíme, egy 64 bites előjeles egész szám, soha nem nagyobb, mint capacity.capacity: a vetítés helyeinek száma, egy 64 bites előjeles egész szám, mindig nagyobb, mint 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
Ez a függvény a hely számát adja vissza, vagy 0-t, ha a vetítés telt házas, 64 bites előjeles egészként.
A vetítőfülkébe egy ember fér be.
Egy memóriabeli szó mondja meg, hogy foglalt-e: 0 a szabad, 1 a foglalt.
A személyzet egész éjjel jön-megy, mindegyiküknek van egy elvégeznivalója odabent: aláírni a naplót, befűzni a vetítőt, elrakni egy tekercset.
Bármi is a feladat, a fülke szabálya ugyanaz, és ezt neked kell érvényesítened: egyszerre egy ember lehet bent, a feladata kezdetétől a végéig.
Valósítsd meg a visit_booth függvényt, amely:
Amíg a fülkét más tartja, illedelmesen várnia kell a pause utasítással.
Ez a függvény a következő argumentumokat kapja, ebben a sorrendben:
booth: a foglaltsági szó memóriacíme, egy 64 bites előjeles egész szám, amely vagy 0, vagy 1.task: a látogató feladata, egy függvény, amely nem vesz át paramétert, és nincs visszatérési értéke.Vedd figyelembe, hogy a tesztek olyan feladatokat futtatnak, amelyek a fülkére és egy közös naplóra is pillantanak. Ez azt jelenti, hogy a fülkének a teljes feladat idejére foglaltnak kell lennie, minden látogatásnak pontosan egyszer kell lefuttatnia a feladatát, és ha egyszerre több látogató van, a naplónak pontosnak kell lennie.
A látogató feladata egy közönséges függvény, amely követi a hívási konvenciót:
A két szabály bármelyikének megsértése összeomlásként is megjelenhet, nem pedig egy elbukott ellenőrzésként.
Iratkozz fel az Exercism-re, hogy megtanuld és elsajátítsd a(z) x86-64 Assembly nyelvet 22 fogalom130 feladat segítségével, valódi emberi mentorálással, mindez ingyen.