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.
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 |
|---|---|
:
counter olvasása |
:
counter olvasása |
:
counter módosítása |
:
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 és , valamint és 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.
A végrehajtási gráf (execution graph) csúcsai műveletek, élei pedig nyelvi vagy szinkronizációs kapcsolatok.
A sequenced-before, röviden , 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.
A synchronizes-with, röviden , 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.
A happens-before, röviden , a helyesség szempontjából lényeges megelőzési reláció. Az itt használt, mutexes részmodellben az és élekből tranzitív módon vezetjük le.
Legyen:
Ekkor:
amiből:
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 é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.
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.
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:
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-clangA 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.
Ha a dolgozó nem null pointert olvasott, a releváns gráf:
| Főszál | Kapcsolat | Dolgozó |
|---|---|---|
:
cfg inicializálása |
||
:
published = &cfg |
||
| : mutex feloldása | : mutex megszerzése | |
| : pointer kiolvasása | ||
| mutex feloldása | ||
| : konfiguráció olvasása |
Mivel , következik:
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.
| 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.
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-tsanEhhez 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.
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?
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.
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?
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 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.
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.
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.
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.