Když před cca 2 roky konečně vyšla norma C++ verze 20, těšil jsem se, jako asi každý, jak si ošahám a vyzkouším nové featury, které měly posunout jazyk zase trošku dál do budoucnosti. C++20 přináší podporu korutin, tedy nástroje, který byl doposud spíš výsadou vyšších programovacích jazyků – ano těch, co mají pod sebou nějaký runtime, ať už jde o interprety, nebo JIT překládané jazyky (třeba javascriptovýasync-await). Otevřel jsem svůj oblíbený editor a vytáhl první primitvní příklad z nějakých tutoriálových stránek v této formě
task<int> test_coro() { co_return 42; }
Asi tušíte, jak to dopadlo. Přes počáteční boj s překladačem o nastavení správne C++ verze, includování nového headeru <coroutine> jsem skončil na tom, že nejsem schopen kód přeložit, protože překladač neví, co je to task<> a nevěděl to ani v případě std::task<>. Obsah typu task<> byl pro mne záhadou. Moje pokusy s korutinami tak skončili se závěrem který lze volně parafrázovat jako “hmm, možná to ještě není dodělaný, třeba v nové verzi gececečka to bude”
Dva roky uběhly jako voda a kde nic tu nic, ticho po pěšině. Nic se nechystá a letmý pohled na C++23 nenaznačuje, že by se chystal nějaký upgrade pro STL. A teď mne nepřerušujte, já vím, že na githubu existuje projekt CoroCpp, to ale není můj styl. Nová featura by přece měla být nasazená v STL ne?
Normální funkce (rutina) je část kódu, který je na začátku spuštěn, běží, doběhne na konec a skončí. Pokud v rámci rutiny volám jinou rutinu (funkci, proceduru, podprogram), tak současná rutina je přerušena dokud volaná rutina neskončí. Korutina oproti tomu může být přerušena uprostřed svého běhu – a zde se myslí kooperativně, tedy že sama korutina chce (protože jinak to není nic nového, thready umí C++11). Později může být korutina znovu vyvolána s tím, že pokračuje na místě, kde byla přerušena.
K čemu se to hodí?
Hlavní výhodou je, že samotný kód nemusí vůbec brát ohled na to, že je součástí nějakého dalšího prostředí. Jediný co musí takový kód udělat je čas od času udělat await, čímž zbytku světa oznamuje svou připravenost na přerušení
(následující příklady nejsou c++)
data = await connection.read()
for(;;) {
decision=sim_monster(world);
move_monster(decision);
await next_step();
}
C++20 zavádí několik nových klíčových slov
Jakmile ve funkci použijeme jakékoliv z výše uvedených klíčových slov, stává se z funkce korutina. Překladač bude od nás jako programátora požadovat moře další informací, tak aby byl schopen překlad korutiny zajistit. Samo se to nezařídí.
Jsem člověk, co rád chodí až k jádru pudla, nespokojím se jen s nějakou “magií”. Takže nechte mě, abych vám přiblížil, co se odehrává “úplně na dně”. C++ odjakživa balancuje mezi nízkoúrovňovým a vysokoúrovňovým jazykem. Ke spoustě moderním featurám nutně potřebujeme i nějaký runtime, který představuje standardní knihovna (stdlibc++). Snahou tvůrců je však minimalizovat nároky na tento runtime protože jazyk C++ je také cílen na zařízení, kde pro větší runtime není moc prostoru.
Korutiny jsou implementované jako stackless. Při jejich uspávání a probouzení nedochází k přepínání celých zásobníků. Korutiny nepoužívají zásobník pro ukládání lokálních proměnných, ale mají k tomuto účelu připravený předalokovaný rámec (frame). Představte si to jako třídu, ve které každá lokální proměnná korutiny je mapována na členskou proměnnou té třídy.
class korutina { public: korutina(argumeny):args(argumenty) {} void run() { …kod korutiny …} private: args, local variables, resume point, promise_type promise; };
Nečekané! Korutiny jsou jen syntaktickým cukrem pro třídu! Jako by to bylo někdy jinak? Co jsou třeba lambda funkce? Že by “zakamuflované” třídy? V C++ je všechno vlastně třída!
To co se tady děje je, že jakmile spustíte korutinu, spustí se normálně funkce run(), která v okamžiku, když narazí na bod přerušení, si jen zapamatuje místo od kterého bude příště pokračovat a normálně skončí. Když pak je korutina obnovena, opět se spustí funkce run() a pouze skočí na místo, na kterém korutina byla přerušena a jede se dál. Dalo by se to naprogramovat jednoduše
int _state = 1; void run() { switch(state) { case 1: start();_state++;break; case 2: resume_point1();_state++;break; case 3: resume_point2();_state++;break; case 4: finish();break; } }
Podle tohoto receptu zvládnete naprogramovat simulaci korutin ve starším C++ bez podpory překladače.
Výhodou korutin je, že nic z toho programovat nemusíte. Všechno tohle za vás překladač udělá sám. Je ale dobré mít představu, jak to funguje, aby se člověk vyhnul chybám a také přehnaným očekáváním. Také to trochu vysvětluje, proč to zatím není v STL. Protože tohle je evidentně finální produkt.
Co je to tedy ten task<>?
Při použití korutiny očekává překladač, že návratový typ korutiny umí převést na promise_type. Tento typ pak představuje námi dodanou třídu, která se stane součástí instance korutiny. Třídu instanciuje překladač při zavolání korutiny. Výše zmíněný task<> je teda nějaká třída, která pak představuje návratový typ korutiny – něco jako future, proměnnou, která v době návratu nemusí mít hodnotu. Tady pozor, nezaměňovat se std::future, sice se to jmenuje stejně, ale je to něco jiného. Zde to je myšleno jako název konceptu. Tento typ zároveň přes promise_type určuje chování korutiny (ano, ten vedlejší typ je konceptem promise). Mohou být další typy jako generator<>, lazy<> nebo cokoliv co si vymyslíme a bude mít nějaké speciální chování, nebo speciální futuru (a k ní vlastní promisu).
Třída představující promise_type má nějaké povinné členy a nechci je teď tady podrobně procházet, přesnější informace najdete na cppreference. Jen ve zkratce:
Aby toho nebylo málo, samotná operace co_await nefunguje automaticky. Očekává, že jí dodáte objekt, která objekt má implementované nějaké metody
Awaiter typicky nejprve prověří, jestli objekt, na který chci čekat není už náhodou připraven (await_ready). To je jen zkratka, aby se přeskočily nějaké náročnější operace. Pokud objekt není připraven, pak je zahájen proces uspávání aktuální korutiny které je ukončeno zavoláním await_suspend(). V této části se awaiter dozví handle uspané korutiny a tohle handle nejspíš někam zaregistruje, kde později jiná část programu, která spravuje onen objekt, skrze handle korutinu probudí, jakmile se objekt stane připravený. A jakmile k tomu dojde, je zavolaná metoda await_resume, kterou může korutina vyzvednou výsledek operace, a nebo i výjimku – tak, že ji z funkce vyhodí (std::rethrow_exception).
Co je asi ne úplně očividná výhoda je, že objekt awaiteru během zastavené korutiny nikam neodejde, je stále platný a můžete jej použít k uložení jakýchkoliv informací aniž by bylo třeba alokovat nějakou paměť.
V předchozím odstavci jsem se zmínil o handle. Toto handle je definováno v STL a jediným aplikačním rozhraním ve standardním knihovně. Handle představuje !_pointer_! na pozastavenou korutinu. S tím objektem můžete udělat:
std::coroutine_handle<> je také cestou, jak získat ukazatel na promise dané korutiny (a existuje i opačná konverze kdy z ukazatele na promisu můžeme získat handle).
I když tento objekt může nabývat hodnoty nullptr (se kterou se nedá dělat vůbec nic), tak má definovanou ještě jednu „neutrální hodnotu“ a to std::noop_coroutine(). To je pointer, který ukazuje na korutinu, která, když je obnovena, tak okamžitě skončí. Hodí se to v awaiterech, kdy chcete explicitně vrátit běh kódu do frame původního volacího.
A to je celé API v rámci STL.
Zde jsem si nechal prostor pro shrnutí věcí se kterými budete zápasit
Tady je hezky vidět, jak minimalistická je implementace korutin, že standard neřeší okrajové podmínky, ale nechává je na programátorovi, aby si je ošetřil. U většiny těchto okrajových podmínek se nic netestuje a maximálně se dozvíte, že pohybem kodu takto na okraji si koledujete o UB (undefined behavior).
Nepsal bych tenhle příspěvek, aby to nebyla zároveň reklama na můj projekt knihovny primitivních tříd pro práci s korutinami (jo já vím, že existuje CoroCpp sakra, ale … nelíbí se mi, není pro mne dostatečně kiss, je to spíš moloch)
https://github.com/ondra-novak/coroutines_classes
Je to je kolekce hlavičkových souborů,žádné libky, nic co by bylo třeba přibalovat k projektu. Získáte tak k dispozici třídy task<>, lazy<>, generator<> ale i primitiva jako future, promise, mutex, thread_pool, scheduler, queue, nebo dvě třídy pro pattern publisher-subscriber. Nehledejte zde asynchronní IO operace, podporu sítě ani jiné pokusy převést C++ na jazyk s dispatcherem, to už by bylo nad rámec konceptu kiss. To si naprogramujte sami. Já se snažil jen doručit základní primitiva
Vzpomeňte si, jak se změnilo programování javascriptu, když přišel async-await
async read_json() {
var resp = await fetch(url);
return await resp.json();
}
task<json> read_json() { auto resp = co_await fetch(url); co_return co_await resp.json(); }
Určitě je čitelnost mnohem lepší, představte si, že byste to psali pomocí callbacků
void read_json(std::function<void(json)> cb) { fetch(url, [cb](auto resp) { resp.json([cb](json value) { cb(value); }); }); }
… protože obě funkce, jak fetch tak resp.json() jsou asynchroni. Korutiny navíc řeší i odchyt výjimek, zatímco callbacky velice těžko.
Z hlediska paměti: korutina si alokuje paměť na haldě pro svůj rámec. Co myslíte, jak je to s paměti v případě použití callbacků? No, je to ještě horší, protože zde si paměť na haldě alokuje každý callback (std::function alokuje paměť na haldě). Takže z hlediska četností alokací mohou korutiny výrazně ušetřit, pokud jsou dobře použity.
Ještě bych zmínil jednu výhodu. Mnoho věcí lze v korutinách řešit lock-free (pouze s pomocí atomických operací). Například v mé knihovně mutex je lock-free, ale třeba většina co_await je řešeno jako lock-free. Pokud se někde objevují zámky, tak zejména proto, že se očekává, že danou funkci budou volat i normální funkce, které je třeba při konfliktu zastavit syscallem.
To byl takový “přehled do hloubky” jehož smyslem mělo být poodkrytí roušku tajemství korutin v C++20, které se často v tutoriálech nedozvíte. Zároveň tím tedy představit mou knihovnu, která některé problémy korutin řeší. Ne, nemusíte ji začít hned používat, klidně to jen prozkoumejte. Projděte si příklady, dají se přeložit, debuggovat (použil jsem clang 14) Ale téma je široké, je možné, že se k tomu někdy vrátím.
Zdravim,
je to problem jen urciteho prekladace, nebo je situace obdobna, ci dokonce stejna i na jinych prekladacich (napr. llvm vs. gcc vs. Visual C++ atd....)? Asi nema smysl to Vam psat, ale ta implementace standardu byla vzdycky malinko jina a na jine uruvni u kazdeho prekladace. A tak by me zajimalo, jestli - pokud by to clovek chtel vyuzit a bylo to mozne - stacilo treba jen zmenit kompilator.
Ted netuším, na kterou část reagujete.
Víceméně se implementace GCC a Clang neliší, nebo jen v nuancích. Clang mi přijde, že generuje o pár procent menší framy a optimálnější kód v řádu promile, než GCC. Clang umí rozbalit a optimalizovat různé konstrukce awaiterů, takže nakonec to co má několik desítek řádek kódu se smrskne na jednu instrukci. Přepínání framů umí lépe optimalizovat Clang, který mezi corutinama skáče instrukcí JMP. Zatímco GCC někdy nezvládne TCO a vytváří zbytečně zásobníkové framy, ale naštěstí se tak neděje při symetrickém transferu, o tom snad ještě napíšu.
Obecně třeba s výpočtem velikosti frame je problém, že jeho velikost se spočítá až po optimalizaci kódu, kdy už musí být všechny konstanty známy. Tohle číslo pak tedy pouze propadne do operátoru new pro alokaci jako "ne-konstanta" (není to constexpr)
GCC oproti tomu má několik ještě neopravených a nepochopitelných bugů, které se v clangu nijak neprojevují. Jako je dost znát, že to má dětské nemoci, protože jako Clang, tak GCC umí nevhodně napsaným kódem sestřelit na SIGSEGV
Jak to dělá Visual C++ netuším, neměl jsem tu čest.
V ostatních "problémech" - něco definuje norma, ale platí, že to co norma nedefinuje (například MT Safe u korutin), to je UB. Je třeba se vyhnout takové situaci - synchronizovat
Diky za doplneni.
Ptal jsem se kvuli uvodu. Pochopil - asi blbe - jsem jej tak, ze ani po dvou letech se situace (na danem kompilatoru) moc nezlepsila od dob co jste to testoval prvne.
Ne, tohle je finální produkt pro C++20. Cppreference obsahuje kompletní popis platný v té normě. Problém byl u mě, že jsem to takhle nepochopil
Jen se hloupe zeptam: kdyz zavolam co_await fetch(url), tak kdo vykona tu funkci fetch? Musim mit nejake separatni vlastnorucne managovane vlakno, ktere kouka do nejakeho seznamu korutin a ty pousti? Nebo se nejake vlakno pro moji korutinu objevi nejak automagicky? A nebo tomu vubec nerozumim?
funkce fetch() musí být napsaná tak, aby vrátila objekt awaitera. Tam začíná celá magie. Představ si, že to není fetch (moc složitý) ale třeba recv (ze socketu)
co_await postupně zavolá tři funkce awaiteraTakže tvá odpověď na otázku. Záleží na implementaci. Pokud daná funkce něco řeší asynchroně, musí si k tomu zařídit nějaká vlákna a postavit vlastní asynchroní systém. Korutině je to jedno, ta komunikuje s tím mechanismem přes awaitera, což je takový zástupce toho mechanismu. Výhodou je, že dokud celá operace je stále v procesu, instance awaitera je po celou dobu k dispozici. Jakmile je operace kompletní, awaiter je zdestruován
mam radsi C a k C++ jsem donucen praci.
Rust mozna jednou prijde na pretres, ale zatim se mi nechce.
a najednou jsem se chytil Go(langu), je to jako C, jsem spokojeny,
nema to leve a prave reference jako C++11 a ma to jen pointery jako C,
jsem spokojeny.
v praci budu nadale drtit C++11, ale v soukromi krasne delam v GO
a to ma svoje krasne goroutine. podekuju stejnym lidem co delali C :-)
Intenzivně se zabývám programováním zejména v jazyce C++. Vyvíjím vlastní knihovny, vzory, techniky, používám šablony, to vše proto, aby se mi usnadnil život při návrhu aplikací. Pracoval jsem jako programátor ve společnosti Seznam.cz. Nyní jsem se usadil v jednom startupu, kde vyvíjím serverové komponenty a informační systémy v C++
Přečteno 51 062×
Přečteno 23 938×
Přečteno 22 869×
Přečteno 20 949×
Přečteno 17 759×