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).
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:
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);
}
};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:
task log_after_gap(std::string msg) – a frame a string
másolatát tárolja, a hívó bármikor megszűnhet. Költsége egy másolás
(vagy mozgatás, ha a hívó std::move-val adja át).std::shared_ptr<std::string>-et ad át, vagy a
coroutine-t egy olyan objektum tagjaként indítja, amelynek élettartama
igazoltan hosszabb.await_resume
addig nem tér vissza, amíg a coroutine be nem fejeződött, tehát ha a
hívó a co_await előtt létrehozza az objektumot és csak
utána engedi ki, a hivatkozás biztonságos. Ezt a szerződést a
task típus dokumentációjában kell rögzíteni, mert a nyelv
nem kényszeríti ki.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.
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:
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.
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:
std::array vagy
std::unique_ptr<std::byte[]>), lehet az I/O objektum,
lehet egy pool.free, sem mmap-módosítás nem
történhet. Ezt a típussal kell kikényszeríteni (pl.
std::array és konstans kapacitás), vagy dokumentált
invariánssal.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.
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:
stop_requested
flag és a művelet visszavonása, pl. io_uring_prep_cancel
vagy close(fd)).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.
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-é.
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.
co_await mint felfüggesztés a hívó
vermére. A hívó stackje nem marad meg; a co_await
előtti pointerek és referenciák csak akkor érvényesek, ha a hivatkozott
objektum máshol él. Javítás: érték szerinti paraméter vagy tulajdonosi
lánc.coroutine_handle. Javítás: a
destroy() előtti deregisztráció, és a befejezési számláló
nullára vitele.co_await szálat
vált. Javítás: a resume-politika dokumentálása és a megosztott
állapot védelme.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ő.
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.
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.