A taskhatáron átvitt eredmény látszólag egyszerű dolog: az egyik
szálon futó számításnak van egy visszatérési értéke vagy egy hibája, és
ezt egy másik szálon szeretnénk megkapni. A nehézséget nem az érték
átadása jelenti, hanem annak eldöntése, hogy ki birtokolja az átadás
csatornáját, mi történik, ha a fogadó fél már nem él, hogyan kerül át a
kivétel, és mikor szabad felszabadítani az állapotot. A
std::future család pontosan ezt a szerződést modellezi: egy
különálló, referenciaszámlált shared state (megosztott
állapot) hordozza az eredményt vagy a hibát, az aszinkron szolgáltató
(provider) pedig egy fogantyún keresztül teszi elérhetővé. Ebben az
alfejezetben a C++20 alapprofil (-std=c++20) szerinti
szemantikát tárgyaljuk; a pontos szabványparagrafusokat a
[futures.*] szakaszokban kell ellenőrizni a saját
szabványpéldányodban, mert ehhez az egységhez nem kaptam olyan
forrásjegyzetet, amelyre kattintható hivatkozást adhatnék. Az itt leírt
viselkedés tehát a nyelvi/library szerződés leírása, nem mért vagy
forrásellenőrzött állítás; a konkrét libc++/libstdc++ kiadást a
laborprofilban rögzítsd.
A shared state egy meg nem nevezett, dinamikusan
létrehozott, referenciaszámlált állapotobjektum. Tartalmazza egyfelől az
eredményt (vagy egy std::exception_ptr-t), másfelől egy
„ready” jelzőt és a várakozáshoz szükséges szinkronizációt. Nem része a
std::future objektumnak: a future csak egy fogantyú, amely
hivatkozik rá. A shared state-et mindig egy aszinkron
szolgáltató hozza létre — std::promise,
std::packaged_task, vagy std::async —, és a
hozzá tartozó aszinkron visszatérési objektum
(std::future vagy std::shared_future) olvassa.
A shared state élettartama addig tart, amíg az összes rá hivatkozó
szolgáltató és fogantyú el nem tűnik; ez a referenciaszámlálás az, ami
miatt a std::promise-t nyugodtan át lehet mozgatni egy
munkaszálra, és ott helyben megsemmisíteni.
A std::future<T>-nek három, egymástól függetlennek
tűnő, de összefüggő állapotjellemzője van. A valid azt
jelenti, hogy a future ténylegesen hivatkozik egy shared state-re. Az
alapértelmezett konstruktorral létrehozott future invalid; a
std::move forrása invalid lesz; és a get()
sikeres lefutása után a future invalidálódik, mert az eredményt
„kiadtuk” belőle. Invalid future-ön hívott get(),
wait() vagy wait_for()
std::future_error-t dob
std::future_errc::no_state hibakóddal — ez nem UB, hanem
szerződésszegés-jelzés. A ready azt jelenti, hogy az
eredmény vagy a hiba be van állítva, tehát a get() azonnal
visszatér. A deferred csak a std::async
által létrehozott future-öknél fordul elő, és azt jelenti, hogy a
feladat még el sem indult, hanem a
get()/wait() hívására fog lefutni a hívó
szálon.
A std::shared_future<T> ugyanarra a shared
state-re hivatkozik, de más az olvasási szerződése: másolható,
get()-je többször hívható, és nem invalidálja a fogantyút.
std::shared_future<T>::get()
const T&-t ad vissza (ha a tárolt típus
T&, akkor T&-t), így a több olvasó nem
másolja az eredményt. A std::future<T>::share()
átadja a shared state-et egy shared_future-be, és az
eredeti future invalidálódik. Fontos korlát: a
shared_future önmagában nem tesz semmit „automatikusan”
szálbiztossá az eredmény típusán belül — a get()
által visszaadott objektumot továbbra is a szokásos szabályok szerint
kell kezelni, ha több szál egyszerre írná.
A std::promise<T> a shared state írási oldala. A
get_future() egyszer hívható meg (második hívásra
future_already_retrieved), és a
set_value/set_exception páros közül pontosan
az egyik lehet sikeres: a második kísérlet
promise_already_satisfied hibát dob. A
set_value_at_thread_exit és a
set_exception_at_thread_exit változatok nem azonnal, hanem
a szál futásának befejeződésekor teszik ready-vé az állapotot — ez akkor
hasznos, ha az eredmény beállítása után még thread_local
destruktorok futnak, és azokat a fogyasztónak meg kell várnia.
A legsúlyosabb eset, amit mindenképp ismerni kell: ha a
std::promise úgy semmisül meg, hogy egyik beállító
függvényt sem hívtuk meg, a shared state a
std::future_errc::broken_promise hibával lesz ready, és a
fogyasztó get()-je std::future_error-t dob. Ez
a viselkedés szándékos: a fogyasztó soha nem „akad be” csendesen, hanem
megtudja, hogy a termelő eltűnt. Ugyanez igaz a
std::packaged_task-re is, ha meghívás nélkül semmisül meg.
Egy feladatokat tároló sor leállításakor ez a tulajdonság különösen
értékes: a várakozó hívók hibát kapnak, nem holtpontot.
A get() három dolgot tesz egyetlen hívásban: megvárja a
ready állapotot, visszaadja a tárolt értéket, és érvényteleníti a
future-öt. Ha a shared state hibát tartalmaz, a get() a
tárolt exception_ptr-t dobja újra. A kivétel eredeti típusa
megmarad, tehát a hívó oldalon a konkrét típusra illeszkedő
catch ág fut le — ez az, ami a taskhatáron átvihető
hibakezelést lehetővé teszi.
Itt érdemes megérteni, mi történik a munkaszálon. A
std::packaged_task, illetve a std::async belső
implementációja a hívott callable-t
try/catch(...) blokkban futtatja, és
catch esetén std::current_exception()-t tárol
el. A stack unwinding tehát a munkaszálon lezajlik és
befejeződik — a throw, catch és stack unwinding
szabályai szerint —, a határon csak egy std::exception_ptr
kel át. Ez magyarázza, hogy a get() nem folytatja a
munkaszál vermét, hanem a hívó szálon dobja újra a kivételt, a
saját vermén.
A wait() csak blokkol, értéket nem ad és nem invalidál;
a wait_for(d)/wait_until(tp)
std::future_status::ready, timeout vagy
deferred értékkel tér vissza. A deferred
státusz megkülönböztetése azért fontos, mert ilyenkor a
wait_for nem futtatja le a feladatot, csak jelzi, hogy a
feladat nem indult el.
A std::packaged_task<R(Args...)> egy callable-t és
egy shared state-et kapcsol össze: a get_future() átadja az
olvasási oldalt, a operator() pedig meghívja a tárolt
callable-t, és az eredményt (vagy a kivételt) beállítja. A
packaged_task a C++20 alapprofilban
move-only, nem másolható; ez tudatos tervezési döntés,
mert egy „futtatható feladat” csak egyszer futhat le, és a másolás ezt a
garanciát lazítaná el.
Ennek közvetlen következménye van a korábbi fejezetre épülve: a
std::function csak másolható callable-t képes tárolni,
ezért packaged_task-et közvetlenül nem lehet
std::function<void()>-be zárni — lásd az invoke, function wrapper és type erasure
tárgyalását. Egy feladatsor tipikus eleme ezért
std::queue<std::packaged_task<void()>> vagy
std::vector<std::move_only_function<void()>>
(utóbbi C++23), és a munkaszál a queue-ból std::move-val
veszi ki a feladatot. A queue-tulajdonos a packaged_task-et
élettartama alatt birtokolja; a hívó csak a std::future-t
tartja meg, tehát az eredmény fogantyúja és a futtató objektum
tulajdonosa szétválik. A packaged_task::operator()
másodszori hívása promise_already_satisfied hibát dob.
A std::async(policy, f, args...) kényelmi szolgáltató:
létrehozza a shared state-et, elindítja vagy ütemezni hagyja a
callable-t, és visszaadja a future-t. A policy két bitje a
std::launch::async és a
std::launch::deferred.
std::launch::async esetén a feladat egy új
végrehajtási szálon fut (a szabvány „as if in a new thread”
megfogalmazása szerint), és a visszatérés garantáltan aszinkron. Ha a
szál létrehozása nem sikerül, a hívás std::system_error-t
dob resource_unavailable_try_again hibakóddal — ez a
gyakorlatban a szál-erőforrás kimerülését jelzi, például nagy számú
egyidejű async hívásnál.
std::launch::deferred esetén a feladat nem indul
el: a get()/wait() hívásakor fut le,
a hívó szálon, szinkron módon. A
wait_for/wait_until ilyenkor
deferred státusszal tér vissza anélkül, hogy lefuttatná a
feladatot. Ez a viselkedés hasznos lusta kiértékeléshez, de csapda: ha a
future-t get() nélkül eldobod, a feladat soha nem
fut le, és erről semmilyen jelzést nem kapsz.
A policy nélküli std::async(f, args...) overload a
launch::async | launch::deferred kombinációt jelenti,
vagyis az implementáció dönt, hogy tényleg szálat
indít-e vagy lustán futtat. Ez nem specifikált választás, tehát olyan
kódban, ahol a párhuzamosság megléte vagy hiánya számít, ezt az
overloadot ne használd: a viselkedés implementációnként és verzióként
eltérhet, és a program helyessége nem épülhet rá.
A szabály valójában nem a std::future típusról, hanem a
shared state elengedéséről szól ([C++20]
[futures.state], [futures.async]): egy shared state elengedése nem vár
annak elkészültére, kivéve, ha (1) a shared state-et
std::async hozta létre, (2) launch::async
policyval ténylegesen futó feladattal, (3) ez az utolsó hivatkozás rá,
és (4) az állapot még nem ready. Ekkor az elengedés — a gyakorlatban a
destruktor — blokkol, amíg a shared state ready nem
lesz. Mindegy, hogy az utolsó hivatkozást egy
std::future vagy egy std::shared_future
tartja. A cél az, hogy a hívó vermén lévő objektumokra mutató
referenciák ne váljanak danglinggé, és hogy a „tűzd ki és felejtsd el”
használat ne hozzon létre csendben hátrahagyott, senki által nem várt
szálakat.
Ez a szabály nem érvényes a std::promise-hez vagy
std::packaged_task-hez tartozó future destruktorára: azok
nem blokkolnak. Nem érvényes továbbá a deferred esetre sem:
a destruktor nem blokkol, és ha a feladatot még nem hívták meg, soha nem
is fog lefutni. A shared_future-re ugyanez a szabály
vonatkozik, csak a helye változik: a share() után a shared
state-nek több tulajdonosa lehet, és mindig az utolsó
elengedő shared_future destruktora a potenciális várakozási
pont — akárhol is él az a másolat (egy másik objektum tagjaként, egy
másik szálon). A share() tehát nem szünteti meg a
blokkolást, hanem nehezebben látható helyre viszi.
// shared_future_block.cpp — C++20: az async shared state utolsó elengedése blokkolhat
#include <chrono>
#include <cstdio>
#include <future>
#include <thread>
using namespace std::chrono;
int main() {
std::shared_future<int> holder;
{
auto f = std::async(std::launch::async, [] {
std::this_thread::sleep_for(300ms);
return 7;
});
holder = f.share(); // f üres lesz, holder az egyetlen tulajdonos
} // itt nincs blokkolás: holder még él
auto t0 = steady_clock::now();
{
std::shared_future<int> last = std::move(holder);
} // az utolsó shared_future elengedése: vár
auto ms = duration_cast<milliseconds>(steady_clock::now() - t0).count();
std::printf("utolso shared_future elengedese: %lld ms\n", static_cast<long long>(ms));
}Buildjegyzőkönyv (lab-gcc profil).
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -pthread shared_future_block.cpp
kimenete utolso shared_future elengedese: 300 ms: a
share() utáni eredeti future megszűnése nem várt, az utolsó
shared_future elengedése viszont kivárta a feladat
végét.
A szabály legismertebb következménye: a
std::async(std::launch::async, f); utasítás, amely eldobja
a visszaadott ideiglenes future-t, a teljes kifejezés végén blokkol,
tehát gyakorlatilag szinkron hívássá alakul. Ugyanez ciklusban
katasztrofális: a „párhuzamos” ciklus sorban futó ciklussá válik,
észrevétlenül.
A C++20 és C++23 standard library nem tartalmaz szálmedencét
és általános executort. A std::async nem pool:
launch::async esetén hívásonként egy szálat indít, ami nagy
terhelésnél szál-erőforrás-kimerüléshez és túlparhuzamosításhoz
(oversubscription) vezet. A launch::deferred sem pool, mert
nem tart fenn munkásokat, csak elhalasztja a végrehajtást a hívó szálra.
A std::thread::hardware_concurrency() csak tippet ad a
hardveres szálszámról, és nullát is visszaadhat, tehát önmagában nem
konfiguráció.
A gyakorlati megoldás ezért saját medence: fix számú munkaszál,
std::mutex + std::condition_variable (lásd RAII lockok, condition variable és wait predicate)
és std::packaged_task-et tároló queue, ahol a leállítás
sorrendje a queue lezárása, a munkások felébresztése, majd join. A C++26
irányai között szerepel a szabványos execution modell
(std::execution, P2300), de ennek elfogadási és
implementálási állapotát itt nem ellenőriztem; a C++20/23 alapprofilban
nem elérhető. A shared_ptr-ral való megosztott tulajdonlás
konkurens kérdéseit szándékosan nem itt tárgyaljuk, hanem a shared ownership, reclamation és konkurens review
részben.
Az alábbi táblázat a négy mechanizmus szerződését foglalja össze.
| Mechanizmus | Ki hozza létre a shared state-et? | Ki állítja be az eredményt? | Hol fut a feladat? | A future destruktora blokkol? |
|---|---|---|---|---|
std::promise |
a promise | a felhasználó (set_value) |
a felhasználó dönti el | nem |
std::packaged_task |
a packaged_task | operator() |
aki meghívja | nem |
std::async(launch::async, …) |
az async |
a callable | új szálon | igen, ha nem ready és ez az utolsó referencia |
std::async(launch::deferred, …) |
az async |
a callable | a get()/wait() hívó szálán |
nem; ha nem hívták meg, nem fut le |
A példa három esetet mutat ugyanabban a fordítási egységben: explicit
promise-t, packaged_task-et move-only
callable-lel, valamint async-et mindkét policy-vel. A cél
annak megfigyelése, hogy a kivétel mindhárom esetben a
get() hívás helyén, a hívó szálon jelenik meg.
// task_result.cpp -- oktatasi pelda, C++20, Linux, -pthread
#include <chrono>
#include <exception>
#include <future>
#include <iostream>
#include <stdexcept>
#include <string>
#include <thread>
#include <utility>
namespace demo {
int parse_parallelism(const std::string& s) {
if (s.empty()) throw std::invalid_argument("ures parallelizmus");
return std::stoi(s); // dobhat std::invalid_argument / std::out_of_range
}
// (1) promise: a dolgozo szal explicit eredmeny- es hibaatadassal
void worker(std::promise<std::string> p) {
try {
p.set_value(std::string{"kesz"});
} catch (...) {
p.set_exception(std::current_exception());
}
}
// (2) packaged_task: move-only callable
struct Divider {
int operator()(int a, int b) const {
if (b == 0) throw std::domain_error("osztas nullaval");
return a / b;
}
};
} // namespace demo
int main() {
using namespace std::chrono_literals;
{ // (1) promise + future
std::promise<std::string> p;
std::future<std::string> f = p.get_future();
std::thread t{demo::worker, std::move(p)};
std::cout << "promise: " << f.get() << '\n';
t.join();
}
{ // (2) packaged_task: a hivas hozza letre az eredmenyt
std::packaged_task<int(int, int)> task{demo::Divider{}};
std::future<int> f = task.get_future();
std::thread t{std::move(task), 7, 0};
try {
std::cout << "packaged_task: " << f.get() << '\n';
} catch (const std::domain_error& e) {
std::cout << "packaged_task hiba: " << e.what() << '\n';
}
t.join();
}
{ // (3) async: async es deferred policy
std::future<int> fa = std::async(std::launch::async,
demo::parse_parallelism,
std::string{"4"});
std::future<int> fd = std::async(std::launch::deferred,
demo::parse_parallelism,
std::string{""});
auto st = fd.wait_for(0ms);
std::cout << "deferred statusz: "
<< (st == std::future_status::deferred ? "deferred" : "nem deferred")
<< '\n';
std::cout << "async: " << fa.get() << '\n';
try {
std::cout << "deferred: " << fd.get() << '\n';
} catch (const std::invalid_argument& e) {
std::cout << "deferred hiba: " << e.what() << '\n';
}
}
}Fordítás és futtatás a tanuló saját laborjában:
g++ -std=c++20 -O2 -g -Wall -Wextra -pthread task_result.cpp -o task_result
./task_resultA példa nincs lefuttatva a szöveg írásakor, ezért
nem állítom, hogy tesztelt. A várt kimenet (nem mért eredmény)
sorrendben: promise: kesz,
packaged_task hiba: osztas nullaval,
deferred statusz: deferred, async: 4,
deferred hiba: ures parallelizmus. A harmadik blokkban a
fd future a wait_for pillanatában még nem
futtatta le a feladatot, ezért kapunk deferred státuszt; a
get() ekkor futtatja le a hívó szálon, ahol a
std::invalid_argument keletkezik és azonnal tovább is
dobódik. Ha ezt a blokkot -fsanitize=thread kapcsolóval
futtatod, a példa adatversenyt nem tartalmaz, mert minden shared
state-et a future/promise szinkronizál — de ez a sanitizer jelenléte
miatt még nem bizonyíték, csak egy ellenőrzési lehetőség.
Az első és leggyakoribb hiba az std::async eredményének
eldobása. A std::async(std::launch::async, f); látszólag
aszinkron, de a destruktor blokkolása miatt szinkronná válik; javítás:
mindig tárold a future-t, és a blokkolást a
get()/wait() helyére tedd. A második a
launch::deferred future get() nélküli
eldobása: a feladat csendben elmarad, ami hiányzó oldalhatás formájában
jelentkezik — ilyenkor vagy mindig hívd meg a get()-et,
vagy ne használj deferred-et. A harmadik a
get() kétszeri hívása: a második no_state
future_error-t dob, mert az első hívás invalidálta a
future-öt; ha többszöri olvasás kell, használj
shared_future-t. A negyedik a csupasz
std::async(f, ...) policy nélküli hívás produkciós kódban:
a párhuzamosság megléte implementációfüggő, ezért determinisztikus
viselkedést ne építs rá. Az ötödik, hogy a packaged_task-et
std::function-be próbálják tenni: az nem fordul le, mert a
packaged_task nem másolható; a megoldás move-only tároló
vagy std::move-val átadott queue-elem.
1. feladat (magyarázó). Egy kollégád ezt írja:
„std::async(std::launch::async, []{ /* hosszu munka */ });
— így tűzd ki a munkát, és ne várj rá.” Magyarázd el pontosan, miért nem
aszinkron ez a kód, és melyik szabványbeli feltétel teljesül itt a
future destruktor blokkolásához.
2. feladat (kódolási). Írj
std::vector<int> wait_all(std::vector<std::future<int>>& fs)
függvényt, amely minden future-ből begyűjti az eredményt, és az első
kivételt a hívó felé továbbítja, de csak azután, hogy minden future-t
megvárt. Indokold a sorrendet.
3. feladat (diagnosztikai). Az alábbi kód lefut, de
a mentes() soha nem hajtódik végre. Add meg a pontos okot,
a javítást és az ellenőrzési feltételt.
std::async(std::launch::deferred, []{ mentes(); });4. feladat (tervezési). Egészítsd ki a vázlatot egy
olyan pontig, ahol a packaged_task-et move-only queue-ba
tesszük, a future-t a hívó kapja meg, és a medence leállításakor a
függőban lévő feladatok broken_promise hibát okoznak a
hívóknál a holtpont helyett. Melyik objektum birtokolja a
packaged_task-et az egyes fázisokban?
1. megoldás. Az ideiglenes std::future
a teljes kifejezés végén megsemmisül. Mivel az async
launch::async policy-vel indult, a shared state létrehozója
az async, a feladat még nem ready, és ez a future az utolsó
referencia — mind a négy feltétel teljesül, ezért a destruktor blokkol a
ready állapotig. A kód tehát szinkron: a „kitűzés” helyén megvárja a
munkát. Javítás: auto f = std::async(...); és a várakozás
később, explicit f.get()/f.wait() formában.
Ellenőrzési feltétel: a munkába tett
std::this_thread::sleep_for után mérd a hívó szál eltelt
idejét — aszinkron esetben rövid, a hibás változatban a munka idejével
nő (a mérés a te labodban történik, nem itt).
2. megoldás. A helyes sorrend: először minden
future-ön wait(), aztán get(). Ha azonnal
get()-elnénk és az első kivételt eldobnánk, a többi future
destruktora (async esetén) blokkolna, vagy ami rosszabb, a többi feladat
eredményét elveszítenénk, és a kivétel átvitele közben további kivételek
keletkezhetnének. A wait() nem dob és nem invalidál, ezért
utána a get()-ek már nem blokkolnak. A kivételt a ciklus
után, egy std::exception_ptr-ben tárolva kell újradobni,
hogy a „mindenki fusson le” garancia megmaradjon. Ellenőrzési feltétel:
a függvény akkor is visszatér, ha a második future dob — a korábbi és
későbbi future-ök is ready állapotba kerültek.
3. megoldás. A launch::deferred azt
jelenti, hogy a callable csak get()/wait()
hívására fut le; az eldobott ideiglenes future destruktora nem blokkol
és nem futtat. Javítás: vagy tárold a future-t és hívd meg a
get()-et, vagy használj launch::async-et.
Ellenőrzési feltétel: a mentes() oldalhatása (pl. fájl
létrejötte) csak akkor figyelhető meg, ha a get() lefutott;
ezt a fájl létezésével lehet ellenőrizni, nem a program futási
idejével.
4. megoldás. A packaged_task
életciklusa három fázisra bontható: (a) a hívó létrehozza,
get_future()-rel kiveszi a future-t, majd
std::move-val a queue-ba teszi — innentől a queue a
tulajdonos; (b) a munkaszál std::move-val kiveszi a
queue-ból, és a saját vermén futtatja — a futtatás idejére a munkaszál a
tulajdonos; (c) a futtatás után a packaged_task
megsemmisül, a shared state ready lesz, és a hívó get()-je
visszatér. Leállításkor a queue-t le kell zárni, a munkásokat
felébreszteni és joinolni; a queue-ban maradt
packaged_task-ek megsemmisülése
broken_promise-t állít be, így a hívók hibát kapnak.
Ellenőrzési feltétel: leállítás után egyetlen hívó get()-je
sem akad be határozatlan ideig, és a join minden
munkaszálra lefut.
A std::future család egy referenciaszámlált shared state
köré szerveződik, amely az értéket vagy a
std::exception_ptr-t hordozza, és amelyet mindig egy
szolgáltató — promise, packaged_task vagy
async — hoz létre. A get() megvárja a ready
állapotot, kiemeli az eredményt vagy újradobja a kivételt a hívó szálon,
majd invalidálja a future-öt; a shared_future többszöri,
nem invalidáló olvasást tesz lehetővé. A packaged_task
move-only, ezért std::function-be nem tehető, viszont
move-only queue-val jól illeszkedik egy saját szálmedence
feladatmodelljéhez. Az std::async két policy-je közül a
launch::async garantáltan új szálon fut és szál-létrehozási
hibánál std::system_error-t dob, a
launch::deferred pedig csak
get()/wait() hívásra, a hívó szálon fut le. A
future destruktor blokkolása kizárólag a launch::async-kel
indított, még nem ready, utolsó referenciát tartó
async-future-re érvényes — ez a szabály teszi a látszólag
aszinkron, eldobott future-t szinkronná, és ezért kell minden
async future-öt tárolni és explicit módon megvárni. A
C++20/23 alapprofilban nincs szabványos thread pool és executor, ezért a
medence, a queue és a leállítási sorrend továbbra is a program
szerzőjének felelőssége.