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.
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.
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:
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.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.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.co_await-nél lefut az
awaiter protokoll; minden co_yield a
yield_value-t hívja, majd felfüggeszt.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().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.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.
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.
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.
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ó.
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.
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.
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.
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.cppA 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.
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.
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?
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.
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?
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.
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?
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ő.
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.