14.5 volatile, compiler barrier és hardver barrier

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.

14.5.1 Három különböző sorrendiség

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.

14.5.2 Mit jelent a volatile, és mit nem?

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:

14.5.2.1 Hibás publikálás volatile jelzővel

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:

  1. A volatile unsigned nem atomikus típus.
  2. Ugyanazt a jelzőobjektumot külön szál írja és olvassa.
  3. A volatile nem létesít szálközi rendezési kapcsolatot.
  4. Ezért a program nem kap nyelvi helyességi garanciát attól, hogy a fordító tényleges betöltéseket generál.

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.

14.5.3 Compiler barrier és hardveres fence

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.

14.5.3.1 GCC/Clang builtinok: nem ISO API

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:

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.

14.5.4 MMIO és közönséges megosztott memória

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.

14.5.4.1 Kidolgozott példa: korlátozott számú státuszlekérdezés

A következő C17 fordítási egység feltételes platformpélda, nem önmagában teljes MMIO-megoldás.

Előfeltételei:

/* 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:

0x00&0x05=0x00,0x04&0x05=0x04,0x05&0x05=0x05. 0x00 \mathbin{\&} 0x05 = 0x00,\qquad 0x04 \mathbin{\&} 0x05 = 0x04,\qquad 0x05 \mathbin{\&} 0x05 = 0x05.

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 AA olvasás történik, ahol A=𝚊𝚝𝚝𝚎𝚖𝚙𝚝𝚜A=\texttt{attempts}; a költség O(A)O(A), 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.s

A 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.

14.5.5 Átvezetés a stdatomic használatához

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:

14.5.6 Feladatok

14.5.6.1 Magyarázó feladat: mit bizonyít az assembly?

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?

14.5.6.2 Kódolási feladat: ellenőrizhető bemeneti hiba

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?

14.5.6.3 Diagnosztikai feladat: fence a hibás jelző körü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.

14.5.7 Megoldások

14.5.7.1 Az assembly megfigyelésének határa

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.

14.5.7.2 Nulla maszk kezelése

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.

14.5.7.3 A fence nem alakítja át a változó típusá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.

14.5.8 Összefoglaló

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.