35.3 Dangling capture, resumption és cancellation

A coroutine nem kerül ki a memóriából akkor, amikor a létrehozó kifejezés véget ér: a fordító egy úgynevezett coroutine frame-et (activation record) készít, amely a promise objektumot, a paraméterek másolatait, a felfüggesztési pont indexét és a felfüggesztéseket átélő lokálisokat tartalmazza. A frame élettartama független a létrehozó lexikális hatókörétől, és éppen ez a különbség szüli a coroutine-ok saját hibacsaládját: a nem tulajdonló hivatkozások (referencia, this, pointer, string_view, iterator, span) túlélhetik azt az objektumot, amire mutatnak. Ebben az alfejezetben ezt a jelenséget nevezzük dangling capture-nek, és három tipikus alakját, majd a cancellation–completion–destroy versenyhelyzet kezelését tárgyaljuk. Az [promise_type, awaiter és coroutine_handle (35.2)](#s177) részben felépített fogalmakra építünk; a nem tulajdonló nézetek élettartam-szabályait az [Borrowed range, dangling és iterator lifetime (30.5)](#s153) rögzítette, a publikálási mintákat pedig az [C++ atomics, atomic_ref és publication (34.3)](#s172).

35.3.1 A frame mint élettartam-tartomány

A coroutine frame tartalmát a szabvány taxatíven felsorolja: a promise objektum, a coroutine paramétereinek másolatai, a nem statikus tagfüggvényként definiált coroutine esetén a *this objektum elérése, valamint azok az automatikus tárolási idejű változók, amelyek élettartama átnyúlik egy felfüggesztési ponton. Fontos árnyalat: a paraméter másolata nem azt jelenti, hogy a hivatkozott objektum is bemásolódik. Ha a paraméter típusa T const& vagy T&, a frame-be kerülő „másolat” maga a hivatkozás (a gyakorlatban egy pointer), a hivatkozott objektum pedig a hívó oldalon marad. Ugyanez igaz a *this-re: a frame nem a closure másolatát, hanem a closure elérési útját őrzi.

Ebből két feltétel adódik, amelyeket minden coroutine-alapú API-nak dokumentálnia kell:

  1. Hívói élettartam-feltétel: a coroutine által nem tulajdonolt minden objektumnak életben kell maradnia addig, amíg a coroutine azt utoljára használja – nem addig, amíg a létrehozó kifejezés véget ér.
  2. Frame-tulajdonosi feltétel: a std::coroutine_handle nem tulajdonló; a frame-et pontosan egyszer kell megszüntetni (destroy()), és csakis felfüggesztett állapotban, más szál részvételének kizárásával. Befejezett (final suspendet elért) coroutine-ra resume() hívása, illetve futó coroutine destroy()-ja nem definiált viselkedés.

A két feltétel együtt adja a coroutine-típus szerződését; a task típus, amelyet az alábbi példák használnak, egy szálon, sorrendben futtatható, nem kezel cancellationt, és a frame-et a destruktora szünteti meg.

// task.h -- minimalis, egy szalon, sorrendben futtathato task (oktatasi cel)
#pragma once
#include <coroutine>
#include <exception>
#include <utility>

struct task {
    struct promise_type {
        std::exception_ptr err{};
        std::coroutine_handle<> cont{};

        task get_return_object() {
            return task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() noexcept { return {}; }

        struct final_awaiter {
            bool await_ready() noexcept { return false; }
            std::coroutine_handle<> await_suspend(std::coroutine_handle<promise_type> h) noexcept {
                auto c = h.promise().cont;
                return c ? c : std::noop_coroutine();   // symmmetric transfer
            }
            void await_resume() noexcept {}
        };
        final_awaiter final_suspend() noexcept { return {}; }

        void return_void() noexcept {}
        void unhandled_exception() noexcept { err = std::current_exception(); }
    };

    using handle_t = std::coroutine_handle<promise_type>;
    handle_t h{};

    task() = default;
    explicit task(handle_t hh) noexcept : h(hh) {}
    task(task&& o) noexcept : h(std::exchange(o.h, {})) {}
    task& operator=(task&& o) noexcept {
        if (this != &o) { if (h) h.destroy(); h = std::exchange(o.h, {}); }
        return *this;
    }
    task(const task&) = delete;
    task& operator=(const task&) = delete;
    ~task() { if (h) h.destroy(); }

    bool valid() const noexcept { return static_cast<bool>(h); }
    bool await_ready() const noexcept { return false; }
    std::coroutine_handle<> await_suspend(std::coroutine_handle<> caller) noexcept {
        h.promise().cont = caller;
        return h;
    }
    void await_resume() {
        if (h.promise().err) std::rethrow_exception(h.promise().err);
    }
};

35.3.2 By-reference paraméter és az életüket túlélő hívó

A leggyakoribb hibaforma: a coroutine referenciaparamétert kap, a hívó pedig egy temporaryt ad át, vagy lokális változót, amely a coroutine befejezése előtt kimegy a hatóköréből. A temporary a teljes kifejezés végén megszűnik, nem a coroutine végén.

// NEGATIV PELDA -- szandekosan hibas, ne masold a termekkodbe
#include "task.h"
#include <cstdio>
#include <string>

task log_after_gap(std::string const& msg) {   // referenciaparameter
    co_await std::suspend_always{};            // felfuggesztes: a hivo tovabblep
    std::puts(msg.c_str());                    // msg -> a hivo objektuma
}

void caller() {
    auto t = log_after_gap(std::string("kesz"));  // temporary ertek
    // A temporary a kifejezes vegen megszunik; a frame tovabb el,
    // es a kesobbi resume utan a msg hivatkozas dangling.
    (void)t;                                       // későbbi co_await/await_resume: UB
}

A hiba nem azért következik be, mert a frame „elfelejtette” másolni az objektumot, hanem azért, mert a paraméter típusa nem kért másolatot. A javítás mintái:

Vigyázat: a std::string_view paraméter különösen csapdás, mert érték szerinti átadása is nem tulajdonló. Egy string literálból képzett string_view statikus tárolású adatra mutat, ezért biztonságos; egy std::string-ből képzett viszont ugyanúgy dangling, mint a referencia.

35.3.3 Coroutine lambda és a closure élettartama

A lambda operator()-a nem statikus tagfüggvény, ezért coroutine-ként a frame a closure objektumra mutató elérési utat tárolja, a capture-öket viszont a closure tartalmazza. Ha a closure temporary, a co_await utáni hozzáférés a megszűnt closure-re mutat.

// NEGATIV PELDA -- temporary closure + coroutine lambda
#include "task.h"
#include <string>

auto make_source(std::string path) {
    return [path]() -> task<int> {                 // by-value capture a closure-ben
        co_await std::suspend_always{};            // felfuggesztes
        co_return static_cast<int>(path.size());   // path: a closure tagja
    };
}

void caller_bad() {
    auto t = make_source("/var/log/app.log")();    // temporary closure
    // A closure a teljes kifejezes vegen megszunik, a frame tovabb el.
    (void)t;                                       // UB a resume utan
}

void caller_ok() {
    auto closure = make_source("/var/log/app.log"); // nevesitett closure
    auto t = closure();                             // a closure tulel a task-ot?
    // csak akkor helyes, ha a closure a task befejezeseig el
}

A capture fajtája nem számít: a [path], a [&path] és a [path = std::move(path)] egyaránt a closure-ben lakik, tehát a closure élettartama a korlát. Két helyes stratégia van:

  1. Ne írj coroutine lambdát. A coroutine legyen szabad függvény, a szükséges adatokat érték szerinti paraméterként kapja; így a frame a paramétereket birtokolja.
  2. Nevesített, igazoltan hosszú életű closure. A closure-t egy hosszabb életű objektum tagjaként vagy a futtató környezet élettartamához kötve tárold, és a task destruktora előbb fusson le, mint a closure-é.

A coroutine lambda ráadásul megnehezíti a diagnózist, mert a hibahely a lambdan belül látszik, a kiváltó ok viszont a hívási kifejezésben van. Ezért a -Wall -Wextra mellett érdemes a fordító coroutine-specifikus figyelmeztetéseit is bekapcsolni; ezek támogatása és pontos szövege fordítóverziótól függ, a labor során ellenőrizd.

35.3.4 Felfüggesztett I/O buffer: tulajdonlás és pinning

Aszinkron I/O-nál a kernel vagy az I/O végrehajtó (io_uring, epoll + read, Windows overlapped) a művelet indításakor átveszi a buffer címét, és a befejezés pillanatában ír oda. Amíg a művelet „in flight”, a buffer címének stabilnak és a tárterületnek élőnek kell lennie. A coroutine frame ezt nem garantálja: a destroy() a frame lokálisait is megszünteti.

// NEGATIV PELDA -- felfuggesztett I/O buffer a frame-ben
#include "task.h"
#include <vector>

task<std::size_t> read_chunk(int fd, std::size_t n) {
    std::vector<char> buf(n);                                  // frame-ben él
    std::size_t k = co_await async_read(fd, buf.data(), n);    // cím átadva
    co_return k;                                               // buf itt szunik meg
}
// Két hiba:
//  (1) ha a coroutine-t felfuggesztve destroy()-oljuk, a kernel meg irhat
//      a felszabaditott taruletre -> memoria-korrupcio;
//  (2) ha kozben a buf_at noveljuk (resize/push_back), a regisztralt
//      pointer mar a felszabaditott regi puffert mutatja.

A helyes szerződés három eleme:

35.3.5 Cancellation, completion és destroy versenyhelyzet

A coroutine-alapú I/O-ban három szereplő van: a futtató (resume), a befejezési esemény (completion) és a tulajdonos (aki a task-ot megszünteti). A versenyhelyzet akkor keletkezik, ha ezek nem ugyanazon a szálon és nem szigorú sorrendben futnak.

35.3.5.1 A destroy–resume verseny

Tipikus hibaminta: a befejezési kezelő egy coroutine_handle-t tárol, a task destruktora viszont közben destroy()-t hív. Eredmény: a kezelő egy felszabadított frame-re hív resume()-t. A minimális protokoll:

  1. Megszakítás kérése (stop_requested flag és a művelet visszavonása, pl. io_uring_prep_cancel vagy close(fd)).
  2. Kivárás: a tulajdonos megvárja, hogy a befejezési kezelő lefusson és a számláló nullára csökkenjen (atomic fetch_sub + acquire, vagy condition_variable).
  3. Csak ezután destroy(), és a kezelő regisztrációját előbb törölni kell a nyilvántartásból.

A resume() és a destroy() egymásra nem versenyezhet: a resume() csak akkor legális, ha a coroutine felfüggesztett, a destroy() csak akkor, ha a coroutine felfüggesztett és senki nem fogja resume-olni. Ezt a gyakorlatban egy állapotváltozóval (pl. std::atomic<int> a promise-ban: suspended / running / completed / destroyed) kell kikényszeríteni, és minden átmenetnél compare-exchange-szel kell dönteni. Ha két szál egyszerre próbál resume-olni, az már adatverseny, nem „csak” logikai hiba.

35.3.5.2 Resume thread és publikálás

A co_await önmagában nem jelent szálat, schedulert vagy thread-affinitást. Azt, hogy melyik szálon folytatódik a coroutine, a futtató dönti el; ezt a task/executor típusának szerződése rögzíti. Három tipikus politika: egy szálú eseményhurok (a coroutine mindig ugyanazon a szálon fut), szálpool (bármelyik szálon folytatódhat), dedikált szál coroutine-onként. A középső esetben a coroutine teste által elért megosztott állapotot vagy atomikusan, vagy zárral kell védeni.

A befejezési esemény és a resume közötti happens-before nem a resume() hívásból származik, hanem a köztük lévő szinkronizációból. Ha a befejező szál release-store-ot végez, a folytató szál acquire-load-dal olvassa az állapotot, a befejező szál által írt buffer tartalma látható lesz. Ha a befejezés egy zárral védett sorba kerül, a zár adja ugyanezt. A resume() csupán vezérlésátadás; nem tesz semmit thread-safe-é.

35.3.5.3 Hibaterjedés és RAII a frame-ben

A frame lokálisai akkor is megszűnnek, amikor a coroutine-t destroy()-oljuk: a fordító a frame megszüntetésekor lefuttatja a hatókörben lévő objektumok destruktorait, mintha a coroutine teste volna visszatekerve. Ez teszi lehetővé, hogy a cancellation könyvelését RAII-val végezzük.

#include <atomic>

struct in_flight {                       // a frame-ben el, a destroy is futtatja
    std::atomic<int>& counter;
    explicit in_flight(std::atomic<int>& c) noexcept : counter(c) {
        counter.fetch_add(1, std::memory_order_relaxed);
    }
    ~in_flight() { counter.fetch_sub(1, std::memory_order_release); }
    in_flight(const in_flight&) = delete;
    in_flight& operator=(const in_flight&) = delete;
};
// hasznalat: task read_chunk(...) { in_flight guard{g_inflight}; ... }

A guard nem véd meg a kernellel szemben: ha a művelet még fut, a destroy() után is írhat a bufferbe. A guard csak azt garantálja, hogy a tulajdonos tudja, van-e folyamatban lévő művelet, és meg tudja várni a drain-t.

A hibaterjedés a promise unhandled_exception() → std::exception_ptr → await_resume() újradobás útvonalon megy. Két buktató: az exception_ptr-t a frame megszüntetése előtt ki kell nyerni, különben a hiba csendben elvész; és az újradobás csak egyszer történhet meg, különben a hívó kétszer látja ugyanazt a kivételt. A hibás ágon is ugyanaz a task destruktor fut, tehát a frame felszabadul – de csak a drain után.

35.3.6 Gyakori hibák és javításuk

35.3.7 Összefoglaló

A coroutine frame élettartama független a létrehozó kifejezésétől, ezért minden nem tulajdonló hivatkozás, this-elérés, string_view és buffer-pointer új élettartam-szerződést igényel. A paraméterek a frame-be másolódnak, de a referenciaparaméter csak a hivatkozást viszi magával; a coroutine lambda a closure-re mutat, nem a closure másolatát őrzi; a felfüggesztett I/O buffer pedig addig marad „pinelve”, amíg a befejezés be nem következett. A cancellation nem egyetlen destroy() hívás, hanem három lépés: kérés, drain, megszüntetés. A resume nem szinkronizációs művelet, a happens-before-t a befejezés és a folytatás közötti atomikus vagy zárral védett publikálás adja. A frame lokálisainak destruktorai a destroy()-nál is lefutnak, ezért a RAII a cancellation könyvelésének elsődleges eszköze.

A fejezetrész nem végzett forrásellenőrzést; a coroutine-mechanika leírása a C++20 modellre támaszkodik, a pontos szabványparagrafusok, a fordítók figyelmeztetéseinek szövege és a std::generator (C++23) támogatottsága verziófüggő, a labor során feature-test makróval és fordítási próbával ellenőrizendő.

35.3.8 Feladatok

1. feladat (magyarázó). Három változatban deklaráljuk ugyanazt a coroutine-t: task f(std::string const&), task f(std::string) és task f(std::string_view). Melyikben dangling a temporaryval történő hívás f(std::string("x")) esetén, és melyikben nem? Indokold a frame tartalma alapján.

2. feladat (kódolási). Alakítsd át a make_source coroutine lambdát olyan szabad függvénnyé, amelynek nincs closure-függősége, és add meg a hívó oldalt is. A megoldás forduljon -std=c++20 -Wall -Wextra mellett figyelmeztetés nélkül.

3. feladat (diagnosztikai). Egy read_chunk coroutine std::vector<char> puffert használ, és a task destruktora destroy()-t hív. Sorolj fel legalább három konkrét eseménysorrendet, amely memóriakorrupcióhoz vezet, és mindegyikhez adj egy-egy minimális javítást.

4. feladat (tervezési, nehezebb). Tervezz a task típushoz cancellation-protokollt: milyen állapotokat vezetsz be, milyen atomikus műveletekkel lépsz át köztük, és hogyan biztosítod, hogy a destroy() csak a drain után történjen? Fogalmazz meg legalább négy ellenőrzési feltételt, amelyet egy teszt ellenőrizni tud.

35.3.9 Megoldások

1. megoldás. A std::string const& változat dangling: a frame a hivatkozást (gyakorlatban pointert) tárolja, a temporary a teljes kifejezés végén megszűnik, a coroutine viszont később folytatódik. A std::string változat biztonságos: a paraméter másolata a frame-be kerül, és a frame megszüntetésekor fut le a destruktora. A std::string_view változat érték szerinti átadás esetén is nem tulajdonló: a string_view egy std::string belsejére mutat, ezért a temporary megszűnésével dangling; string literálból képzett string_view esetén viszont statikus tárolású adatra mutat, tehát biztonságos. Ellenőrzési feltétel: minden esetben nevezd meg, ki a tulajdonos, és meddig él.

2. megoldás. A closure-t megszüntetjük azzal, hogy a coroutine szabad függvény, a paramétert érték szerint kapja:

#include "task.h"
#include <string>
#include <utility>

task<int> source_size(std::string path) {      // ertek szerinti parameter: a frame birtokolja
    co_await std::suspend_always{};            // tetszoleges felfuggesztes
    co_return static_cast<int>(path.size());
}

void caller() {
    auto t = source_size("/var/log/app.log");  // nincs closure, nincs temporary closure
    (void)t;
}

Build: g++ -std=c++20 -O2 -Wall -Wextra -o lab lab.cpp (Clang esetén clang++ -std=c++20 -O2 -Wall -Wextra). Indoklás: a frame a path másolatát tárolja, a hívó a source_size visszatérése után bármikor megszűnhet. Ellenőrzési feltétel: a task élettartama alatt a path címét senki nem használja a frame-en kívül; a program a co_await után is helyes size()-ot ad. Futtatás e fejezet írásakor nem történt, a parancs a tanuló laborjához tartozik.

3. megoldás. (a) A coroutine felfüggesztve van az async_read alatt, a tulajdonos destroy()-ol, a kernel később a felszabadított buf.data()-ra ír → heap-korrupció. Javítás: drain a destroy előtt (a befejezési számláló nullára vitele) és fix méretű std::array buffer. (b) A coroutine fut, közben a buf átméreteződik (resize), az I/O végrehajtó a régi pointert használja → a régi puffer felszabadul, az új címre nem érkezik adat, a régi címre viszont igen. Javítás: a buffer soha nem méreteződik át a művelet alatt; a méret a coroutine paramétere és konstans kapacitás. (c) A coroutine destroy()-ol, miközben a befejezési kezelő még a regisztrációban van és resume()-ot hív → felszabadított frame. Javítás: deregisztráció a destroy előtt, a kezelőben done()-ellenőrzés és a számláló kezelése. Ellenőrzési feltétel: minden esethez nevezd meg a tulajdonost, az invalidáló műveletet és a hibakimenetet.

4. megoldás. Négy állapot: suspended, running, completed, destroyed; az átmeneteket std::atomic<int>-en compare-exchange végzi. A cancellation két flag: cancel_requested és completion_count. A tulajdonos cancel()-je beállítja a kérést és visszavonja az I/O-t, majd drain()-ben megvárja a completion_count == 0-t (acquire), és csak ezután hív destroy()-t. A befejezési kezelő csak akkor resume()-ol, ha az állapot suspended és nem destroyed. Ellenőrzési feltételek: (1) destroy() után nincs resume(); (2) kétszeres destroy() nem fordulhat elő; (3) resume() és destroy() soha nem fut egyszerre ugyanarra a handle-re; (4) a befejezési kezelő regisztrációja a destroy() előtt törlődik. Megjegyzés: a teljes, éles futtató implementációja (scheduler, io_uring adapter) kívül esik ezen az alfejezeten; itt csak a szerződés és a minimális protokoll a cél.