35.2 promise_type, awaiter és coroutine_handle

A coroutine nem „mágikus aszinkronitás”, hanem egy nyelvi szinten előállított állapotgép, amelynek a fordítási szabályai pontosan rögzítik, ki mikor kap vezérlést, hol tárolódik az állapot, és ki felelős a frame felszabadításáért. Ez az alfejezet egy minimális oktatási Generator és Task típus teljes lifecycle-ját követi végig. A cél nem egy éles framework, hanem annak megértése, hogy a promise_type, az awaiter és a coroutine_handle milyen szerződést alkot, és ebben ki a frame tulajdonosa. A leírás a C++20 nyelvi mechanizmusára támaszkodik; a konkrét szabványparagrafust, a compiler és a standard library verzióját a laborprofilban rögzített eszközökkel kell ellenőrizni. A rész nem tárgyal schedulert, hálózati runtimet és teljes async I/O implementációt.

35.2.1 A coroutine protokoll mint szerződés

Coroutinenak azt a függvényt nevezzük, amelynek a teste tartalmaz legalább egy co_await, co_yield vagy co_return kifejezést. A függvény visszatérési típusa a return object típusa; ezt a típust a compiler a std::coroutine_traits<R, Args...>::promise_type révén kapcsolja össze a promise típussal, amely alapértelmezésben R::promise_type. A promise objektum a coroutine frame-en belül jön létre, és a coroutine teljes élettartama alatt ott él. A return object a hívóhoz kerül, és tipikusan egy coroutine_handle-t tárol.

A szerződés kötelező elemei: a promise_type-nak rendelkeznie kell get_return_object, initial_suspend, final_suspend és unhandled_exception tagfüggvénnyel, valamint a co_return formájától függően return_void vagy return_value függvénnyel. A co_yield a yield_value-re képződik le. A co_await egy awaitable-en az await_ready, await_suspend, await_resume hármast hívja, vagy ha az awaitable rendelkezik operator co_await-tal, annak eredményén keresztül. A részletek implementációfüggő optimalizációktól függhetnek, de a hívási sorrend nyelvi szabály.

35.2.2 A hívási sorrend levezetése

Legyen a hívás auto x = f(args);, ahol f coroutine. A fordítás eredménye egy olyan függvény, amely a következő lépéseket végzi:

  1. Létrehozza a frame-et: lefoglalja a tárolót a promise-nak, a paraméterek másolatának, a lokálisoknak és a felfüggesztési pontok állapotának.
  2. Lefuttatja a promise_type megfelelő konstruktorát; ha van olyan konstruktor, amely a coroutine paramétereit fogadja, azt, egyébként a default konstruktort.
  3. A frame allokációjához a promise_type operator new-ját használja, ha létezik; különben a globális ::operator new-t. Allokációs hiba esetén a get_return_object_on_allocation_failure hívható, ha definiált.
  4. Meghívja a promise.get_return_object()-ot. Az eredmény egy ideiglenes objektum, amely a coroutine első felfüggesztésekor kerül a hívóhoz; addig a frame-ben tárolódik.
  5. Kiértékeli a co_await promise.initial_suspend() kifejezést. Ha az awaiter await_ready()-je igaz, a coroutine nem függesztődik fel, és azonnal fut tovább. Ha hamis, a coroutine felfüggesztődik, és a hívó megkapja a return objectet.
  6. Fut a coroutine teste. Minden co_await-nél lefut az awaiter protokoll; minden co_yield a yield_value-t hívja, majd felfüggeszt.
  7. co_return esetén a return_value vagy return_void fut, majd a frame-ben lévő lokálisok destruktorai, végül a co_await promise.final_suspend().
  8. Ha a final_suspend awaiter await_ready()-je igaz, a frame automatikusan felszabadul, és a handle érvénytelenné válik. Ha hamis, a coroutine a végpontján felfüggesztve marad, a frame életben van.
  9. Ha a testben kivétel keletkezik, a lokálisok destruktorai után a promise.unhandled_exception() fut, majd a vezérlés a final_suspend-hez kerül, mintha co_return történt volna.

Ez a sorrend magyarázza, miért fontos az initial_suspend és a final_suspend választása: az előbbi azt dönti el, hogy a coroutine a hívás után azonnal fut-e, az utóbbi pedig azt, hogy a frame automatikusan felszabadul-e, vagy a return object kezében marad.

35.2.3 promise_type és a return object

A promise_type a coroutine „belső objektuma”: a get_return_object() állítja elő a hívó által látott objektumot. Egy minimális generatorban ez a következő:

struct promise_type {
    std::optional<T> current;
    Generator get_return_object() {
        return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
    }
    // ...
};

A from_promise(*this) egy coroutine_handle<promise_type>-ot ad, amely a frame-re mutat. A return object ezt a handle-t tárolja. A return object élettartama a hívóé: ha a coroutine az első felfüggesztés előtt kivételt dob, a return object destruktora fut, és a frame felszabadul. A promise-ban tárolt adatok (például a current) a frame részei, ezért azok élettartama a frame élettartamához kötött.

A return objectnek tisztáznia kell a tulajdonjogot. Ha másolható, és minden példány a destruktorában destroy()-t hív, kettős felszabadítás lesz. A helyes minta a move-only return object: a move constructor std::exchange-dzsel nullázza a forrás handle-t, a destruktor csak nem nulla handle esetén hív destroy()-t.

35.2.4 initial_suspend és final_suspend

Az initial_suspend() egy awaitable-t ad vissza. A std::suspend_always esetén a coroutine a hívás után azonnal felfüggesztődik, és a return object a hívóhoz kerül; ez a pull-alapú generator tipikus választása. A std::suspend_never esetén a coroutine azonnal fut az első co_await-ig vagy a végéig; ez az eager task jellemzője.

A final_suspend() hasonlóan awaitable-t ad. Ha suspend_always, a coroutine a végpontján felfüggesztve marad, a frame életben van, és a return object destruktora szabadítja fel. Ha suspend_never, a frame a coroutine végén automatikusan felszabadul, és a handle érvénytelenné válik; ilyenkor a return objectnek nem szabad destroy()-t hívnia. A két választás nem keverhető össze: a done() igaz lehet úgy is, hogy a frame már nem létezik.

35.2.5 Az awaiter három kérdése

A co_await expr kiértékelése az awaiteren három kérdést tesz fel. Az await_ready() eldönti, hogy szükség van-e felfüggesztésre; ha igaz, a coroutine nem függesztődik fel, és rögtön az await_resume() következik. Ha hamis, az await_suspend(handle) fut. Ennek visszatérési típusa lehet void (felfüggesztés), bool (igaz: felfüggesztés, hamis: folytatás), vagy egy coroutine_handle (a megadott coroutine folytatódik, a jelenlegi felfüggesztve marad). Végül az await_resume() adja a co_await kifejezés értékét; ez dobhat is.

A std::suspend_always mindhárom függvénye triviális: await_ready() hamis, await_suspend() üres, await_resume() üres. A std::suspend_never await_ready()-je igaz. Egy saját awaiterrel láthatóvá tehető a sorrend:

struct LoggingAwaiter {
    const char* name;
    bool await_ready() const noexcept {
        std::printf("ready: %s\n", name);
        return false;
    }
    void await_suspend(std::coroutine_handle<>) const noexcept {
        std::printf("suspend: %s\n", name);
    }
    void await_resume() const noexcept {
        std::printf("resume: %s\n", name);
    }
};

A co_await LoggingAwaiter{"pont"} először ready, majd suspend üzenetet ír, felfüggeszt, és a következő resume() után resume üzenetet. A coroutine_handle<> típusú paraméter elfogadja a típusos handle-t is, mert van konverzió.

35.2.6 return_value, return_void és unhandled_exception

A co_return; a return_void-ot hívja, a co_return x; a return_value(x)-et. A kettő kölcsönösen kizárja egymást: ha mindkettő definiált, a program hibás; ha a co_return formájához nem tartozik megfelelő függvény, szintén hiba. A return_value általában a promise-ba mozgatja az értéket, a return_void pedig lezárja a coroutine-t.

Az unhandled_exception akkor fut, ha a coroutine testében kivétel keletkezik. Két alapstratégia van: std::terminate() (a kivétel nem hagyja el a coroutine-t), vagy std::current_exception() eltárolása a promise-ban, hogy a return object később újradobja. Utóbbi esetén a return objectnek dokumentálnia kell, mikor és hogyan lehet a hibát kiolvasni. A kivétel útja mindkét esetben a final_suspend-hez vezet, a lokálisok destruktorai után.

35.2.7 coroutine_handle: resume, done, destroy

A std::coroutine_handle<P> egy olcsó, pointer méretű fogantyú a frame-re. A resume() folytatja a felfüggesztett coroutine-t; a done() igaz, ha a coroutine elérte a végpontját (a final_suspend felfüggesztése után); a destroy() futtatja a frame destruktorait, majd felszabadítja a tárolót. A destroy() csak felfüggesztett coroutine-on hívható: ha a coroutine éppen fut, a viselkedés nem definiált. Ha a final_suspend suspend_never volt, a frame már felszabadult, és a handle érvénytelen; a destroy() ilyenkor szintén nem definiált.

A handle nem tulajdonos önmagában. A resume() és a destroy() felelőssége a return objecté, amely a handle-t tárolja. A handle másolása nem ad tulajdonjogot; a from_promise és a promise() a frame-re mutató kapcsolatot biztosítja, de nem rendelkezik az élettartamról.

35.2.8 Allokáció, deallokáció és a frame tulajdonosa

A frame allokációja a promise_type operator new-jával történik, ha van ilyen; a felszabadítás a operator delete-tel. A operator new megkaphatja a coroutine paramétereit is, ami lehetővé teszi például egy arena használatát. Allokációs hiba esetén a get_return_object_on_allocation_failure hívható, ha a promise_type definiálja; ellenkező esetben a viselkedés olyan, mintha az allokáció kivételt dobott volna.

A frame mérete és elrendezése nem hordozható: függ a promise típusától, a paraméterektől, a lokálisoktól és a felfüggesztési pontoktól. A compiler a frame allokációját el is hagyhatja, ha a coroutine élettartama a hívóhoz kötött és a méret ismert; ez implementációfüggő optimalizáció, nem nyelvi garancia. A frame igazítása a benne lévő objektumok maximális igazításához igazodik.

A frame egyértelmű tulajdonosa a return object, amely a handle-t tagként tárolja, move-only, és a destruktorában destroy()-t hív. A coroutine_handle önmagában nem tulajdonos; a destroy() meghívása a return object szerződése. Ha a coroutine a return objectnél tovább él (például egy külső scheduler tartja a handle-t), a tulajdonjogot dokumentálni kell, és a destroy()-t annak kell hívnia, aki az élettartamot lezárja.

35.2.9 Kidolgozott példa: Generator és Task

A következő példa egy pull-alapú Generator<int>-ot és egy eager Task-ot mutat. A Generator initial_suspend-je suspend_always, final_suspend-je szintén; a frame tulajdonosa a Generator. A Task initial_suspend-je suspend_never, final_suspend-je suspend_always; a frame tulajdonosa a Task. A kód C++20-ra épül, és a laborban a rögzített compilerrel fordítandó; futási eredményt itt nem állítok.

// coro_lab.cpp
#include <coroutine>
#include <cstdio>
#include <exception>
#include <optional>
#include <utility>

template <class T>
class Generator {
public:
    struct promise_type {
        std::optional<T> current;

        Generator get_return_object() {
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() noexcept { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() noexcept {}
        void unhandled_exception() { std::terminate(); }
        std::suspend_always yield_value(T v) {
            current = std::move(v);
            return {};
        }
    };

    explicit Generator(std::coroutine_handle<promise_type> h) noexcept : h_(h) {}
    Generator(Generator&& other) noexcept : h_(std::exchange(other.h_, nullptr)) {}
    Generator& operator=(Generator&& other) noexcept {
        if (this != &other) {
            if (h_) h_.destroy();
            h_ = std::exchange(other.h_, nullptr);
        }
        return *this;
    }
    Generator(const Generator&) = delete;
    Generator& operator=(const Generator&) = delete;
    ~Generator() { if (h_) h_.destroy(); }

    bool next() {
        if (!h_ || h_.done()) return false;
        h_.resume();
        return !h_.done();
    }
    const T& value() const { return *h_.promise().current; }
    bool done() const { return !h_ || h_.done(); }

private:
    std::coroutine_handle<promise_type> h_;
};

class Task {
public:
    struct promise_type {
        std::exception_ptr error;

        Task get_return_object() {
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_never initial_suspend() noexcept { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() noexcept {}
        void unhandled_exception() noexcept { error = std::current_exception(); }
    };

    explicit Task(std::coroutine_handle<promise_type> h) noexcept : h_(h) {}
    Task(Task&& other) noexcept : h_(std::exchange(other.h_, nullptr)) {}
    Task& operator=(Task&& other) noexcept {
        if (this != &other) {
            if (h_) h_.destroy();
            h_ = std::exchange(other.h_, nullptr);
        }
        return *this;
    }
    Task(const Task&) = delete;
    Task& operator=(const Task&) = delete;
    ~Task() { if (h_) h_.destroy(); }

    void resume() { if (h_ && !h_.done()) h_.resume(); }
    bool done() const { return !h_ || h_.done(); }
    void rethrow_if_error() {
        if (h_ && h_.promise().error) std::rethrow_exception(h_.promise().error);
    }

private:
    std::coroutine_handle<promise_type> h_;
};

Generator<int> iota(int from, int to) {
    for (int i = from; i < to; ++i)
        co_yield i;
}

Task print_after_suspend() {
    std::puts("A: elso szakasz");
    co_await std::suspend_always{};
    std::puts("B: folytatas resume utan");
}

int main() {
    auto g = iota(0, 3);
    while (g.next())
        std::printf("gen: %d\n", g.value());

    auto t = print_after_suspend();
    std::puts("main: a task elso felfuggesztes utan");
    t.resume();
    t.rethrow_if_error();
}

A fordításhoz például:

g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -o coro_lab coro_lab.cpp
# vagy
clang++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -o coro_lab coro_lab.cpp

A használt compiler és standard library verziójától függően a coroutine támogatás külön kapcsolóhoz kötött lehet; ezt a laborprofilban kell ellenőrizni. A példa nem állítja, hogy bármely konkrét fordítóval tesztelt.

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

Az első hiba a kettős felszabadítás: a return object másolható, és mindkét példány destroy()-t hív. Javítás: move-only szemantika, std::exchange, és a másolás törlése.

A második a final_suspend = suspend_never és destroy() együtt: a frame a coroutine végén automatikusan felszabadul, a handle érvénytelen, a destroy() nem definiált. Javítás: suspend_always, ha a return object kezeli a frame-et; vagy a return object ne hívjon destroy()-t, ha a frame automatikusan felszabadult.

A harmadik a dangling referencia a promise-ban: a promise egy hívói lokálisra mutató referenciát tárol, amely a coroutine továbbélésekor érvénytelenné válik. Javítás: érték szerint tárolni, vagy dokumentált élettartam-szerződést adni.

A negyedik a handle elmentése await_suspend-ben, majd a frame felszabadítása: a későbbi resume() érvénytelen frame-re hív. Javítás: a tulajdonjog és az élettartam egyértelmű rendezése; a handle-t csak addig szabad megtartani, amíg a frame él.

Az ötödik a kivétel elnyelése: az unhandled_exception nem tárolja el a hibát, így a hívó nem értesül róla. Javítás: std::current_exception() eltárolása és a return objectben történő újradobás, vagy tudatos std::terminate.

35.2.11 Feladatok

  1. Magyarázó feladat. Írja le a hívási sorrendet arra a coroutine-ra, amelynek initial_suspend-je suspend_always, a testében egy co_await std::suspend_always{} van, majd co_return;. Mikor fut get_return_object, return_void, final_suspend, és mikor kerül a return object a hívóhoz?

  2. Kódolási feladat. Egészítse ki a Generator<int>-ot begin()/end() iterátorral, hogy range-for-ban használható legyen. Az iterátor operator*-ja a value()-t adja, operator++-ja a next()-et hívja. Ügyeljen arra, hogy a Generator move-only maradjon, és a frame pontosan egyszer szabaduljon fel.

  3. Diagnosztikai feladat. Adott egy Task, amelynek final_suspend-je suspend_never, a destruktora pedig destroy()-t hív. Mi a hiba, milyen tünetet okoz AddressSanitizer alatt, és hogyan javítja?

  4. Kódolási feladat. Készítsen egy LoggingAwaiter-t, amely kiírja az await_ready, await_suspend és await_resume hívásokat, és használja egy Task-ban. Magyarázza el a kimenet sorrendjét.

  5. Elemző feladat. Miért nem tulajdonos a coroutine_handle? Milyen esetben viselkedhet mégis tulajdonosként, és milyen szerződést kell ilyenkor dokumentálni?

35.2.12 Megoldások

1. megoldás. A hívás sorrendje: a frame létrehozása és a promise konstrukciója után get_return_object() fut; az eredmény a frame-ben tárolódik. Ezután co_await initial_suspend() következik; mivel suspend_always, a coroutine felfüggesztődik, és a return object a hívóhoz kerül. A hívó resume()-ja folytatja a testet; a co_await std::suspend_always{} újra felfüggeszt. A következő resume() után a co_return; a return_void-ot hívja, majd a lokálisok destruktorai és a final_suspend következnek; mivel ez is suspend_always, a coroutine a végpontján felfüggesztve marad. A frame felszabadítása a return object destruktorában történik. Ellenőrzési feltétel: a done() a return_void után, a final_suspend felfüggesztésekor válik igazzá; a destroy() csak ezután hívható.

2. megoldás. Az iterátor egy könnyű, nem tulajdonló nézet:

class iterator {
public:
    explicit iterator(Generator* g) : g_(g) {}
    const T& operator*() const { return g_->value(); }
    iterator& operator++() { g_->next(); return *this; }
    bool operator==(std::default_sentinel_t) const { return !g_ || g_->done(); }
private:
    Generator* g_;
};
iterator begin() { next(); return iterator{this}; }
std::default_sentinel_t end() { return {}; }

A begin() elsőként next()-et hív, hogy az első co_yield után legyen érték. A Generator move-only marad; az iterátor csak addig érvényes, amíg a Generator él. Ellenőrzési feltétel: a range-for minden elemet egyszer ad, a Generator destruktora egy destroy()-t hív, és a begin() után a value() nem olvas inicializálatlan optionalt.

3. megoldás. A hiba, hogy a final_suspend = suspend_never miatt a frame a coroutine végén automatikusan felszabadul, a handle érvénytelenné válik. A destruktor destroy() hívása így érvénytelen frame-re mutat; AddressSanitizer alatt tipikusan heap-use-after-free vagy double-free lép fel. A javítás: final_suspend legyen suspend_always, és a return object destruktora hívja a destroy()-t; vagy ha marad a suspend_never, a return object ne hívjon destroy()-t, és a handle-t ne használja a done() után. Ellenőrzési feltétel: a done() igaz, a destroy() egyszer fut, és a sanitizer nem jelez.

4. megoldás. A co_await LoggingAwaiter{"pont"} először ready: pont-ot ír, mivel az await_ready() hamis, majd suspend: pont-ot, és felfüggeszt. A resume() után resume: pont jelenik meg. A sorrend tehát ready → suspend → (felfüggesztés) → resume. Ha az await_ready() igaz lenne, a suspend kimaradna. Ellenőrzési feltétel: a kimenet pontosan a három hívásnak megfelelő sorrendet mutatja, és a co_await kifejezés értéke az await_resume visszatérési értéke.

5. megoldás. A coroutine_handle egy nem tulajdonló fogantyú: másolható, olcsó, és nem tartalmazza a frame felszabadításának felelősségét. A tulajdonjog a return objecté, amely a handle-t tárolja, és a destruktorában destroy()-t hív. A handle akkor viselkedhet tulajdonosként, ha a felhasználó kézzel kezeli a frame élettartamát, például egy külső scheduler tárolja, és dokumentáltan ő hívja a destroy()-t. Ilyenkor a szerződésnek rögzítenie kell, ki hívja a destroy()-t, mikor szabad a handle-t másolni, és hogy a coroutine felfüggesztett állapotban van-e a felszabadításkor. Ellenőrzési feltétel: a frame egyszer szabadul fel, és a handle egyetlen tulajdonoshoz köthető.

35.2.13 Összefoglaló

A coroutine nyelvi állapotgép, amelynek protokollja a promise_type, az awaiter és a coroutine_handle hármasára épül. A get_return_object() adja a hívó által látott objektumot; az initial_suspend és a final_suspend dönti el, mikor kerül a return object a hívóhoz, és hogy a frame automatikusan felszabadul-e. Az awaiter await_ready/await_suspend/await_resume sorrendje pontos; a co_return a return_value vagy return_void függvényre képződik, a kivétel az unhandled_exception-en keresztül jut el a final_suspend-hez. A frame tulajdonosa a return object, amely move-only módon tárolja a handle-t, és a destruktorában destroy()-t hív. A coroutine_handle maga nem tulajdonos; a resume(), done() és destroy() helyes használata a frame élettartam-szerződésén múlik.