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.
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.
Egy co_await kifejezés kiértékelése három, a várakozó
(awaitable) típuson meghívott műveletből áll:
await_ready() — ha true, nem történik
felfüggesztés, azonnal jön az await_resume().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).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.
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.
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.
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.
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.
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”.
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”.
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.
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(); // ???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ő.