15.4 C atomics és memory order

Az atomikus művelet nem „kis mutex”, és nem általános védelem minden megosztott állapot körül. Egy meghatározott objektumon biztosít oszthatatlan hozzáférést; a más objektumokra vonatkozó rendezést a választott memóriarendezés (memory order) határozza meg. Az objektum élettartamát egyik sem hosszabbítja meg.

A Data race, happens-before és publikalás alapján két külön kérdést kell megválaszolnunk: versenymentes-e maga a hozzáférés, és létrejön-e a szükséges happens-before kapcsolat? A Mutex, condition variable és producer–consumer megoldásai továbbra is az alapértelmezett választások összetett invariánsokhoz. Itt egyszerű számlálóra és egyszeri adatátadásra szorítkozunk.

15.4.1 Atomikus objektumok és műveletek

ISO C17. Az _Atomic atomikus típust jelöl; például az _Atomic(unsigned) atomikus unsigned típus. A <stdatomic.h> többek között az ennek megfelelő atomic_uint típusnevet és az atomikus műveleteket biztosítja. C17-ben az atomikus támogatás opcionális: a __STDC_NO_ATOMICS__ makró jelzi, ha az implementáció nem támogatja.

Az atomikusság (atomicity) azt jelenti, hogy a művelet az érintett objektumon oszthatatlan. Egy atomikus olvasás nem két külön írásból összerakott „félértéket” vesz át. Ez azonban nem tesz több egymást követő műveletet közös tranzakcióvá.

Művelet Hatás Eredmény
atomic_load Érték olvasása Az olvasott érték
atomic_store Érték beírása Nincs visszatérési érték
atomic_exchange Érték lecserélése A korábbi érték
atomic_fetch_add Összeadás egyetlen read–modify–write művelettel A korábbi érték
atomic_compare_exchange_* Feltételes csere A siker logikai értéke

Az RMW, vagyis read–modify–write, egyetlen oszthatatlan olvasás–módosítás–írás. Az _explicit végződésű változatok memóriarendezést is kapnak; az enélküliek alapértelmezése memory_order_seq_cst.

Az inicializálás külön lépés. Az atomic_init használatakor másik szál még nem férhet hozzá az objektumhoz; ez nem konkurens újrainicializálási eszköz. Statikus objektumhoz C17-ben használható az ATOMIC_VAR_INIT.

Az atomikus objektum megfelelő tárolót és igazítást igényel. Egy közönséges unsigned címének atomikus pointerré alakítása nem hoz létre megfelelő atomikus objektumot.

15.4.2 Sequential consistency és relaxed számláló

15.4.2.1 Az első alapmodell: sequential consistency

ISO C17. A sequential consistency, röviden SC, a legkézenfekvőbb kiindulópont. A memory_order_seq_cst műveletekhez a szabvány által előírt rendezésekkel összhangban álló egyetlen teljes sorrend tartozik. Egyszerű, kizárólag SC atomikus hozzáféréseket használó példákban úgy gondolkodhatunk, mintha a szálak műveletei egy közös sorrendbe lennének összefésülve.

Ez nem jelenti minden közönséges memóriahozzáférés automatikus szinkronizálását, és a különálló atomikus lépéseket sem egyesíti:

/* ISO C17, hibás számlálási logika; függvénytörzs-részlet.
   counter: élő, inicializált atomic_uint objektum. */
unsigned old = atomic_load(&counter);
atomic_store(&counter, old + 1U);

Két szál egyaránt olvashat nullát, majd mindkettő egyet írhat. Az atomikus objektumon nincs adatverseny, de egy növelés elveszik: ez logikai race.

15.4.2.2 Mikor elegendő a relaxed?

Ha csak egy számot összesítünk, és a szám értéke nem ad engedélyt más adatok olvasására, gyakran elegendő a memory_order_relaxed.

Az alábbi ISO C17 kódrészlet statikus tárolási idejű számlálót használ. Tulajdonosa a program; a függvény nem vesz át erőforrást, és nem ad vissza kölcsönzött hozzáférést.

#include <stdatomic.h>

static atomic_ulong completed = ATOMIC_VAR_INIT(0UL);

void record_completion(void)
{
    (void)atomic_fetch_add_explicit(
        &completed, 1UL, memory_order_relaxed);
}

unsigned long completion_snapshot(void)
{
    return atomic_load_explicit(
        &completed, memory_order_relaxed);
}

Minden RMW egyetlen lépésben növeli a számlálót. Minden atomikus objektum módosításainak saját teljes sorrendje van, a modification order; ezt a relaxed rendezés sem szünteti meg.

Ha összesen NN növelés történik, a kezdőérték nulla, és N≤ULONG_MAXN \leq \mathrm{ULONG\_MAX}, akkor az összes növelő szál sikeres bevárása után olvasott érték NN. A túlcsordulás unsigned típuson körbefordulást okozna, ami általában sérti a metrikaszerződést.

A relaxed művelet viszont nem publikálja az elkészült munkához tartozó adatokat. Attól, hogy a megfigyelt számláló elér egy küszöböt, egy másik tömb vagy eredménystruktúra még nem válik biztonságosan olvashatóvá.

15.4.3 Egyszeri publikálás release/acquire kapcsolattal

Egy szál két közönséges mezőt tölt ki, majd jelzi, hogy az üzenet elkészült. Az olvasó kizárólag a jelzés megfelelő átvétele után olvassa a mezőket.

Példaprofil: ISO C17 és POSIX pthreads; Linux x86-64 System V célrendszer. Választott laborverziók: GCC 14.2.0 vagy Clang 19.1.7, glibc 2.40. A szinkronizálás atomikus része ISO C17, a szálkezelés POSIX. Az alábbiak reprodukálási parancsok; itt nem tartozik hozzájuk elvégzett fordítás vagy futtatási eredmény.

/* publication.c */
#include <pthread.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>

struct message {
    int value;
    unsigned jobs;
    atomic_bool ready;
};

static void *produce(void *arg)
{
    struct message *m = arg;

    m->value = 42;
    m->jobs = 7U;
    atomic_store_explicit(&m->ready, true, memory_order_release);
    return NULL;
}

int main(void)
{
    struct message m;
    atomic_init(&m.ready, false);

    pthread_t thread;
    int rc = pthread_create(&thread, NULL, produce, &m);
    if (rc != 0) {
        fprintf(stderr, "pthread_create: %d\n", rc);
        return EXIT_FAILURE;
    }

    while (!atomic_load_explicit(&m.ready, memory_order_acquire)) {
        /* Kizárólag szemléltető aktív várakozás. */
    }

    /* Szándékosan a join előtt: itt az atomikus publikálás véd. */
    int value = m.value;
    unsigned jobs = m.jobs;

    rc = pthread_join(thread, NULL);
    if (rc != 0) {
        fprintf(stderr, "pthread_join: %d\n", rc);
        abort();
    }

    printf("%d %u\n", value, jobs);
    return EXIT_SUCCESS;
}
gcc -std=c17 -O2 -Wall -Wextra -Wpedantic \
    -pthread publication.c -o publication-gcc

clang -std=c17 -O2 -Wall -Wextra -Wpedantic \
    -pthread publication.c -o publication-clang

Sikeres szálműveletek és befejeződő végrehajtás esetén a várt kimenet 42 7. A pörgő várakozás nem termelési ajánlás: CPU-időt fogyaszt, és önmagában nem ad időkorlátos előrehaladási garanciát. Hosszabb várakozáshoz használjunk condition variable-t.

15.4.3.1 Pontosan mely adatokat teszi olvashatóvá?

Bizonyításvázlat, ISO C17. Jelölje:

Ebben a programban egyetlen művelet ír true értéket. Ezért LL az SS által írt értéket olvassa, és létrejön a synchronizes-with kapcsolat:

Wsequenced-beforeSsynchronizes-withLsequenced-beforeR. W \mathrel{\text{sequenced-before}} S \mathrel{\text{synchronizes-with}} L \mathrel{\text{sequenced-before}} R.

Ebből:

Whappens-beforeR. W \mathrel{\text{happens-before}} R.

A mezőket publikálás után senki nem módosítja, ezért olvasásuk versenymentes, és az írt értékeket szolgáltatja.

A kapcsolat tehát a release előtt rendezett írásokat teszi elérhetővé az acquire utáni olvasások számára. Nem védi a producer későbbi módosításait, és nem jelent általános kizárólagos hozzáférést. A ready egyszerű visszaállítása sem teszi ezt automatikusan újrahasználható üzenetsorrá.

15.4.3.2 Élettartam és hibakimenet

Elem Tulajdonos és tároló Kölcsönzés, érvénytelenítés
m és mezői A main automatikus objektuma A produce pointere kölcsönzött; m élettartamának vége érvénytelenítené
arg A worker függvényparamétere Nem tulajdonolja és nem szabadítja fel m-et
Szál-erőforrás A main felelőssége bevárni A sikeres pthread_join lezárja a kötelezettséget

Sikertelen létrehozáskor nincs elindított worker. Sikertelen beváráskor a példa megszakítja a folyamatot: nem feltételezi igazolatlanul, hogy a worker már befejeződött. A pthread függvények itt közvetlen hibakódot adnak, nem az errno ellenőrzése szükséges.

15.4.4 Compare-exchange: feltételes módosítás

A compare-and-exchange, röviden CAS, összehasonlítja az atomikus objektum értékét az expected által megadott várt értékkel.

A weak változat egyező értékek mellett is jelezhet sikertelenséget: ez a spurious failure. Újrapróbálkozó ciklusban használható. A strong változatnak nincs ilyen megengedett álhibája, de az sem garantálja az egész algoritmus befejeződését.

Az alábbi ISO C17 függvényrészlet legfeljebb limit értékig enged növelni:

#include <stdatomic.h>
#include <stdbool.h>

bool try_increment_below(atomic_uint *counter, unsigned limit)
{
    unsigned expected =
        atomic_load_explicit(counter, memory_order_relaxed);

    while (expected < limit) {
        if (atomic_compare_exchange_weak_explicit(
                counter, &expected, expected + 1U,
                memory_order_relaxed,
                memory_order_relaxed)) {
            return true;
        }
        /* expected már a sikertelen kísérletben olvasott érték. */
    }
    return false;
}

A pointer nem null, élő, inicializált atomikus objektumra mutat. Tulajdonosa a hívó; annak tárolóját csak minden párhuzamos hívás befejezése után szüntetheti meg. Az expected minden hívás saját lokális objektuma. A false kapacitáshiányt jelent, nem rendszerhibát.

Bizonyításvázlat. Tegyük fel, hogy kezdetben counter <= limit, minden módosító ezt a függvényt használja, és ugyanaz a korlát. A sikeres CAS pillanatában az aktuális érték valóban expected, továbbá:

𝚎𝚡𝚙𝚎𝚌𝚝𝚎𝚍<𝚕𝚒𝚖𝚒𝚝⇒𝚎𝚡𝚙𝚎𝚌𝚝𝚎𝚍+1≤𝚕𝚒𝚖𝚒𝚝. \texttt{expected} < \texttt{limit} \quad\Longrightarrow\quad \texttt{expected}+1 \leq \texttt{limit}.

Az összeadás sem csordul túl, mert limit unsigned érték. Sikertelen CAS nem változtatja meg a számlálót. A korlát így megmarad. Ez kizárólag számszerű kvóta: nem publikál hozzá tartozó erőforrást vagy payloadot.

15.4.4.1 A failure order szabályai

A CAS sikeres ága RMW; a sikertelen ág csak olvasás. ISO C17-ben a sikertelenségi rendezés nem lehet memory_order_release vagy memory_order_acq_rel, és nem lehet erősebb a sikeres ág rendezésénél.

A consume lehetőséget mellőzve:

Success order Megengedett failure order
relaxed relaxed
acquire relaxed, acquire
release relaxed
acq_rel relaxed, acquire
seq_cst relaxed, acquire, seq_cst

A táblázatban minden név memory_order_ előtaggal értendő. A release siker és acquire sikertelenség például C17-ben nem megengedett páros.

15.4.5 Az atomic nem egyenlő lock-free-vel

Az atomic_is_lock_free(&object) azt kérdezi le, hogy az adott atomikus objektum műveletei zárolásmentesek-e az implementációban. A válasz típus- és célrendszerfüggő; egyes műveleteket az implementáció belső zárolással valósíthat meg. Bizonyos fordító/célrendszer párosításokhoz külön atomikus futásidejű könyvtár linkelése is szükséges lehet.

A lock-free művelet sem tesz automatikusan lock-free algoritmussá egy teljes rendszert. Nem garantál várakozásmentes (wait-free) végrehajtást, szálankénti méltányosságot vagy alacsony késleltetést. A CAS-ciklus ismétlésszámának például nincs itt bizonyított felső korlátja.

A memory_order_consume C17-ben adatfüggőségen alapuló rendezési mechanizmus része, nem egyszerűen „olcsó acquire”. Implementációs gyakorlata és későbbi szabványverziókhoz kapcsolódó kezelése külön ellenőrzést igényel. Tanulási és általános publikálási alapként használjunk acquire rendezést; ne vigyük át automatikusan a C++ verziószabályait C-re.

15.4.6 Gyakori hibák

A legsúlyosabb tévedés a rendezés és az élettartam összekeverése. Egy pointer release/acquire publikálása láthatóvá teheti az objektum inicializálását, de nem akadályozza meg annak idő előtti felszabadítását.

Hasonlóan hibás a „jelző atomikus, tehát minden biztonságos” érvelés. A release/acquire kapcsolatnak ténylegesen létre kell jönnie; két relaxed művelet ezt nem pótolja. A volatile sem helyettesít atomikus hozzáférést vagy szinkronizálást.

Végül az erősebb rendezés nem javít algoritmikus hibát: a különálló load és store SC mellett is elveszíthet növeléseket.

15.4.7 Feladatok

  1. Magyarázat. Két szál SC load, majd SC store segítségével növel egy nulláról induló atomikus számlálót. Adj olyan műveletsorrendet, amelyben a végeredmény egy. Mi a javítás?

  2. Kódolás. Bővítsd a CAS-példát try_add_bounded(counter, amount, limit) függvénnyé. Csak akkor adjon hozzá amount értéket, ha az eredmény nem haladja meg a korlátot. A nulla növekmény legyen megengedett.

  3. Diagnózis. A publikálási példában:

    Melyik változtatás helyes, és milyen bizonyítás hiányzik a többiből?

15.4.8 Megoldások

15.4.8.1 Elvesző növelés

Megengedett sorrend: A olvas nullát; B olvas nullát; A egyet ír; B egyet ír. A megoldás egyetlen atomic_fetch_add_explicit művelet. Puszta számláláshoz relaxed rendezés elég.

Ellenőrzési feltétel: két befejezett, bevárt növelő után, más módosító nélkül, az eredmény kettő.

15.4.8.2 Korlátos összeadás

ISO C17 kódrészlet; ugyanazok az ownership- és élettartamfeltételek érvényesek, mint az előző CAS-függvénynél.

#include <stdatomic.h>
#include <stdbool.h>

bool try_add_bounded(atomic_uint *counter,
                     unsigned amount,
                     unsigned limit)
{
    unsigned old =
        atomic_load_explicit(counter, memory_order_relaxed);

    for (;;) {
        if (old > limit || amount > limit - old) {
            return false;
        }

        if (atomic_compare_exchange_weak_explicit(
                counter, &old, old + amount,
                memory_order_relaxed,
                memory_order_relaxed)) {
            return true;
        }
    }
}

Az első feltétel rövidzáras kiértékelése után a kivonás matematikailag is értelmes. A második feltétel biztosítja:

𝚊𝚖𝚘𝚞𝚗𝚝≤𝚕𝚒𝚖𝚒𝚝−𝚘𝚕𝚍⇒𝚘𝚕𝚍+𝚊𝚖𝚘𝚞𝚗𝚝≤𝚕𝚒𝚖𝚒𝚝. \texttt{amount} \leq \texttt{limit}-\texttt{old} \quad\Longrightarrow\quad \texttt{old}+\texttt{amount}\leq\texttt{limit}.

Ellenőrzési feltételek: nulla növekmény, pontosan a korlátig növelés, túllépés és UINT_MAX közeli értékek. Párhuzamos ellenőrzésnél minden módosító azonos korláttal használja ezt a műveletet.

15.4.8.3 Publikálási és élettartamhiba

A relaxed load nem hozza létre a szükséges synchronizes-with kapcsolatot. Mivel a payload olvasása a join előtt történik, annak írásával adatverseny áll elő: ez UB, nem pusztán „esetleg régi érték”.

A két SC művelet megfelelő: a store release-, a load acquire-hatása is biztosított.

A korai blokk-kilépés megszüntetheti az objektum élettartamát, miközben a worker még használja. Ezt semmilyen memory order nem javítja. Külön befejezési és élettartam-protokoll szükséges.

15.4.9 Összefoglaló

Induljunk SC műveletekkel, és csak megindokolt esetben gyengítsük a rendezést. Önálló számlálóhoz gyakran relaxed RMW elegendő; payload publikálásához release/acquire kapcsolat kell. A CAS sikertelenségi rendezése külön szabályokat követ. Mindig külön bizonyítsuk az atomikusságot, a szükséges happens-before kapcsolatot és az objektum élettartamának érvényességét.