34.4 promise, future, async és task eredmény

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.

34.4.1 A shared state szerződése

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.

34.4.1.1 A future állapotai: valid, ready, deferred

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á.

34.4.2 A promise mint írási végpont

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.

34.4.3 get, wait és az eredmény átvitele a taskhatáron

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.

34.4.4 A packaged_task és a move-only callable

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.

34.4.5 Az std::async és a launch policy

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á.

34.4.6 Mikor blokkol a future destruktora?

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.

34.4.7 A hiányzó thread pool és executor

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

34.4.8 Kidolgozott példa: eredmény és hiba átvitele három mechanizmussal

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_result

A 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.

34.4.9 Tipikus hibák és javításuk

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.

34.4.10 Feladatok

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?

34.4.11 Megoldások

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.

34.4.12 Összefoglaló

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.