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.
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.
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.
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.
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.
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.
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.
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 sorA 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.
jthread a használt adat előtt
deklarálva. A destruktori join a már megszűnt objektumra fut.
Javítás: az adat deklarációja előbb, a szál-handle utána.request_stop() blokkoló I/O-ra várva.
A join beragad; a stop csak jelző. Javítás: ébresztő mechanizmus
(self-pipe, eventfd, shutdown), vagy nem blokkoló I/O.request_stop()-ot a munkás szál hívja, a callback ugyanott
fut, a join önmagára vár. Javítás: a stop kérését kívülről indítjuk, a
join a jthread destruktorára bízzuk.std::terminate, illetve elveszett hiba. Javítás:
exception_ptr + rethrow_exception.detach() referencia-capture-rel.
Dangling. Javítás: join, vagy érték szerinti capture, vagy
shared_ptr — utóbbinál azonban a pointee thread safetyje
nem automatikus, ezt itt nem tárgyaljuk.std::system_error. Javítás: joinable()
ellenőrzés vagy RAII.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.
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().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.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.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.