A volatile gyakran azért kerül egy változó
deklarációjába, mert a programozó azt szeretné: „a fordító ne tüntesse
el ezt a hozzáférést”. Ez bizonyos feladatoknál indokolt, de nem azonos
azzal, hogy „egy másik szál biztonságosan megfigyelheti az adatot”, vagy
hogy „az eszköz már végrehajtotta az írással kért műveletet”. A három
állítás három külön szerződéshez tartozik.
A típusminősítőkről szóló rész a
volatile nyelvi helyét, az optimalizálóról szóló rész az as-if szabályt, a cache és locality tárgyalása pedig a hardveres
környezetet alapozta meg. Most ezeket választjuk szét úgy, hogy a
következő fejezet atomikus műveleteit már ne „különleges gépi
utasításként”, hanem nyelvi szinkronizációs szerződés részeként
értelmezzük.
A forráskód sorrendje, a gépi utasítások sorrendje és a memóriahatások más végrehajtók számára látható sorrendje nem ugyanaz.
| Szint | Miről rendelkezik? | Mit nem bizonyít önmagában? |
|---|---|---|
| ISO C nyelvi modell | Megengedett kiértékelések, objektumhozzáférések, szálközi szinkronizáció | Konkrét utasítássorozatot |
| Fordító | A forrásból előállított gépi program szerkezete | Más magok vagy eszközök megfigyelési sorrendjét |
| CPU és platform | Az utasítások, memóriatípusok és barrier utasítások hardveres hatása | A C forrás adatverseny-mentességét |
Fordítói átrendezésnek (compiler reordering) nevezzük, amikor a fordító a megengedett viselkedés megőrzésével más sorrendű gépi műveleteket állít elő. Hozzáféréseket összevonhat vagy elhagyhat, értékeket regiszterben tarthat. Ez nem feltétlenül két szomszédos utasítás egyszerű felcserélése.
Sorrenden kívüli végrehajtáskor (out-of-order execution) a processzor a gépi utasítások belső végrehajtását ütemezi át. Ettől külön kérdés a memóriahatások megfigyelési sorrendje: például egy írási puffer (store buffer) késleltetheti egy tárolás más magok számára való láthatóságát. Memóriasorrendi problémához tehát nem feltétlenül szükséges out-of-order mag.
Az egy szálból megfigyelhető helyes végrehajtás nem követeli meg minden belső művelet forrás szerinti fizikai sorrendjét. A fordító és a processzor is kihasználhatja ezt, de más szabályrendszer alapján.
ISO C17: a nyelvi szálközi modell azt határozza meg, hogy mely hozzáférések rendezettek egymáshoz képest. A később pontosított happens-before reláció nem pusztán azt jelenti, hogy a faliórán mérve az egyik művelet korábban történt. Szabályosan létrehozott nyelvi kapcsolat szükséges.
Következésképpen a disassemblyben látható „jó sorrend” önmagában nem bizonyítja egy többszálú C program helyességét.
ISO C17: a volatile típusminősítővel
jelzett objektumhoz tartozó hozzáféréseket az absztrakt gép szabályai
szerint kell kezelni. Annak pontos meghatározása, hogy mi számít ilyen
hozzáférésnek, implementáció által meghatározott. Hardverkapcsolatnál
ezért a fordító dokumentált szerződése is szükséges.
A volatile nem általános optimalizálástiltás. Különösen
nem biztosítja, hogy:
volatile hozzáférések minden kívánt
sorrendben maradnak;Az alábbi hibás C17 tanítási részlet, nem önálló
program. A feltételezés szerint a publish és a
consume két külön szálon, további szinkronizáció nélkül
fut:
static unsigned payload;
static volatile unsigned ready;
void publish(void)
{
payload = 42u;
ready = 1u;
}
unsigned consume(void)
{
while (ready == 0u) {
}
return payload;
}A szándék az, hogy a fogyasztó csak az adat elkészülte után olvasson.
A volatile miatt az implementáció szokásos hozzáférési
szerződése mellett a fordító nem helyettesítheti egyszerűen egyetlen
kezdeti olvasással a jelző ismételt lekérdezését. Ebből azonban még nem
következik szálközi helyesség.
ISO C17: adatverseny áll fenn, ha különböző szálak egymással konfliktusban álló hozzáférései közül legalább az egyik nem atomikus, és egyik hozzáférés sem történik a másik előtt a nyelvi happens-before reláció szerint. Az adatverseny nem definiált viselkedéshez vezet.
Itt már a ready írása és olvasása adatversenyt képezhet.
A payload publikálásához sincs megfelelő
szinkronizáció.
Indoklásvázlat:
volatile unsigned nem atomikus típus.volatile nem létesít szálközi rendezési
kapcsolatot.Az objektumok statikus tárolási idejűek, élettartamuk a program végrehajtásáig tart; kezelésükért a modul felel. Nincs felszabadítás miatti érvénytelenítés. A hiba itt nem élettartamhiba, hanem szinkronizációs hiba, garantált kimenet nélkül.
A fordítói gát (compiler barrier) az optimalizáló számára korlátozza bizonyos műveletek átrendezését. Hatóköre a konkrét primitív szerződésétől függ: például memóriahozzáférésekre vonatkozhat anélkül, hogy minden regiszterművelet helyét rögzítené.
Egy tisztán fordítói gát nem feltétlenül eredményez gépi utasítást. Emiatt nem várhatjuk tőle, hogy írási puffereket rendezzen, vagy egy perifériával folytatott tranzakciót befejezzen.
A hardveres memóriagát (hardware memory barrier, fence) a processzor architekturális szabályai szerint rendel egymáshoz memóriahatásokat. Nem szükségképpen „mindent megállító” művelet: számíthat a hozzáférés fajtája, a memóriatípus és a megfigyelők köre.
Architekturális példák: x86-64-en az
mfence, AArch64-en a dmb család kapcsolódik
memóriasorrendi követelményekhez. Ezek nem felcserélhető receptek. Az
AArch64 barrier tartománya és fajtája, illetve mindkét architektúrán a
memóriatípus is lényeges. Az utasításválasztást itt nem vezetjük le.
| Eszköz | Elsődleges szerep | Önmagában kijavítja a fenti adatversenyt? |
|---|---|---|
volatile |
Volatile hozzáférések implementáció szerinti kezelése | Nem |
| Compiler barrier | Fordítói átrendezések korlátozása | Nem |
| CPU-fence | Meghatározott hardveres memóriahatások rendezése | Nem |
| Megfelelő atomikus protokoll vagy mutex | Nyelvi szálközi szinkronizáció | Igen, ha a teljes protokoll helyes |
Fontos fordított összefüggés: egy helyes nyelvi szinkronizációs művelet nem mindig igényel külön hardveres fence utasítást. Egy adott célarchitektúra meglévő sorrendiségi garanciái elegendők lehetnek. A hiányzó fence utasítás tehát önmagában nem bizonyít hibát.
Fordítóbővítés: a GCC és a Clang
__atomic_* builtin családja nem az ISO C hordozható
felülete. Például:
__atomic_signal_fence(__ATOMIC_SEQ_CST) fordítói
rendezést szolgál a futó szál és az ugyanazon szálon végrehajtott signal
handler viszonyában, hardveres gát nélkül;__atomic_thread_fence(__ATOMIC_SEQ_CST) szálközi
fence szemantikát kér, amelyet a fordító a célarchitektúrához képez
le.Ezek nem szöveges „itt tilos bármit megmozdítani” jelölők, és nem általános MMIO-eszközök. Az elfogadott argumentumokat és a célplatformos leképezést a konkrét fordítókiadás alapján kell ellenőrizni.
Egy ilyen fence beillesztése a hibás ready változó mellé
nem teszi atomikussá annak hozzáféréseit.
A memóriatérképezett I/O (memory-mapped I/O, MMIO) olyan platformmechanizmus, amelyben egy címtartomány elérései eszközműveleteket kezdeményeznek. Az olvasás például állapotot adhat vissza, FIFO-elemet fogyaszthat el vagy törölhet egy jelzést.
Ezzel szemben a szálak által közösen használt közönséges RAM esetében általában C objektumokat olvasunk és írunk.
| Tulajdonság | Közönséges megosztott RAM | MMIO |
|---|---|---|
| Jelentés | Programadat | Eszközprotokoll része |
| Cache-elhetőség | Gyakran cache-elhető, koherens memória | Platform- és leképezésfüggő |
| Olvasási mellékhatás | Közönséges objektumnál nincs eszközmellékhatás | Lehet |
| Helyes rendezés eszköze | Nyelvi szinkronizáció | Eszköz-, OS- és architektúraszerződés |
volatile szerepe |
Nem helyettesíti a szinkronizációt | Része lehet a hozzáférési megoldásnak |
A volatile nem változtatja meg a laptábla vagy más
hardveres attribútum által meghatározott cache-elhetőséget. Az MMIO sem
egyszerűen „mindig cache nélküli memória”: létezhetnek például
írásösszevonást engedő tartományok. Ugyanazon eszköz különböző régióihoz
is eltérő attribútumok tartozhatnak.
Linux/platform: egy tetszőleges fizikai cím pointerré alakítása nem hoz létre érvényes felhasználói leképezést. Az eszközhöz hozzáférést megfelelő driver- vagy más dokumentált OS-interfésznek kell biztosítania.
A sorrendiség és a befejeződés is külön fogalom. Egy elküldött, de még továbbítás alatt álló eszközírás után akár dokumentált visszaolvasásra vagy státuszvizsgálatra is szükség lehet. Általános CPU-fence nem helyettesíti az eszköz protokollját.
A következő C17 fordítási egység feltételes platformpélda, nem önmagában teljes MMIO-megoldás.
Előfeltételei:
uint32_t, és a platform 8 bites C bájtot
használ;status megfelelően igazított, érvényes leképezett
státuszregiszterre mutat;mask != 0./* mmio_poll.c — C17, platformfüggő MMIO-hozzáférési szerződéssel */
#include <limits.h>
#include <stddef.h>
#include <stdint.h>
#ifndef UINT32_MAX
#error "This example requires uint32_t"
#endif
_Static_assert(CHAR_BIT == 8, "This profile requires 8-bit C bytes");
_Static_assert(sizeof(uint32_t) == 4, "Unexpected uint32_t size");
int mmio_poll_mask(volatile const uint32_t *status,
uint32_t mask,
size_t attempts)
{
for (size_t i = 0; i < attempts; ++i) {
uint32_t sample = *status;
if ((sample & mask) == mask) {
return 1;
}
}
return 0;
}A const ezen a hozzáférésen keresztül tiltja az írást;
nem állítja, hogy az eszköz állapota változatlan. Az egy iterációban
végzett egyetlen olvasás után a teszt a helyi sample
értéken dolgozik.
| Szerep | Szerződés |
|---|---|
| Tulajdonos | A leképezést kezelő hívó komponens |
| Tároló | Platform által elérhetővé tett eszközregiszter-tartomány |
| Kölcsönzött hozzáférés | status, a teljes hívás alatt érvényesen |
| Érvénytelenítés | Leképezés megszüntetése vagy eszközhozzáférés visszavonása |
| Eredmény és hibakimenet | 1: feltétel teljesült; 0: próbakeret
elfogyott; hibás pointerre nincs garantált hibakód |
Legyen mask = 0x05, a három olvasott érték pedig
szemléltetésként 0x00, 0x04,
0x05. A teszt rendre:
Csak a harmadik minta tartalmazza az összes kért bitet, ezért a
függvény akkor ad vissza 1-et. Ez feltételezett bemeneti
sorozat, nem mérési eredmény.
Legfeljebb olvasás történik, ahol ; a költség , allokáció nincs. Ez nem időkorlát: egy eszközolvasás késleltetése nincs a függvény által korlátozva.
Választott fordítási profil: Linux x86-64 System V, LP64, GCC 14.2 vagy Clang 19.1.7, glibc 2.39 fejlécekkel. Az alábbi parancsok megfelelően telepített fordítókat feltételeznek; nem igazolják az eszközoldali szerződést.
gcc -std=c17 -O2 -Wall -Wextra -Wpedantic \
-S mmio_poll.c -o mmio_poll.gcc.s
clang -std=c17 -O2 -Wall -Wextra -Wpedantic \
-S mmio_poll.c -o mmio_poll.clang.sA példa nincs itt fordítással vagy futtatással ellenőrizve. Az assembly vizsgálatának célja a hozzáférések leképezése, nem az MMIO-protokoll vagy a szálközi helyesség bizonyítása.
Az ISO C17 <stdatomic.h> fejléce atomikus
típusokat, műveleteket és fence függvényeket biztosít. Ezeknél már a
nyelvi szerződésből indulunk ki, és annak megvalósítása
a fordító feladata.
A következő részekben a publikálás kérdése így fogalmazódik át: melyik művelet teszi közzé az adatot, melyik figyeli meg ezt, és milyen kapcsolat rendezi közéjük a közönséges hozzáféréseket? Nem elég annyi, hogy „valahol van egy atomikus változó”.
Az atomic_signal_fence és az
atomic_thread_fence célja sem azonos; az utóbbi
használhatóságához is teljes szinkronizációs protokoll szükséges. A
részleteket az adatversenyről és publikálásról,
majd a C atomikus műveleteiről szóló egység
dolgozza fel.
A leggyakoribb hibák javítási iránya röviden:
Egy volatile változó két olvasásához két betöltési
utasítást látsz. Mit bizonyít ez a fordítóról, és mit nem bizonyít a
CPU-ról, az eszközről vagy a szálközi helyességről?
Módosítsd az mmio_poll_mask függvényt úgy, hogy nulla
maszk esetén -1 legyen az eredmény, és ekkor ne érje el a
regisztert. Mi történjen nulla próbakeretnél?
A hibás publikálási példában a fejlesztő mindkét szálon erős thread fence-et helyez el a jelző hozzáférése közelében. A változók típusát nem módosítja. Megszűnt-e az adatverseny? Adj egy helyes javítási irányt.
A látott kódrészlet két betöltési utasítást tartalmaz az adott fordítási profil mellett. Ebből nem következik atomikusság, megfelelő cache-attribútum, eszközoldali befejeződés vagy nyelvi szinkronizáció.
Az ellenőrzési feltétel: minden állítást a saját rétegéhez kell kötni. A teljes program helyességét nem lehet pusztán az utasítások megszámolásával igazolni.
A függvénytörzs elejére, minden dereferencia elé kerüljön a következő C17 részlet:
if (mask == 0) {
return -1;
}Így a nulla maszk már kezelt bemeneti hiba, nem előfeltétel-sértés.
Nem nulla maszk és nulla próbakeret esetén a ciklus nem fut le, az
eredmény 0.
Ellenőrzési feltétel: mindkét esetben nulla regiszterolvasás történjen. Ez nem teszi általában ellenőrzötté a pointer érvényességét.
Nem szűnt meg az adatverseny. A ready továbbra is nem
atomikus objektum, és a fence-ek puszta jelenléte nem hoz létre
megfelelő kapcsolatot a hozzáférések között.
Helyes irány például a jelző és az adat minden konfliktusban álló hozzáférésének ugyanazzal a mutexszel történő védelme. Másik lehetőség egy megfelelő atomikus publikálási protokoll. Az ellenőrzési feltétel nem egy fence utasítás megjelenése, hanem az összes érintett hozzáférés nyelvi rendezése.
A volatile hozzáférési minősítő, nem szálközi
szinkronizáció. A compiler barrier a fordítót, a hardveres fence
meghatározott gépi memóriahatásokat korlátoz. Egyik sem javítja ki
automatikusan a C adatversenyét.
MMIO esetén az eszköz és a platform hozzáférési szerződése dönt; közönséges megosztott memóriánál a nyelvi szinkronizációból kell kiindulni. A helyes sorrend: először a szerződés, utána annak fordítói és hardveres megvalósítása.