15.2 Data race, happens-before és publikalás

A szálbiztos program helyességét nem az igazolja, hogy „a tesztekben mindig a jó értéket kaptuk”, hanem az, hogy minden megosztott hozzáféréshez meg tudjuk nevezni a szükséges rendezési és élettartam-garanciát. A C memóriamodelljében ezeket a garanciákat elsősorban a happens-before relációval fejezzük ki.

Az előző részben megismert szálindítás és csatlakozás adja az életciklus keretét. A sequencing szabályai egy szálon belül rendezik a kiértékeléseket; most ehhez kapcsolunk szálak közötti rendezést. A volatile korlátait is felidézzük: megosztott adatból önmagában nem készít szinkronizált adatot.

15.2.1 Ütköző hozzáférések és adatverseny

ISO C17: két kiértékelés ütköző hozzáférést (conflicting access) jelent, ha ugyanazt a memóriahelyet érintik, és az egyik módosítja, a másik pedig olvassa vagy módosítja azt. Két puszta olvasás tehát nem ütköző.

A memóriahely itt nyelvi fogalom, nem cache line. Skalár objektum, illetve bizonyos szomszédos bitmezők együttese alkot memóriahelyet. Egy struktúra különálló, nem bitmező tagjai külön memóriahelyek lehetnek akkor is, ha a hardveren ugyanabba a cache line-ba kerülnek.

Egy végrehajtásban adatverseny (data race) van, ha:

Az adatverseny C17-ben nem definiált viselkedést, UB-t okoz. Nem csupán elveszett növelésre vagy régi értékre kell számítani: a szabvány a program eredményére nem ad garanciát.

Tegyük fel, hogy egy static unsigned counter; objektumon két dolgozó egyaránt ++counter műveletet végez. Ez nem atomikus növelés. A kifejezés értékének meghatározásához olvasás, a módosításához írás tartozik:

A szál eseményei B szál eseményei
ARA_R: counter olvasása BRB_R: counter olvasása
AWA_W: counter módosítása BWB_W: counter módosítása

A táblázat nem gépiutasítás-lista és nem garantált kimenetű végrehajtás. A konfliktusokat mutatja: például AWA_W és BRB_R, valamint AWA_W és BWB_W között megfelelő szinkronizáció nélkül nincs szükséges rendezés.

Az objektum tárolója statikus, élettartama végig tart; itt tehát nem dangling hozzáférés, hanem adatverseny a hiba. Egyetlen processzormag, ritka végrehajtás vagy a szálak közé tett késleltetés sem változtatja ezt meg.

15.2.2 A rendezési relációk és a végrehajtási gráf

A végrehajtási gráf (execution graph) csúcsai műveletek, élei pedig nyelvi vagy szinkronizációs kapcsolatok.

15.2.2.1 Sequenced-before

A sequenced-before, röviden sbsb, egy szálon belüli rendezés. Például az alábbi külön teljes kifejezések esetén az első értékadás megelőzi a másodikat:

/* ISO C17-részlet; x és y ezen a ponton csak a hívó szálé. */
x = 10;
y = 20;

Ez nem jelenti azt, hogy minden összetett C-kifejezés részkifejezései balról jobbra értékelődnek ki.

15.2.2.2 Synchronizes-with

A synchronizes-with, röviden swsw, meghatározott szinkronizációs műveletek között létesül.

POSIX: megfelelően használt mutex esetén az egyik szál feloldása és ugyanazon mutex későbbi sikeres megszerzése biztosítja a szükséges szálak közötti memória-szinkronizációt. Két különböző mutex használata önmagában nem ad ilyen kapcsolatot.

Az ISO C17 saját szálkönyvtára hasonló célt szolgáló műveleteket kínál, de a pthread_* függvények POSIX API-k, nem ISO C függvények.

15.2.2.3 Happens-before

A happens-before, röviden hbhb, a helyesség szempontjából lényeges megelőzési reláció. Az itt használt, mutexes részmodellben az sbsb és swsw élekből tranzitív módon vezetjük le.

Legyen:

Ekkor:

W→sbU→swL→sbR W \xrightarrow{sb} U \xrightarrow{sw} L \xrightarrow{sb} R

amiből:

W→hbR. W \xrightarrow{hb} R.

Bizonyításvázlat: az írás a feloldás előtt történik a kiíró szál programrendjében. A feloldás és a későbbi megszerzés szinkronizál. Az olvasás a megszerzés után történik az olvasó szálban. Ezek összekapcsolása rendezi az ütköző hozzáféréseket.

A „később” itt nem egyszerű falióra-megfigyelés. Attól, hogy egy napló szerint A korábban futott, még nem azonosítottunk hbhb élt.

A szál-életciklus is ad rendezést: a létrehozás előtti inicializálás átadható az új szálnak, a sikeres pthread_join pedig lehetővé teszi a befejezett szál eredményeinek megfigyelését. Két versengő dolgozó utólagos csatlakoztatása azonban nem rendezi visszamenőleg a dolgozók egymás közötti hozzáféréseit.

15.2.3 Adatverseny és logikai race condition

A magasabb szintű race condition, vagyis logikai versenyhelyzet azt jelenti, hogy az eredmény a műveletek kedvezőtlen összefonódásától függ, és sérül az alkalmazás szerződése. Ez adatverseny nélkül is bekövetkezhet.

Legyen a kapcsolatok számlálója kezdetben 9, a korlát pedig 10. Minden hozzáférés ugyanazon mutex alatt történik, de az ellenőrzés és a növelés külön kritikus szakasz:

Lépés Mutex alatt végzett művelet
A ellenőriz active < 10 igaz
B ellenőriz active < 10 igaz
A lefoglal active értéke 10 lesz
B lefoglal active értéke 11 lesz

Nincs adatverseny, mert a számláló hozzáférései rendezettek. A kapacitáskorlát mégis sérül.

A javítás nem „még több zárolás”, hanem a megfelelő műveleti egység: az ellenőrzésnek és a foglalásnak egyetlen kritikus szakaszban kell lennie. A memóriahelyek védelme és az alkalmazási invariáns védelme két külön bizonyítási feladat.

15.2.4 Megosztott pointer publikálása

A publikálás (publication) azt jelenti, hogy egy előkészített objektumot más szál számára hozzáférhetővé teszünk, és biztosítjuk az inicializálás megfelelő megfigyelhetőségét.

Két külön kérdést kell megválaszolni:

  1. Rendezett-e a pointert tároló objektum olvasása és írása?
  2. Rendezett-e a megcímzett objektum inicializálása és későbbi használata, és él-e még az objektum?

Az alábbi programban a főszál publikál egy konfigurációt. A dolgozó egyszer próbálja kiolvasni; megengedett eredmény, hogy még nincs konfiguráció. Ez nem várakozási protokoll.

Példaprofil: ISO C17 és POSIX threads, Linux x86-64 System V ABI, LP64. A külön laborhoz választott eszközverziók: GCC 14.2.0, Clang 19.1.7, glibc 2.40. A példa itt nem fordított vagy futtatott mérési eredmény; a helyi verziókat rögzíteni kell.

A publication.c teljes tartalma:

#define _POSIX_C_SOURCE 200809L
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

struct config {
    unsigned port;
    unsigned workers;
};

static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
static const struct config *published = NULL;

static void check(int rc, const char *operation)
{
    if (rc != 0) {
        fprintf(stderr, "%s: pthread hibakod=%d\n", operation, rc);
        _Exit(EXIT_FAILURE);
    }
}

static void *reader(void *unused)
{
    (void)unused;

    check(pthread_mutex_lock(&mutex), "lock");
    const struct config *p = published;
    check(pthread_mutex_unlock(&mutex), "unlock");

    if (p != NULL) {
        printf("port=%u workers=%u\n", p->port, p->workers);
    } else {
        puts("Meg nincs publikalt konfiguracio.");
    }
    return NULL;
}

int main(void)
{
    struct config cfg;
    pthread_t thread;

    check(pthread_create(&thread, NULL, reader, NULL), "create");

    cfg.port = 8080;
    cfg.workers = 4;

    check(pthread_mutex_lock(&mutex), "lock");
    published = &cfg;
    check(pthread_mutex_unlock(&mutex), "unlock");

    check(pthread_join(thread, NULL), "join");

    published = NULL;  /* Mar nincs masik hozzafero szal. */
    check(pthread_mutex_destroy(&mutex), "destroy");
    return EXIT_SUCCESS;
}

Fordítás a megadott verziókra beállított fordítóparancsokkal:

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

A hibapolicy szándékosan fail-fast: pthread-hiba esetén az egész folyamat befejeződik. Különösen fontos, hogy sikertelen join után ne térjünk vissza úgy, mintha a dolgozó biztosan befejeződött volna. A pthread-függvények visszatérési hibakódját vizsgáljuk, nem az errno értékét.

15.2.4.1 A publikálás levezetése

Ha a dolgozó nem null pointert olvasott, a releváns gráf:

Főszál Kapcsolat Dolgozó
II: cfg inicializálása I→sbPI \xrightarrow{sb} P
PP: published = &cfg P→sbUP \xrightarrow{sb} U
UU: mutex feloldása U→swLU \xrightarrow{sw} L LL: mutex megszerzése
QQ: pointer kiolvasása
mutex feloldása
RR: konfiguráció olvasása

Mivel L→sbQ→sbRL \xrightarrow{sb} Q \xrightarrow{sb} R, következik:

I→hbR. I \xrightarrow{hb} R.

A konfiguráció olvasása lehet a kritikus szakaszon kívül, mert publikálás után senki nem módosítja. A const pointer ezt a hozzáférési szándékot jelzi, de önmagában nem akadályozná meg, hogy a főszál a saját cfg objektumát módosítsa.

Ha a dolgozó szerzi meg először a mutexet, null pointert olvas, és nem érinti az inicializálás alatt álló konfigurációt.

15.2.4.2 Tulajdonlás és élettartam

Elem Tulajdonos, tároló Kölcsönzött hozzáférés és érvényesség
cfg A főszál által kezelt automatikus objektum A main blokkjának végéig él
published Statikus pointerobjektum Nem birtokolja cfg-t; közös használatakor mutex védi
Dolgozó p pointere Lokális pointerobjektum cfg-re mutató kölcsönzés; a dolgozó befejezéséig használható
mutex A főszál által lezárt szinkronizációs erőforrás Csak a dolgozó csatlakoztatása után semmisül meg

A cfg élettartamának vége érvénytelenítené a kölcsönzött hozzáférést. A join biztosítja, hogy ez csak az utolsó dolgozói használat után történjen meg.

A publikálás tehát nem élettartam-kezelés. Egy megfelelő atomikus pointerátadás sem akadályozná meg önmagában az objektum túl korai felszabadítását.

15.2.5 A volatile és a diagnosztika korlátai

ISO C17: a volatile nem hoz létre szálak közötti szinkronizációt, és nem tesz egy hozzáférést atomikussá. A pointerobjektumot volatile-nak jelölő deklaráció:

/* ISO C17, deklarációrészlet.
   A volatile NEM teszi biztonsagossa a zarolas elhagyasat. */
static const struct config * volatile published = NULL;

nem pótolja a mutexet. A pointer párhuzamos olvasása és írása továbbra is adatversenyt okozhat. A megcímzett konfiguráció inicializálásának átadását sem oldja meg.

A ThreadSanitizer, TSan instrumentált végrehajtásokban keres adatversenyre utaló hozzáféréseket. A laborban külön diagnosztikai build készíthető:

clang -std=c17 -Wall -Wextra -Wpedantic -O1 -g \
  -fsanitize=thread -pthread publication.c -o publication-tsan
./publication-tsan

Ehhez az adott targeten támogatott, a fordítóhoz illeszkedő sanitizer futtatókönyvtár szükséges.

Egy lelet értelmezésekor keresd meg:

A lelet nem pusztán „rossz értéket” mutat: a rendezési szerződés hiányára irányítja a figyelmet. Ugyanakkor a lelet hiánya nem bizonyít helyességet. A futás nem jár be minden ágat; hiányos instrumentálás vagy nem támogatott szinkronizáció is korlátozhatja a megfigyelést. A kapacitásellenőrzés korábbi logikai hibája pedig megfelelő mutexhasználat mellett nem is adatverseny.

15.2.6 Gyakori hibák és javításuk

15.2.7 Feladatok

  1. Magyarázó feladat. Két szál ugyanazt a nem atomikus objektumot csak olvassa. Ezután az egyik szálban megjelenik egy írás is. Milyen további információ kell az adatverseny eldöntéséhez? Miért nem elég a műveletek időbélyege?

  2. Kódolási feladat. A példában a főszál publikálás után is módosíthassa a konfigurációt, mindig a mutex alatt. Írd át a dolgozót úgy, hogy a kiírás továbbra is zárolás nélkül történjen, de konzisztens értékmásolatot használjon.

  3. Diagnosztikai feladat. Valaki eltávolítja a publikálási példából a mutex megszerzését és feloldását, majd a pointerobjektumot volatile-nak jelöli. Egy TSan-futás nem jelez hibát. Elfogadható-e a módosítás? Megoldaná-e a problémát a program végén végrehajtott join?

15.2.8 Megoldások

15.2.8.1 Az ütközés és a rendezés vizsgálata

Két olvasás önmagában nem ütközik. Írás megjelenésekor az összes érintett olvasással és írással vizsgálni kell a hbhb kapcsolatot. Ha különböző szálak ütköző, legalább részben nem atomikus hozzáférései rendezetlenek, adatverseny van.

Ellenőrzési feltétel: minden ütköző párhoz megnevezhető a rendezési lánc. Az időbélyeg nem ilyen lánc, csak megfigyelt időadat.

15.2.8.2 Pillanatkép készítése a mutex alatt

Az eredeti fájlban a dolgozó függvény helyettesíthető ezzel a C17/POSIX-részlettel:

static void *reader(void *unused)
{
    (void)unused;
    struct config snapshot;
    int available;

    check(pthread_mutex_lock(&mutex), "lock");
    available = (published != NULL);
    if (available) {
        snapshot = *published;
    }
    check(pthread_mutex_unlock(&mutex), "unlock");

    if (available) {
        printf("port=%u workers=%u\n",
               snapshot.port, snapshot.workers);
    }
    return NULL;
}

A dolgozó birtokolja saját snapshot objektumát; a megosztott objektum kölcsönzött elérése a kritikus szakaszra korlátozódik. A főszál továbbra is életben tartja cfg-t a csatlakozásig.

Ellenőrzési feltétel: minden publikálás utáni módosítás ugyanazon mutex alatt történjen, és a teljes struktúramásolás egyetlen kritikus szakaszban maradjon. A másolat itt önálló, mert a struktúra nem tartalmaz további kölcsönzött pointereket.

15.2.8.3 A negatív TSan-futás értékelése

A módosítás nem fogadható el. A pointer írása és olvasása között nincs megfelelő rendezés; a volatile nem teremt ilyet. A sikeres join a dolgozó befejezését rendezi a főszál későbbi műveletei elé, nem a főszál korábbi publikálását a dolgozó korábbi olvasása elé.

Ellenőrzési feltétel: vissza kell állítani a bizonyítható szinkronizációs protokollt. Az ismételt TSan-futtatás kiegészítő diagnosztika, nem helyettesítő bizonyítás.

15.2.9 Összefoglaló

Az adatversenyhez ütköző, különböző szálakon végzett, legalább részben nem atomikus és happens-before szerint rendezetlen hozzáférések kellenek. Következménye UB. A logikai versenyhelyzet ennél tágabb: adatversenymentes műveletekből is összeállhat hibás protokoll.

Megosztott pointer használatakor külön bizonyítsd a pointer átadását, az objektum inicializálásának megfigyelhetőségét és az élettartamot. A mutex ezekhez rendezést adhat; a volatile és a hibajelzés nélkül lefutó teszt nem ad ilyen garanciát.