15.1 Szál-életciklus, pthreads és C11 threads

Egy szál indítása nem pusztán új végrehajtási út létrehozása: kölcsönadunk neki adatokat, felelőst rendelünk a befejezésének megfigyeléséhez, és meghatározzuk, mi történjen leállításkor. A legfontosabb kérdés ezért nem az, hogy „melyik függvény indít szálat?”, hanem az, hogy ki garantálja a szál által használt objektumok élettartamát, és ki várja meg az utolsó hozzáférést.

A Tulajdonlási szerződés és cleanup ágak elvei itt is érvényesek. A Függvénypointerek, callbackek és context pointer mintája most párhuzamos végrehajtással egészül ki. A fork, exec, pipe és gyermekfolyamat kapcsán tárgyalt folyamatoktól eltérően a szálak ugyanannak a folyamatnak az objektumait érhetik el; külön végrehajtási állapotuk nem jelent külön tulajdonlást.

15.1.1 Két API, közös életciklus

POSIX: a pthreads API deklarációit a <pthread.h> biztosítja. A szál belépési függvényének típusa:

/* Deklarációs részlet; C17 + POSIX. */
void *worker(void *context);

ISO C17: a C11-ben bevezetett, opcionálisan támogatott szálkönyvtár a <threads.h> fejlécben található. Ennek belépési függvénye eltérő típusú:

/* Deklarációs részlet; C17, támogatott <threads.h> mellett. */
int worker(void *context);

A két függvénytípus nem felcserélhető. A típuseltérést függvénypointer-casttal elfedni nem javítás.

Feladat POSIX pthreads ISO C17 szálkönyvtár
Szálazonosító típusa pthread_t thrd_t
Indítás pthread_create thrd_create
Befejezés megvárása pthread_join thrd_join
Leválasztás pthread_detach thrd_detach
Belépési függvény eredménye void * int
API sikerjelzése Az itt tárgyalt függvényeknél 0 thrd_success
Szálmegszakítási API Van cancellation mechanizmus Nincs megfelelő standard cancellation API

A továbbiakban csatlakoztatható szálnak (joinable thread) nevezzük azt, amelynek befejezését egy másik szál join művelettel megvárhatja. A leválasztott szál (detached thread) befejezésekor a szálkezelő rendszer a szálhoz tartozó erőforrásokat külön join nélkül visszanyerheti.

A belépési függvény visszatérése és a szál erőforrásainak visszanyerése tehát külön esemény. Egy már befejeződött joinable szálhoz is tartozhatnak visszanyerésre váró erőforrások.

15.1.2 Indítás, context és tulajdonlás

A következő pthread-hívás új szálat kér, alapértelmezett attribútumokkal:

/* Hívási részlet; C17 + POSIX, a változók deklarációját feltételezi. */
int rc = pthread_create(&thread, NULL, worker, &job);

Siker esetén a szál joinable. Az új szál már a pthread_create visszatérése előtt is elkezdhet futni. Emiatt a job szükséges inicializálását az indítás előtt be kell fejezni.

A void * paraméter csak pointerérték. Nem másolja le a megcímzett objektumot, és nem hosszabbítja meg annak élettartamát.

Egy stacken létrehozott context teljesen megfelelő, ha az azt tartalmazó blokk nem ér véget a worker utolsó hozzáférése előtt. Ennek egyszerű biztosítéka a blokk elhagyását megelőző sikeres join.

Entitás Tulajdonos vagy felelős Élettartami szerződés Érvénytelenítés
job objektum és tárolója Az indító függvény A worker befejezéséig él A létrehozó blokk vége
Worker context pointere Kölcsönzött hozzáférés Csak élő job objektumot érhet el A job élettartamának vége
Joinable szál életciklusa Kijelölt join-felelős Sikeres joinnal vagy tudatos detach-csel rendezendő Az életciklus lezárása
Worker által írt eredmény A job része Az indító csak sikeres join után olvassa A job élettartamának vége

A pthread_create hibája esetén nem jött létre kezelendő szál; a kimeneti szálazonosítót nem szabad joinhoz használni.

15.1.2.1 Kidolgozott példa: kölcsönzött context, sikeres join után olvasott eredmény

A példa minimumkövetelménye C17 és POSIX pthreads. Konkrét reprodukciós célprofilként Linux x86-64 System V ABI, glibc 2.40, GCC 14.2.0 vagy Clang 19.1.7 választható. Ez megadott, nem fordítással ellenőrzött profil; a saját laborban a tényleges verziókat rögzíteni kell.

A job.count előfeltétele ebben a példában legfeljebb 1000. Nincs külső cancellation, és csak a main kezeli a szálazonosítót.

/* thread_job.c — C17 + POSIX pthreads */
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

struct job {
    unsigned long count;
    unsigned long result;
};

static void *worker(void *context)
{
    struct job *job = context;
    unsigned long sum = 0;

    for (unsigned long i = 0; i < job->count; ++i) {
        sum += i;
    }

    job->result = sum;
    return context;
}

static void fatal_pthread(const char *operation, int code)
{
    fprintf(stderr, "%s: hibakod=%d\n", operation, code);
    exit(EXIT_FAILURE);
}

int main(void)
{
    struct job job = {
        .count = 1000,
        .result = 0
    };
    pthread_t thread;

    int rc = pthread_create(&thread, NULL, worker, &job);
    if (rc != 0) {
        fatal_pthread("pthread_create", rc);
    }

    void *returned = NULL;
    rc = pthread_join(thread, &returned);
    if (rc != 0) {
        fatal_pthread("pthread_join", rc);
    }

    if (returned != &job) {
        fputs("Ervenytelen worker-eredmeny\n", stderr);
        return EXIT_FAILURE;
    }

    printf("%lu\n", job.result);
    return EXIT_SUCCESS;
}

Fordítás a külön laborban:

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

A -pthread a GCC/Clang platformfüggő driveropciója, nem ISO C nyelvi elem. A fordítási és linkelési lépésben is használni kell; a fenti parancsok mindkettőt elvégzik.

A számítás szerint várt, nem mért eredmény:

∑i=0999i=999⋅10002=499500. \sum_{i=0}^{999} i = \frac{999\cdot1000}{2} = 499500.

Helyességi bizonyításvázlat.

  1. A main inicializálja a contextet a szál indítása előtt.
  2. A worker futása alatt a main nem módosítja a bemenetet, és nem olvassa az eredményt.
  3. A sikeres pthread_join megvárja a worker befejezését, és biztosítja a szükséges szinkronizációt az eredmény ezt követő olvasásához.
  4. A job blokkja csak ezután ér véget.

A pontos memóriamodellbeli kapcsolatot a Data race, happens-before és publikalás tárgyalja.

A join hibája különösen fontos: ilyenkor a program nem állíthatja, hogy a worker már nem használja a contextet. A példa ezért folyamatmegszakító hibapolitikát alkalmaz. Egy könyvtár nem dönthet önkényesen így; annak az unresolved életciklusért viselt felelősséget is meg kell őriznie vagy át kell adnia.

15.1.3 Hibakód, eredmény és errno

POSIX: a pthread_create, pthread_join és pthread_detach hibakódját a függvény visszatérési értéke adja. Nem a szokásos „-1, majd errno” mintát követik.

Ezért hibás diagnosztikai minta:

/* HIBÁS részlet: a perror nem a visszaadott pthread-hibakódot írja ki. */
if (pthread_create(&thread, NULL, worker, &job) != 0) {
    perror("pthread_create");
}

A perror az aktuális errno alapján dolgozik, amely nem feltétlenül kapcsolódik ehhez a hibához. A helyes minta eltárolja a visszatérési értéket; a teljes példában ezért közvetlenül a numerikus hibakód szerepel.

A pthread_join esetében két külön eredménycsatorna van:

A worker nem adhat vissza használható pointert a saját megszűnt automatikus objektumára. Heapobjektum pointerét visszaadhatja, ha az átadott tulajdonlást és a felszabadítás felelősét dokumentáljuk. A példában nincs tulajdonátruházás: ugyanaz a kölcsönzött pointer tér vissza.

15.1.4 C11 threads: támogatás és fordítási próba

ISO C17: a szálkönyvtár opcionális. A __STDC_NO_THREADS__ makró 1 értéke jelzi a támogatás hiányát. A makró vizsgálata mellett a tényleges compiler–libc–linker összeállítást is ellenőrizni kell.

A következő önálló program egyszerre mutatja az API eltérő eredményszerződését, és használható fordítási/linkelési próbának:

/* c11_probe.c — C17, opcionális ISO szálkönyvtár */
#if defined(__STDC_NO_THREADS__) && __STDC_NO_THREADS__ == 1
#error "Az implementacio nem tamogatja a C szalkonyvtarat"
#endif

#include <stdlib.h>
#include <threads.h>

static int worker(void *context)
{
    (void)context;
    return 7;
}

int main(void)
{
    thrd_t thread;

    int status = thrd_create(&thread, worker, NULL);
    if (status != thrd_success) {
        return EXIT_FAILURE;
    }

    int worker_result = 0;
    status = thrd_join(thread, &worker_result);
    if (status != thrd_success) {
        exit(EXIT_FAILURE);
    }

    return worker_result == 7 ? EXIT_SUCCESS : EXIT_FAILURE;
}

A Linuxos GCC/Clang célprofilhoz:

gcc -std=c17 -Wall -Wextra -Wpedantic -pthread c11_probe.c -o c11_probe

Itt nincs kölcsönzött contextobjektum: a callback nem dereferálja a null pointert. A szál életciklusáért a main felel. A sikertelen indítás hibakimenet, a join hibája folyamatleállítást eredményez.

A thrd_create státusza például thrd_success, thrd_nomem vagy thrd_error lehet. Ezek nem POSIX hibakódok, és nem errno-értékek. A thrd_join által átvett 7 pedig nem könyvtári státusz, hanem a worker alkalmazásszintű eredménye.

A sikeres fordítás és linkelés csak a szükséges felület elérhetőségét igazolja. A működés vizsgálata külön futtatási lépés; itt ilyen ellenőrzést nem feltételezünk.

15.1.5 Thread-local storage: szálanként külön objektum

ISO C17: a _Thread_local tárolásiosztály-specifikátor szálhoz kötött tárolási idejű objektumot jelöl. Minden szálnak saját példánya van, amelynek élettartama az adott szál végrehajtásának idejéhez kötődik.

/* Részlet; C17, TLS-t támogató compiler/target.
   A számláló túlcsordulása unsigned moduloaritmetika. */
static _Thread_local unsigned long completed_jobs;

static void record_completion(void)
{
    ++completed_jobs;
}

A static itt fájlszinten belső nyelvi kapcsolást ad; a _Thread_local ettől függetlenül a tárolási időt határozza meg.

A szálankénti számláló nem közös összesítő. Attól, hogy minden worker növeli a saját példányát, a főszál példányában nem jelenik meg az összeg.

A TLS-cím továbbadása sem általános megosztási technika:

Például egy TLS-pointerben tárolt heapobjektum felszabadításáról továbbra is gondoskodni kell. Az explicit szálkilépési destruktorokhoz a pthread kulcsalapú TLS vagy a C szálkönyvtár tss_* felülete külön mechanizmust nyújt.

15.1.6 Join, detach és folyamatleállítás

A detach nem leállítási kérés, és nem várakozás. Csak azt változtatja meg, hogyan rendeződik a szálkezelési erőforrás életciklusa.

Művelet Megvárja a befejezést? Felszabadítja az alkalmazás contextjét?
Sikeres pthread_join Igen Nem
Sikeres pthread_detach Nem Nem
Worker visszatérése A worker befejeződik Csak saját explicit cleanup esetén
Folyamat megszűnése Nem szabályos join-sorozat Az alkalmazási lezárási protokoll nem garantált

Egy szálhoz egyértelmű join-felelőst rendeljünk. Azonos szál párhuzamos joinolása, saját magunk joinolása, illetve már leválasztott vagy lezárt életciklusú szálazonosító használata nem elfogadható protokoll. A pthread_t nem hordozható egész szám, és nem tartós, korlátlanul újrahasználható azonosító.

A rendezett leállítás tipikus sorrendje:

  1. Új munka fogadásának leállítása.
  2. Szinkronizált leállítási állapot beállítása.
  3. A várakozó workerek felébresztése.
  4. A folyamatban lévő munka és saját erőforrások lezárása.
  5. Minden joinable worker joinolása.
  6. A közös context és a szinkronizációs objektumok felszabadítása.

A stopjelző nem lehet szinkronizálatlan közös bool, és a volatile sem teszi megfelelővé. A megoldást a Mutex, condition variable és producer–consumer rész építi fel.

POSIX folyamatéletciklus: a main visszatérése vagy az exit a folyamatot állítja le, nem csak a hívó szálat. A detached workerek sem maradnak életben. A pthread_exit ezzel szemben csak a hívó szálat fejezi be, de nem helyettesíti a join-felelősséget, és nem hosszabbítja meg a megszűnő automatikus contextobjektumok élettartamát.

15.1.7 Cancellation és cleanup: korlátozott eszközök

POSIX: a pthread_cancel megszakítási kérelmet küld. A sikeres visszatérés nem bizonyítja, hogy a célpont már befejeződött.

Az alapértelmezett, halasztott (deferred) cancellation engedélyezett állapotban meghatározott cancellation pointokon léphet érvénybe. Egy ilyen pont nélküli számítás nem feltétlenül reagál rá. Az aszinkron cancellation tetszőleges ponton szakíthatja félbe az invariánsok helyreállítását; általános alkalmazáskódban kerülendő.

A pthread_cleanup_push és pthread_cleanup_pop cleanupkezelőket rendelhet az aktuális szálhoz. Ezek:

A belépési függvény közönséges visszatérése nem futtatja automatikusan a még regisztrált cleanupkezelőket. A cleanup-régiót átugró return vagy goto sem megfelelő használat.

Sikeres join esetén a megszakított pthread eredménye PTHREAD_CANCELED lehet; ez nem join-hibakód. Ráadásul maga a pthread_join is cancellation point, ezért a join-felelős megszakíthatóságát is kezelni kell.

Szolgáltatásokban általában könnyebben indokolható a kooperatív shutdown: a worker ismert, helyes állapotban észleli a leállítási kérést, majd maga végez cleanupot.

15.1.8 Feladatok

15.1.8.1 Élettartam magyarázata

Egy függvény lokális struct job címével indít workert, sikeresen detach-eli a szálat, majd visszatér. Miért nem elegendő a detach? Adj két javítást.

15.1.8.2 Több worker indítása

Bővítsd a pthread-példát két, külön contextet használó workerre. Ha a második indítás sikertelen, az első szál életciklusát akkor is szabályosan rendezd.

15.1.8.3 Hibadiagnosztika

Egy alkalmazás a pthread_create hibáját perror segítségével írja ki, sikeres indítás után pedig join nélkül kiolvassa a worker eredményét. A worker végtelen számítási ciklusát pthread_cancel hívással próbálja leállítani. Azonosíts három külön hibát vagy hibás feltételezést.

15.1.9 Megoldások

15.1.9.1 Élettartam magyarázata

A detach csak a szálkezelési erőforrásokra vonatkozik. A függvény visszatérése megszünteti a lokális context élettartamát, miközben a worker még hozzáférhet.

Két javítás:

A második változatban sikertelen indításkor az indító szabadít fel; siker esetén a worker. Ellenőrzési feltétel: minden hibautat is figyelembe véve pontosan egy felszabadítás legyen, az utolsó hozzáférés után.

15.1.9.2 Több worker indítása

A kiinduló programba illeszthető vezérlési részlet, változatlan workerrel és hibakezelővel:

/* Részlet; C17 + POSIX, a thread_job.c definícióival. */
struct job jobs[2] = {
    { .count = 1000, .result = 0 },
    { .count = 1000, .result = 0 }
};
pthread_t threads[2];
size_t started = 0;
int create_error = 0;

for (size_t i = 0; i < 2; ++i) {
    int rc = pthread_create(&threads[i], NULL, worker, &jobs[i]);
    if (rc != 0) {
        create_error = rc;
        break;
    }
    ++started;
}

for (size_t i = 0; i < started; ++i) {
    int rc = pthread_join(threads[i], NULL);
    if (rc != 0) {
        fatal_pthread("pthread_join", rc);
    }
}

if (create_error != 0) {
    fprintf(stderr, "pthread_create: hibakod=%d\n", create_error);
    return EXIT_FAILURE;
}

A main tulajdonolja a contexttömböt; minden worker csak a saját elemét kölcsönzi. A tömb a joinokig él. Ellenőrzési feltétel: pontosan started érvényes szálazonosítót joinolunk, és indítási hiba miatt nem hagyjuk el idő előtt a context blokkát.

15.1.9.3 Hibadiagnosztika

Az első hiba a téves hibacsatorna: a pthread visszaadott hibakódját kell használni, nem az errno aktuális értékét.

A második a szinkronizálatlan eredményolvasás: megfelelő rendezés nélkül ütközhet a worker írásával. Ebben a mintában a sikeres join utáni olvasás a javítás.

A harmadik a cancellation téves értelmezése: a kérelem nem azonnali befejezés, és cancellation point nélküli ciklus nem feltétlenül reagál. Ellenőrzési feltétel: a leállításnak legyen dokumentált észlelési pontja, majd igazolt befejezése.

15.1.10 Rövid összefoglaló

A szálkezelés alapja az élettartam és a felelősség: az indító érvényes contextet ad, a worker betartja a hozzáférési szerződést, egy kijelölt szereplő pedig rendezi a befejezést. A join megvárja a szálat; a detach nem. A pthread függvények közvetlen hibakódot, a C szálkönyvtár saját státuszokat használ. A TLS szálanként külön objektumot ad, de nem automatikus erőforrás-cleanupot. A kiszámítható shutdown elsődleges eszköze a kooperatív leállítás és az igazolt utolsó hozzáférés.