34.1 thread, jthread és kooperatív cancellation

Ez az egység két dolgot választ szét, amit a gyakorlatban rendszeresen összemosnak: a szál mint erőforrás életciklusát (joinable, join, detach) és a szál mint munkavégző leállítását (stop kérés, cooperative cancellation). Egy DevOps szolgáltatás leállítása tipikusan így néz ki: SIGTERM érkezik, a listener abbahagyja az új kapcsolatok fogadását, a dolgozó szálak befejezik vagy eldobják a futó munkát, majd a folyamat kilép. Ha a kettő nincs külön kezelve, két rossz véglet közül választhatunk: vagy a std::terminate, vagy egy végtelen join. A C oldalon a Szál-életciklus, pthreads és C11 threads (15.1) részben a pthread_create/pthread_join pár mutatta meg a kézi lifecycle-t; a lambda capture élettartamáról Lambda, capture és closure lifetime (30.1) adott pontos szabályokat; a noexcept és hibapolicy határokról noexcept határok és hibapolicy (25.3) szólt. Ezekre építünk, de a C memóriamodellt nem írjuk újra.

Az itt leírt nyelvi szabályok az ISO C++20 szálfejezetére támaszkodnak. Ehhez az egységhez nem állt rendelkezésre ellenőrzött forrásjegyzet, ezért szakaszszámot és hivatkozást nem adok: ahol a viselkedés implementációra vagy platformra van bízva, azt külön jelölöm, és a saját toolchainben fordítási próbával ellenőrizendőnek mondom. A kódok nem tekintendők lefuttatottnak.

34.1.1 A szál mint joinable erőforrás

A std::thread objektum egy végrehajtási szál handle-je. Pontos definíció: az objektum joinable, ha olyan végrehajtási szálat azonosít, amelyet még nem joinoltunk és nem detacholtunk. A joinable() lekérdezés pontosan ezt adja vissza; default-konstruált, már joinolt, detacholt és elmozgatott (moved-from) objektumra hamis.

állapot joinable() destruktor viselkedése
default-konstruált false nincs hatás
futó, nem joinolt true std::terminate()
join() után false nincs hatás
detach() után false nincs hatás
moved-from false nincs hatás

A join() blokkol, amíg a szál befejeződik, majd az objektumot nem-joinable állapotba teszi; nem-joinable objektumon hívva std::system_error-t dob (a szabvány a önmagára váró joinra is hibát ír elő, de ezt nem minden implementáció képes felismerni — ezért ne építs rá). A detach() leválasztja a szálat: a végrehajtás folytatódik, de többé nem tudunk rá várni, és a szál erőforrásai a kilépéskor szabadulnak fel. A detach nem „tűz és felejtsd el” eszköz: ha a callable referenciát capture-öl, a hivatkozott objektum élettartama nincs semmihez kötve.

A destruktor terminate-szabálya tudatos tervezési döntés, nem véletlen. Két „csendes” alternatíva lenne: automatikus detach (ekkor a szál a háttérben tovább olvasná a már felszabadult objektumot, és a folyamat kilépésekor a main visszatérése után is futhatna), vagy automatikus join (ekkor egy destruktor blokkolna, akár kivétel-dobás közbeni stack unwinding során is, ahol a várakozás holtpontot okozhat). A terminate hangos, és arra kényszerít, hogy a lifecycle-t explicit módon kezeljük. A gyakorlati következmény: minden std::thread útját vagy join, vagy detach, vagy egy RAII wrapper zárja le — különösen a korai return és a kivételes ágak esetén.

34.1.2 std::jthread: automatikus join és beépített stop-állapot

A C++20 <thread> fejlécében megjelenő std::jthread ugyanazt a szálat reprezentálja, de két többlete van: saját stop-állapotot (stop_source) tart, és a destruktora nem hív terminate-t. Pontos szabály: ha a destruktor pillanatában az objektum joinable, előbb request_stop()-ot hív, majd join()-ol — ebben a sorrendben. A sorrend lényeges: a kérés előbb megy ki, így az együttműködő ciklusnak esélye van kilépni, és a join nem vár feleslegesen.

A jthread nem másolható, de mozgatható; mozgató értékadásnál a célobjektum, ha joinable volt, előbb stop kérést kap és joinol. A request_stop() a belső stop-forrásra delegál, és bool-t ad vissza: igaz, ha ez a hívás elsőként kérte a leállást. A get_stop_token() a szál stop-tokenjét adja vissza, a get_stop_source() egy ugyanarra az állapotra mutató forrást.

Egy fontos konstrukciós megkötés: a C++20 szerinti konstruktor csak akkor vesz részt a túlterhelés-feloldásban, ha a callable a stop_token első paraméterrel hívható, és a szál belépési pontja ezt a tokent meg is kapja. Ezért a tipikus alak [](std::stop_token st){ ... }; a paraméter nélküli lambda közvetlenül nem illeszkedik. Ez verzió- és implementációfüggő részletként fordítási próbával ellenőrizendő a saját GCC/Clang profilodon; a könyv laborjaiban ezt a próbát rögzíteni kell.

Az automatikus join nem csodaszer: ha a szál belépési pontja nem figyeli a tokent, a destruktor örökké blokkolhat. A jthread tehát a kérést és a várakozást teszi kényelmessé, de a leállást nem tudja kikényszeríteni.

34.1.3 A stop protokoll: stop_source, stop_token, stop_callback

A mechanizmus három szereplője: a stop_source birtokolja a közös stop-állapotot, a stop_token egy könnyű, másolható nézet erre az állapotra, a stop_callback pedig egy, a kérés pillanatában lefutó művelet. Az állapot addig él, amíg bármely source vagy token hivatkozik rá. A request_stop() visszafordíthatatlan: egyszer beállítva nem törölhető, ezért a stop-állapotot nem lehet „újraindítani” — egy szolgáltatás-újraindítás új objektumot igényel.

A stop_token default-konstruált példánya nem kapcsolódik állapothoz: stop_possible() és stop_requested() is hamis. A stop_possible() akkor igaz, ha van állapot, és vagy már kérték a leállást, vagy még létezik olyan source, amely kérheti. Ez a különbségtétel a várakozási döntéseknél hasznos: ha a stop nem is lehetséges, a tokent figyelmen kívül lehet hagyni.

A stop_callback konstruktora regisztrál; ha a stop már be volt kérve, a callback azonnal, a konstruáló szálon fut le. Egyébként a kérés pillanatában a request_stop()-ot hívó szálon fut. A destruktor megvárja a futó callback befejeződését. Ebből két gyakorlati szabály következik. Egyrészt a callback legyen rövid, ne blokkoljon, és ne vegyen fel olyan zárat, amelyet a kérést kiadó szál tart — különben holtpont. Másrészt ne joinoljunk callbackből arra a szálra, amelyen a callback futhat: ha a munkás szál saját maga hívja a request_stop()-ot, a callback ugyanazon a szálon fut, és az önjoin holtpont. A callbackből kivételt ne engedjünk ki; a callbacket eleve noexcept szellemben írjuk.

34.1.4 Kivétel a szál belépési pontján

Ha a szál belépési pontjából kivétel lép ki, a program std::terminate-t hív: a kivétel nem terjed át a joinoló szálra, és nem tárolódik sehol. A jthread ezen nem változtat. Az explicit átvitel mintája: a munkás szál try/catch(...)-ben elkapja a hibát, std::current_exception()-nel std::exception_ptr-t készít, eltárolja egy közös helyre, majd a joinoló szál std::rethrow_exception-nel újradobja.

Az átadás memóriaszempontból rendezett: a szál befejeződése synchronizes-with a sikeres join() visszatérése, így a join után a munkás szál írásai láthatók. Ezért elég a join után olvasni az exception_ptr-t; a join előtti olvasáshoz már szinkronizáció kellene. Ha a hibát nem tároljuk el, a hiba csendben elvész — ez a leggyakoribb „a szál elhalt, de senki nem tudja, miért” hiba.

34.1.5 Capture, élettartam és leállítási sorrend

A referencia-capture nem hosszabbítja meg a hivatkozott objektum élettartamát. A lokális változók megszűnési sorrendje a deklarációs sorrend fordítottja; ebből levezethető a szabály. Ha a szál-handle-t az általa használt adatok után deklaráljuk, a handle hamarabb semmisül meg, tehát a join még az adatok élettartamában megtörténik. Ha előbb deklaráljuk, az adatok hamarabb szűnnek meg, és a szál a joinig dangling referenciát használ — nem definiált viselkedés.

Egy szolgáltatásban a leállítás dokumentált protokoll legyen, például: (1) új munka elfogadásának leállítása; (2) request_stop(); (3) blokkolt várakozások felébresztése (CV notify, self-pipe írás, socket shutdown); (4) join vagy a jthread destruktora; (5) erőforrások felszabadítása; (6) eredmények és hibák jelentése. A sorrend nem ízlés kérdése: a (3) nélkül a (4) beragadhat, az (5) előtt pedig tilos az fd-t lezárni.

34.1.6 Blokkoló I/O: a stop kérés nem megszakítás

A stop_token egy jelző és callback-regiszter, nem megszakítási mechanizmus. Egy read(), recv() vagy accept() hívásban blokkolt szál nem nézi a tokent, amíg a rendszerhívás vissza nem tér; a request_stop() + join tehát végtelenül várhat. A megoldás platformfüggő, és nem oldható meg tisztán a C++ rétegben. Linuxon jellemző minták: nem blokkoló fd + poll/epoll, ahol egy eventfd vagy self-pipe írása ébreszt; socketnél a shutdown(fd, SHUT_RDWR) a stop-callbackből; vagy timeoutos várakozás. A másik szálból végzett close() nem garantáltan ébreszti fel a blokkolt olvasót, és fd-újrahasznosítási versenyt is okozhat, ezért az fd-t a joinig életben kell tartani. A callback itt csak áthidaló: benne ne allokáljunk, ne vegyünk zárat.

34.1.7 Kidolgozott példa: munkás szál vezérelt leállással

Az alábbi program egy munka-sor feldolgozója; a jthread deklarációja a sor után áll, így a destruktor a sor élettartamában joinol. A C++20-as condition_variable::wait(lock, stop_token, pred) overload belsőleg stop-callbacket regisztrál, ezért a request_stop() magától felébreszti a várakozót; visszatérési értéke hamis, ha a stop miatt tért vissza, és a predikátum hamis volt.

// worker.cpp — C++20
#include <chrono>
#include <condition_variable>
#include <cstdio>
#include <cstdlib>
#include <mutex>
#include <queue>
#include <stop_token>
#include <thread>

class JobQueue {
public:
    void push(int job) {
        {
            std::lock_guard<std::mutex> lk(m_);
            q_.push(job);
        }
        cv_.notify_one();
    }

    bool pop(int& out, std::stop_token st) {
        std::unique_lock<std::mutex> lk(m_);
        if (!cv_.wait(lk, st, [this] { return !q_.empty(); }))
            return false;               // stop kérve, a sor üres
        out = q_.front();
        q_.pop();
        return true;
    }

private:
    std::mutex m_;
    std::condition_variable_any cv_;   // a stop_tokenes wait csak ezen van
    std::queue<int> q_;
};

int main(int argc, char** argv) {
    const int jobs = argc > 1 ? std::atoi(argv[1]) : 5;   // 0: üres sor
    JobQueue q;                          // előbb az adat...
    std::jthread worker([&q](std::stop_token st) {   // ...aztán a handle
        int job = 0;
        while (q.pop(job, st))
            std::printf("job %d\n", job);
        std::printf("worker: stopped\n");
    });

    for (int i = 0; i < jobs; ++i) q.push(i);
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    worker.request_stop();               // kérés; a CV-wait felébred
}                                        // destruktor: request_stop + join
# Példa laborprofil; a konkrét verziót rögzítsd a laborjegyzőkönyvben
g++     -std=c++20 -Wall -Wextra -Wpedantic -pthread -O2 -o worker worker.cpp
clang++ -std=c++20 -Wall -Wextra -Wpedantic -pthread -O2 -o worker worker.cpp
# Adatverseny-keresés (nem találja meg a beragadást, csak a race-eket):
g++     -std=c++20 -g -O1 -fsanitize=thread -pthread -o worker_tsan worker.cpp
./worker          # 5 feladat
./worker 0        # üres sor

A stop_token-t fogadó wait overload csak a std::condition_variable_any-n létezik; a std::condition_variable-ön nincs, ezért a tag típusa condition_variable_any. A condition_variable-lel a hívás no matching function for call to 'std::condition_variable::wait(..., std::stop_token&, ...)' hibával nem fordul. Ha valamiért mégis condition_variable kell, külön std::stop_callback-alapú ébresztési protokollt kell írni, amely a stop kéréskor a mutex alatt jelez és notify_all-t hív.

Buildjegyzőkönyv (lab-gcc profil). A program figyelmeztetés nélkül fordult. Öt feladattal job 0 … job 4, majd worker: stopped kimenettel, üres sorral (./worker 0) csak worker: stopped kimenettel állt le; mindkét esetben a jthread destruktora bevárta a szálat. TSan (setarch -R) alatt mindkét változat figyelmeztetés nélkül futott. A Clang-sort nem ellenőriztük.

A példa leállítási szemantikája „drain”: ha a stop a sor kiürülése előtt érkezik, a predikátum igaz marad, és a szál még feldolgozza a maradékot. Ez tudatos választás; a „drop” változatban a ciklusfeltétel a stopot előbb ellenőrizné. A kettő közötti választást a protokollban dokumentálni kell.

34.1.8 Gyakori hibák és javításuk

34.1.9 Összefoglaló

A std::thread egy joinable erőforrás; a destruktora terminate-t hív, ha nem rendeztük a sorsát. A std::jthread ezt úgy oldja meg, hogy destruktorában előbb stopot kér, majd joinol, és a callable stop_token első paramétert kap. A stop protokoll (stop_source, stop_token, stop_callback) egy visszafordíthatatlan jelző, amelynek callbackje a kérést kiadó szálon fut — rövidnek, zárazár nélkülinek kell lennie. A stop kérés nem megszakítás: blokkoló I/O-t platformspecifikus ébresztéssel kell kiváltani. A capture-élettartamot és a leállítási sorrendet a deklarációs sorrend és egy dokumentált protokoll rögzíti; a szál belépési pontjából kivételt nem engedünk ki, hanem explicit módon átvisszük.

34.1.10 Feladatok

  1. Átalakítás. Alakítsd át a std::thread-es mintát std::jthread-té: a munkás ciklus stop_requested()-et figyel, a fő szál request_stop()-ot hív, a join a destruktorra marad. Indokold, miért nem kell külön join().
  2. Leállítási sorrend. Adott egy Stats objektum (std::vector + std::mutex) és egy std::jthread, amely referenciával capture-öli, és végtelen ciklusban írja. Nevezd meg a két hibát (élettartam és leállás), és javítsd mindkettőt.
  3. Blokkoló I/O. Egy szál blokkolt read()-ban egy pipe-on; a request_stop() után a join nem tér vissza. Tervezz kooperatív leállítást, és indokold, miért nem elég a token önmagában.

34.1.11 Megoldások

1. megoldás. A jthread destruktora joinable állapotban request_stop()-ot majd join()-t hív, ezért explicit join csak akkor kell, ha a joinolás pillanatát (például egy jelentés előtt) kézben akarjuk tartani. Ellenőrzési feltétel: a program a stop kérése után „stopped” üzenettel, terminate nélkül érjen véget; a ciklusfeltétel tartalmazza a st.stop_requested() ellenőrzést.

std::jthread t([](std::stop_token st) {
    while (!st.stop_requested()) { /* munka */ }
});
t.request_stop();

2. megoldás. Első hiba: a std::jthread a Stats előtt van deklarálva, ezért a Stats hamarabb szűnik meg, mint ahogy a join megtörténne — dangling. Második hiba: a ciklus nem figyeli a stopot, így a destruktori join végtelen. Javítás: Stats előbb, jthread utána, és a ciklus st.stop_requested()-et ellenőriz. Ellenőrzési feltétel: a munkás írásai a Stats élettartamán belül maradnak, és a program a stop után kilép; TSan-futtatás nem jelez adatversenyt a Stats mezőin.

3. megoldás. A pipe olvasó végére blokkolt read() nem figyeli a tokent. Megoldás: poll() a pipe olvasó végén és az adat-fd-n; egy std::stop_callback a stop pillanatában egy byte-ot ír a pipe író végére, amire a poll() visszatér, és a ciklus kilép. Az fd-eknek a joinig életben kell maradniuk, a callback nem allokál és nem vesz fel zárat. A close() másik szálból nem megbízható ébresztés, és fd-újrahasznosítási versenyt okozhat. Ellenőrzési feltétel: a join a stop után korlátos időn belül visszatér; a self-pipe írása nem blokkol (egy byte); watchdog-teszt igazolja a kilépést.