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.
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.
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.
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 növelés történik, a kezdőérték nulla, és , akkor az összes növelő szál sikeres bevárása után olvasott érték . 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á.
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-clangSikeres 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.
Bizonyításvázlat, ISO C17. Jelölje:
value vagy jobs mező írását;true értéket
olvas;Ebben a programban egyetlen művelet ír true értéket.
Ezért
az
által írt értéket olvassa, és létrejön a synchronizes-with
kapcsolat:
Ebből:
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á.
| 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.
A compare-and-exchange, röviden CAS, összehasonlítja az
atomikus objektum értékét az expected által megadott várt
értékkel.
true értékkel
tér vissza.expected változót.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á:
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.
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.
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.
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.
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?
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.
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?
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ő.
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:
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.
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.
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.