35.1 Coroutine mint felfüggeszthető állapotgép

A C eseményvezérelt szolgáltatásokban a legtöbb bonyolultságot nem az algoritmus, hanem az állapot megőrzése okozza: a poll/epoll ciklus visszatér, a félkész üzenet a pufferen marad, a „hol tartottunk” információt pedig egy enum és néhány struct tag hordozza. A C++20 coroutine ugyanezt a feladatot fordító által generált állapotgépre bízza. Ez a rész kizárólag a nyelvi mechanikát tanítja: hogyan lesz egy függvényből újraindítható állapotgép, mi történik a coroutine frame-ben tárolt lokális állapottal, és mit nem ad hozzá a nyelv. Hogy milyen ütemező, hálózati runtime vagy szálpolitika hajtja a coroutine-t, az későbbi, alkalmazásszintű szerződés (Aszinkron I/O adapter és coroutine-labor (35.5)); itt csak annyit rögzítünk, amennyi a frame és a felfüggesztés megértéséhez kell.

35.1.1 Definició: mikor coroutine egy függvény

Definíció. Egy C++20 függvény akkor és csak akkor coroutine, ha a függvénytörzse tartalmaz legalább egy co_await kifejezést, co_yield kifejezést vagy co_return utasítást, és a függvény nem main, nem konstruktor, nem destruktor. A besorolás a függvénytörzsre vonatkozik: ha a co_await egy belső lambda testében van, akkor a lambda a coroutine, a külső függvény nem.

A fordító ilyenkor nem hagyományos hívást generál, hanem a törzset egy állapotgéppé alakítja, amelynek állapotát egy a fordító által előállított coroutine frame (más néven activation record) hordozza. A co_return a hagyományos return helyett kötelező: ha egy coroutine törzsében return áll, a program hibás (ill-formed), mert a visszatérés a promise_type protokollján keresztül történik.

A feltétel második fele a visszatérési típusra vonatkozik: a típusnak ki kell elégítenie a coroutine-követelményeket, azaz rendelkeznie kell promise_type-tal — közvetlenül beágyazott típusként, vagy a std::coroutine_traits<R, Args...> specializációján keresztül. A promise típus adja az initial_suspend, final_suspend, get_return_object, return_void/return_value, unhandled_exception és yield_value műveleteket. Ha bármelyik hiányzik, a program szintén hibás. Ez a pont azért fontos, mert a „coroutine” nem egy önálló típus, hanem egy protokoll: ugyanaz a co_await mást jelent egy Task<int>-ben és egy std::generator<int>-ben.

A co_yield e nem újabb primitív, hanem a co_await promise.yield_value(e) rövidítése — ezt érdemes fejben tartani, mert a hibakeresésnél a co_yield is ugyanazon az await-mechanizmuson megy keresztül.

35.1.2 A felfüggesztési pont és a visszatérés a hívóhoz

Egy co_await kifejezés kiértékelése három, a várakozó (awaitable) típuson meghívott műveletből áll:

  1. await_ready() — ha true, nem történik felfüggesztés, azonnal jön az await_resume().
  2. await_suspend(handle) — a felfüggesztés pillanata; a handle a futó coroutine azonosítója. Visszatérési típusa void (mindig felfüggeszt), bool (a false azt jelenti, hogy mégsem függesztünk fel), vagy coroutine_handle<> (szimmetrikus átadás egy másik coroutine-nak).
  3. await_resume() — a folytatás értéke, ez lesz a co_await kifejezés eredménye.

A felfüggesztés lényege: a vezérlés visszatér ahhoz, aki a coroutine-t életre hívta vagy utoljára resume()-olta. Nem történik szálváltás, nem jön létre új szál, nem mozdul a veremmutató egy másik veremre — a coroutine ugyanazon a szá0lon fut tovább, amelyik a resume()-t hívja. A coroutine_handle::resume() a frame-ben tárolt folytatási ponttól indítja újra a végrehajtást, egészen a következő felfüggesztésig vagy a co_return-ig.

A hívó szempontjából a coroutine „visszatérése” kétszer történik meg. Először a függvény meghívásakor: a get_return_object() által előállított objektum már akkor a hívóhoz kerül, amikor a törzs még el sem kezdett futni — ezért tud a hívó kezet kapni egy lazán induló coroutine-ra. Másodszor minden felfüggesztésnél: a vezérlés visszaáramlik, de a frame nem semmisül meg, hanem „lefagy”. A hívó a visszakapott objektumon keresztül később újraindíthatja.

35.1.3 A coroutine frame és a lokális állapot élettartama

A frame tipikusan a következőket tartalmazza: a promise objektumot; a paraméterek frame-be másolt példányait (tehát a coroutine paraméterei másolással kerülnek a frame-be, nem a hívó stackjén maradnak); minden olyan lokálist és temporáriust, amelynek élettartama átnyúlik egy felfüggesztési ponton; valamint a folytatási pontot azonosító indexet, amiből a fordító a switch-öt generálja.

A frame élettartama a coroutine-létrehozástól a frame megsemmisítéséig tart, nem a függvényhívás végéig. A megsemmisítés a coroutine_handle::destroy() hívással vagy a frame-et birtokló visszatérési objektum destruktorával történik; ilyenkor lefutnak a hatókörben lévő lokálisok destruktorai és a promise destruktora. A frame-et a promise_type::operator new allokálja (ha van), de a szabvány megengedi az allokáció elhagyását (allocation elision), ha a fordító bizonyítani tudja, hogy a frame élettartama szigorúan beágyazódik a hívóéba. Ez implementációfüggő optimalizálás: a „coroutine mindig heapen lakik” állítás nem igaz, és méréssel kell ellenőrizni, nem feltételezéssel.

Ebből következik a legfontosabb élettartam-szabály: a frame-hez mutató hivatkozás csak addig érvényes, amíg a frame él. Ha egy coroutine co_return-nal visszaad egy referenciát egy frame-lokálisra, vagy egy std::string_view-t a frame-ben tárolt bufferre, az a hívó oldalán dangling lesz — pontosan az az eset, amit a temporary/value category szabályoknál (Temporary, value category és dangling (20.4)) már láttunk. Ugyanígy: felfüggesztett coroutine frame-jét megsemmisíteni szabad (ekkor a lokálisok destruktorai lefutnak), de egy futó coroutine-t megsemmisíteni, vagy egy már megsemmisített frame-re resume()-t hívni nem definiált viselkedés.

35.1.4 initial_suspend és final_suspend előkép

A coroutine életciklusának két végét a promise két művelete szabályozza. Az initial_suspend() a törzs első utasítása előtt fut: std::suspend_always esetén a coroutine lazy módon indul (a hívó azonnal visszakapja a visszatérési objektumot, a test csak az első resume()-ra fut), std::suspend_never esetén eager módon, azonnal futni kezd. A final_suspend() a co_return után fut: suspend_always esetén a frame a végső felfüggesztési ponton marad, done() igaz lesz, de a frame csak a destroy()-ig él — így a promise-ból kiolvasható az eredmény. suspend_never esetén a frame automatikusan megsemmisül, a handle érvénytelenné válik, és a rá hivatkozó objektum dangling.

Ez a két döntés adja a coroutine-típus „karakterét”: a lazy Task az initial_suspend = suspend_always és final_suspend = suspend_always kombinációjával lesz biztonságosan birtokolható, míg egy std::generator a végső felfüggesztést használja arra, hogy az utolsó elem is kiolvasható legyen.

35.1.5 Kidolgozott példa: három lépés felfüggesztve

Az alábbi példa szándékosan minimális: nem hálózat, nem ütemező, csak a frame és a resume() viszonya. A Resumable a frame egyetlen tulajdonosa; a destruktor megsemmisíti a frame-et, a mozgatás átadja a tulajdonjogot.

// steps.cpp — C++20: <coroutine>, <cstdio> és <exception> (std::terminate)
#include <coroutine>
#include <cstdio>
#include <exception>

struct Resumable {
    struct promise_type {
        Resumable get_return_object() {
            return Resumable{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(); }
    };

    using handle_type = std::coroutine_handle<promise_type>;

    explicit Resumable(handle_type h) noexcept : h_(h) {}
    Resumable(const Resumable&)            = delete;
    Resumable& operator=(const Resumable&) = delete;
    Resumable(Resumable&& o) noexcept : h_(o.h_) { o.h_ = {}; }
    Resumable& operator=(Resumable&& o) noexcept {
        if (this != &o) { if (h_) h_.destroy(); h_ = o.h_; o.h_ = {}; }
        return *this;
    }
    ~Resumable() { if (h_) h_.destroy(); }

    // true: van még folytatás; false: a coroutine befejeződött vagy üres
    bool resume() {
        if (!h_ || h_.done()) return false;
        h_.resume();
        return !h_.done();
    }

private:
    handle_type h_{};
};

Resumable steps(int n) {
    for (int i = 0; i < n; ++i) {
        std::printf("step %d\n", i);
        co_await std::suspend_always{};   // itt visszaadjuk a vezérlést a hívónak
    }
    std::printf("done\n");
}   // implicit co_return

int main() {
    auto r = steps(3);
    while (r.resume())
        std::puts("  ... a hivo kozben fut");
}

Fordítás: g++ -std=c++20 -O2 -Wall -Wextra -Wpedantic -o steps steps.cpp. A std::terminate az <exception> fejlécben van deklarálva, ezért azt közvetlenül be kell vonni; nem szabad arra építeni, hogy a <coroutine> vagy a <cstdio> tranzitívan behúzza. Buildjegyzőkönyv (lab-gcc profil): a program figyelmeztetés nélkül fordult, és a következő sorrendet adta: a main először step 0-t, majd a hívó sorát, aztán step 1-et, ismét a hívó sorát, step 2-t, a hívó sorát, végül done-t ír, majd a ciklus kilép, és a destruktor megsemmisíti a frame-et. Figyeljük meg: a hívó sosem „blokkolódik”, a ciklusváltozó i és a n paraméter a felfüggesztés alatt is a frame-ben él.

35.1.6 A scheduler és az executor nem része a nyelvnek

A C++20 coroutine-mechanizmus kizárólag a következőket adja: a frame allokációját és élettartamát, a felfüggesztés/folytatás primitíveit és a handle-t. Nem ad szálat, időzítőt, I/O-t, prioritást, megszakítást (cancellation) vagy futásütemezőt. A resume() hívója maga a „scheduler”: az, hogy mikor és melyik szá0lon hívjuk meg, alkalmazásszintű döntés. Ha a resume() egy másik szálon történik, mint a korábbi felfüggesztés, akkor a frame állapotának átadása szinkronizációs kérdés, és a szálak közötti publikálás szabályai érvényesek (shared ownership, reclamation és konkurens review (34.6)): a coroutine nem tesz automatikusan atomic-ot, mutexet vagy memóriakerítést. Ugyanígy a co_await önmagában nem hálózati művelet: az awaitable await_suspend-jében kell regisztrálni az eseményt (például epoll-ba, poll/epoll és explicit eseményállapotgép (12.6)), és a kész esemény kezelőjének kell resume()-ot hívnia.

35.1.7 Összehasonlítás az explicit C eseményállapgéppel

Nézzük ugyanazt a feladatot C-ben: hossz-előtagos keretek dekódolása nem blokkoló byte-streamen. Az állapot egy struct, a folytatási pont egy enum, a „felfüggesztés” pedig a függvény visszatérése.

/* decoder.h — explicit allapotgep, C17 */
#include <stddef.h>
#include <stdint.h>

enum dec_state { DEC_LEN, DEC_BODY, DEC_DONE, DEC_ERR };

struct decoder {
    enum dec_state state;
    uint8_t        len;        /* vart torzshossz */
    size_t         got;        /* eddig beolvasott bajt */
    uint8_t        buf[255];
};

/* Visszateres: elhasznalt bajtok szama; 0 = nem tudott tovabblepni. */
static size_t decoder_feed(struct decoder *d, const uint8_t *p, size_t n)
{
    size_t used = 0;
    while (used < n) {
        switch (d->state) {
        case DEC_LEN:
            d->len = p[used++];
            d->got = 0;
            d->state = (d->len == 0) ? DEC_DONE : DEC_BODY;
            break;
        case DEC_BODY:
            while (used < n && d->got < d->len)
                d->buf[d->got++] = p[used++];
            if (d->got == d->len) d->state = DEC_DONE;
            else return used;              /* tobb adat kell: "felfuggesztes" */
            break;
        case DEC_DONE:
        case DEC_ERR:
            return used;                   /* a keret kesz, a hivo dolga */
        }
    }
    return used;
}

A megfeleltetés tanulságos, mert a coroutine frame pontosan ezeket a szerepeket tölti be, csak a fordító generálja:

Szerep Explicit C állapotgép C++20 coroutine frame
Folytatási pont enum dec_state + switch fordító által generált index + switch
Helyi állapot struct decoder tagjai frame-lokálisok, paramétermásolatok
Felfüggesztés return used; a ciklusból co_await / co_yield felfüggesztése
Folytatás hívó újra meghívja decoder_feed-et hívó handle.resume()-ot hív
Tulajdonos a struct decoder gazdája a visszatérési objektum (pl. Resumable)
Élettartam vége free/destruktor a gazdánál destroy() / a birtokló destruktora

A coroutine nem tünteti el a bonyolultságot, hanem áthelyezi: a kézi switch-t és a tagok életben tartását a fordító végzi, cserébe a frame allokációjáért, a promise-protokollért és a felfüggesztés élettartam-szabályaiért kell felelni. A C változat explicit, debuggerben könnyen olvasható, és nem igényel allokációt; a coroutine változat lineáris kódként olvasható, de a vezérlésfolyam a frame-ben „rejtőzik”.

35.1.8 Tipikus hibák

A leggyakoribb félreértés, hogy a co_await szálat indít. Nem: ugyanazon a szá0lon marad a végrehajtás, amíg valaki más szá0lon nem hív resume()-t. Második hiba a final_suspend suspend_never-re állítása, miközben a hívó továbbra is használja a handle-t — ilyenkor a frame a co_return után azonnal megsemmisül, a handle dangling. Harmadik: a coroutine-ból referenciát vagy string_view-t visszaadni egy frame-lokálisra. Negyedik: a Resumable-hez hasonló típust másolhatóvá tenni — a frame-nek egy tulajdonosa legyen. Ötödik: egy felfüggesztett coroutine-t úgy megsemmisíteni, hogy közben egy másik szál éppen resume()-ol rá; ez adatverseny, nem „coroutine-bug”.

35.1.9 Összefoglaló

A coroutine egy fordító által generált, felfüggeszthető állapotgép: a co_await/co_yield/co_return jelenléte teszi azzá, a visszatérési típus promise_type-ja szabja meg a viselkedését. A frame a paramétereket, a felfüggesztésen átnyúló lokálisokat és a folytatási pontot hordozza, élettartama a létrehozástól a destroy()-ig tart, nem a hívás végéig. A felfüggesztés nem szálváltás: a vezérlés a hívóhoz tér vissza, és a resume() folytatja. Az initial_suspend/final_suspend a két végpont politikája. Ütemező, I/O és cancellation nincs a nyelvben — az a fölötte épített szerződés.

35.1.10 Feladatok

1. feladat (magyarázó). Döntse el mindegyik esetről, coroutine-e, és ha nem, miért hibás a program: (a) Task<void> f() { co_await g(); }; (b) int f() { co_return 0; }; (c) void f() { auto l = [](){ co_await std::suspend_always{}; }; }; (d) Task<void> f() { return; }.

2. feladat (kódolási). Egészítse ki a Resumable példát úgy, hogy a coroutine co_return 42;-vel adjon vissza egy int eredményt, amely a befejezés után a birtokló objektumon value()-vel kiolvasható. Adja meg a promise módosításait és az élettartam-szerződést.

3. feladat (diagnosztikai). Az alábbi kódrészlet egy Task-ot birtokló objektumot mozgat, majd a korábban kimentett handle-t használja. Mutassa meg, hol sérül a frame élettartama, és javítsa a tulajdonlási szerződést:

auto owner = make_task();
owner.resume();
auto raw = owner.handle();     // nem tulajdonlo handle
owner = make_task();           // a move-assignment destroy()-olja a regi frame-et
raw.resume();                  // ???

35.1.11 Megoldások

1. megoldás. (a) Igen, coroutine: co_await a törzsben, a Task<void> adja a promise-t. (b) Nem coroutine, hanem hibás program: a co_return coroutine-t jelöl, de az int visszatérési típusnak nincs promise_type-ja, így a követelmény nem teljesül. (c) A belső lambda a coroutine, a külső f nem; a lambda visszatérési típusa viszont szintén korlátos, ezt külön ellenőrizni kell. (d) Hibás: coroutine-törzsben a return ill-formed, co_return-nal kell visszatérni. Ellenőrzési feltétel: minden állítás a törzs és a visszatérési típus kapcsolatára hivatkozik, nem a szintaxisra.

2. megoldás. A promise-ba int value = 0; tag és void return_value(int v) noexcept { value = v; } kerül a return_void() helyére; a birtokló osztály int value() const { return h_.promise().value; } hozzáférést ad. A szerződés: value() csak akkor hívható, ha a frame még él (a final_suspend suspend_always), és a coroutine done(); a hívó felelőssége ezt ellenőrizni. A final_suspend-et tehát nem szabad suspend_never-re váltani, különben a promise is a frame-mel együtt megsemmisül. Ellenőrzési feltétel: done() nélkül ne olvassuk az értéket, és a mozgatás után a forrás handle-je legyen üres.

3. megoldás. A hiba nem a resume() szintaxisában, hanem a tulajdonjogban van: a raw nem tulajdonló handle, a move-assignment viszont a régi frame destroy()-ját futtatja, így a raw.resume() egy már megsemmisített frame-re hivatkozik — nem definiált viselkedés, kimenet nem garantált. Javítás: a handle-t csak addig szabad kimenteni, amíg a birtokló objektum él és nem mozgatták; a helyes minta az, hogy a resume() mindig a birtoklón keresztül történik, a nyers coroutine_handle-t pedig csak olyan awaitable kapja meg, amelynek élettartama bizonyítottan rövidebb a frame-énél. Ellenőrzési feltétel: a javított változatot AddressSanitizerrel futtatva nem jelenhet meg use-after-free, és a raw használata előtt a tulajdonos érvényessége ellenőrizhető.