Az ebben a posztban vizsgált C++ atomi műveletek két nagy csoportra oszthatók. Az első csoportba az atomi változók értékének kiolvasása és módosítása, vagyis a load() és store() műveletek tartoznak. A második, érdekesebb csoportot az úgynevezett RMW (read-modify-write) műveletek alkotják, amelyek egyetlen atomi műveletben olvassák, módosítják és tárolják az értéket.
Az atomicitás azt jelenti, hogy az adott művelet szempontjából a többi szál nem figyelhet meg egy köztes állapotot. EGY RMW művelet például nem bomlik két, egymástól függetlenül végrehajtható “olvasás” és “írás” műveletre.
Kiolvasó/tároló művelet
Az x atomi változóból az x.load() függvénnyel tudjuk kiolvasni az aktuális értékét. Új értéket pedig a x.store(value) függvénnyel tudunk beállítani. Ennek első (kötelező) paramétere az újonnan beállítandó érték.
Az itt bemutatott műveletek alapértelmezett memória sorrendje
std::memory_order_seq_cst. A memória sorrendekkel később részletesebben foglalkozunk.
RMW műveletek
Az olvasó-módosító-író műveletek csoportja további három alcsoportra oszható. A fetch_* műveletek módosítják a korábbi értéket, és közben vissza is adják azt (azaz a módosítás előtti értéket). A test_and_set az std::atomic_flag típuson használható, tipikus alkalmazása a lockok, például spinlockok megvalósítása. A compare_exchange egy atomi változó értékét akkor cseréli le egy új értékre, ha annak aktuális értéke megegyezik az általunk elvárt értékkel.
fetch_* műveletek
Az egész típusú std::atomic változóknál az alábbi öt alapvető fetch_* művelettel találkozhatunk:
fetch_addfetch_subfetch_andfetch_orfetch_xor
fetch_add
Az x.fetch_add(increment) művelet az x atomi változó értékét atomikusan növeli increment-tel, és visszaadja x módosítás előtti értékét.
Ha például van egy függvényünk, ami 1000-szer végrehajtja egymást követően az 1-gyel növelést egy statikus atomi változón (fetch_add-ot használva), és ezt a függvényt konkurrens módon két szálon futtatjuk, akkor garantált, hogy 2000-rel növekszik a változó értéke.
| |
Ha a
counter.fetch_add(1)utasítás helyettconst int old = counter.load(), majdcounter.store(old + 1)állna, akkor ez két külön atomikus művelet lenne, így több szálon probléma állhatna elő.
fetch_sub
A fetch_sub teljesen hasonlóan működik, mint a fetch_add, csak összeadás helyett kivonást hajt végre.
Az alábbi példaprogram mutatja a működését.
| |
fetch_and, fetch_or és fetch_xor
Ezek a függvények a bitenkénti és, vagy, illetve kizáró vagy műveleteket valósítják meg.
Az alábbi kódban definiálunk egy 8 bites egészt, amit egy bitsorozattal inicializálunk. Jól látszik, hogy a fetch_or bitenkénti vagy kapcsolattal módosítja az egyes biteket.
| |
test_and_set
Ha van egy std::atomic_flag típusú változónk, akkor arra meghívhatjuk a test_and_set függvényt. A művelet hatását három lépésben írhatjuk le, miközben az egész művelet a C++ memória-modell szempontjából egyetlen atomi műveletként hajtódik végre:
- Lekérdezi a flag jelenlegi értékét
true-ra állítja a flag értékét- Visszaadja a korábban lekérdezett értéket
A test_and_set használatával készíthetünk egy ún. spinlock-ot, amivel kölcsönös kizárást valósíthatunk meg. A Spinlock osztály egy std::atomic_flag adattaggal rendelkezik, amelyet clear/false állapottal inicializálunk. Az osztálynak lock() és unlock() metódusa van, melyek a Spinlock objektumot lezárt, illetve feloldott állapotba helyezik.
| |
A Spinlock használatával az alábbi módon tudunk négy szálon többszöri inkrementálást végrehajtani. A növelendő változó (counter) nem atomikus, hanem hagyományos int típusú.
| |
A
Spinlockhátránya azstd::mutex-szel szemben, hogy a várakozó szál nem “alszik el”, hanem folyamatosan futtatja a ciklust. Emiatt hosszabb várakozás esetén jelentős CPU-időt fogyaszthat. Rövid ideig tartó kritikus szakaszoknál viszont lehet értelme.
A saját
Spinlockosztály ugyan nem öröklődik semmilyen mutex osztályból, mégis használhatóstd::lock_guard-dal, mert biztosítja alock()ésunlock()metódusokat. A C++ standard könyvtár számos esetben nem öröklési hierarchiára épít, hanem azt várja el, hogy egy típus bizonyos műveleteket és az azokhoz tartozó viselkedést biztosítson. Viszont ezt – más nyelvekkel ellentétben – nem öröklési vagy interfész megvalósítási módon ellenőrzi.
compare-exchange
A szakirodalomból ismert compare-and-swap (CAS) műveletnek C++-ban a compare_exchange_weak() és compare_exchange_strong() felel meg.
A függvények két paraméterrel dolgoznak:
expected: az az érték, amelyet az atomi változóban várunk;desired: az az új érték, amelyre az atomi változót módosítani szeretnénk.
Ha x egy atomi változó, akkor az x.compare_exchange_*(expected, desired) logikája a következő:
- Megvizsgálja, hogy
xértéke megegyezik-e azexpectedértékével. - Ha igen, akkor
xértékét adesiredértékre cseréli, éstrue-val tér vissza a metódus. - Ha nem, akkor az
xértékét nem módosítja, viszontexpectedértékét felülírjaxértékével, ésfalse-szal tér vissza. Mivelexpected-et referenciaként veszi át a függvény, így ebben az esetben a módosulás a hívó környezet számára is érzékelhető lesz.
Az
expecteda fentiek miatt in/out paraméter: a függvény bemenetként használja, sikertelen összehasonlítás esetén pedig kimenetként módosítja.
Ennek a furcsa működésnek a következő a magyarázata:
- Van egy elvárásunk, hogy mi a változónk értéke. Ha ez az elvárás teljesül, akkor lecseréljük a változónk értékét az új értékre, és jelezzük, hogy sikeresen végrehajtottuk a cserét.
- Ha viszont a változónk értéke nem egyezik meg az elvárttal, akkor ez amiatt lehetséges, hogy egy másik szálon módosult a változó értéke. Ilyekor az elvárást igazítjuk a változó aktuális értékéhez, majd jelezzük, hogy sikertelen volt a csere művelet.
Az elmondottakra építve az eggyel növelés műveletét az alábbi módon implementálhatjuk:
| |
Ha a counter értéke időközben megváltozott, a compare_exchange_weak sikertelen lesz, és az old értékét frissíti. A ciklus ezután az új érték alapján újabb kísérletet tesz. weak használata esetén a sikertelenségnek lehet egy másik oka is: előfordulhat ún. spurious failure, amikor az összehasonlítás sikeres lehetett volna, a művelet mégis false értékkel tér vissza.
Érdemes végiggondolni, hogy hogyan fut le az
Incrementfüggvény, ha két szálon futtatjuk konkurrens módon. Tegyük fel, hogycounterértéke éppen 10. Az egyik szálon (t1)oldértéke 10 lesz, de mielőtt acompare_exchange_weakvégrehajtódna ezen a szálon, egy másik szál (t2) acounter-t 11-re módosítja. Az összehasonlítás ezért t1 szálon sikertelen lesz. Acompare_exchange_weakekkoroldértékét 11-re frissíti (ezzel lekövetve a t2-n történt módosulást), a következő próbálkozás _t1_en pedig már a 11-ről 12-re növeléssel kísérletezik.
weak vs strong
Mi a különbség a weak és a strong változat között?
weak esetben előállhat ún. spurious failure, ami akkor is sikertelenséget jelezhet, amikor a változó értéke megegyezik az expected-del. Ez a helyzet a strong esetben nem állhat elő. Ha a strong változat hamis értéket ad vissza, akkor biztos, hogy x és expected értéke különböző volt.
Összefoglalás
Az std::atomic RMW műveletei lehetővé teszik, hogy egy megosztott változót ne csak atomikusan olvassunk vagy írjunk, hanem az értéket egyetlen atomi műveletben módosítsuk is.
A fetch_* műveletek egyszerű számlálók és bitenkénti flag-ek kezelésére használhatók. A test_and_set() segítségével például egyszerű spinlock készítható.
A compare_exchange_* ennél általánosabb mechanizmust biztosít: egy érték módosítását ahhoz köthetjük, hogy az értéke még mindig az általunk várt érték legyen. Ennek segítségével ún. CAS ciklusokat írhatunk, amelyek számos lock-free adatstruktúra alapját képezik.
Fontos, hogy az
std::atomichasználata önmagában nem jelenti azt, hogy az adott művelet vagy algoritmus lock-free; a CAS ciklusok viszont fontos eszközei a lock-free algoritmusok megvalósításának.