Jak na korutiny v C++20

16. 10. 2022 19:01 (aktualizováno) Ondřej Novák

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?

Co jsou to korutiny?

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í?

  • Asynchronní operace - je potřeba udělat asynchronní operaci a je třeba korutinu uspat, dokud není operace dokončena, pak je korutina probuzena a může pokračovat tam kde skončila. Tady by šlo použít vlákno, ale vlákno je dost vzácným zdrojem a příliš mnoho vláken zatěžuje operační systém, nehledě na to, že některé systémy vlákna nemají (embedded)
  • Generátory - Kdy jednou smyčkou generujeme data a druhou je zpracováváme
  • Simulační prostředí - Představte si simulaci nestvůr ve hrách. Každá nestvůra má vlastní kód, jako by měla CPU pro sebe. Přesto potřebujeme simulovat stovky nebo tisíce nestvůr současně. Místo nestvůr lze simulovat – já nevím – buňky, nebo cokoliv čeho je hodně a každá entita má vlastní program

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(); }

Korutiny v C++ 20

C++20 zavádí několik nových klíčových slov

  • co_await - výše zmíněný await, který definuje místo pro uspání korutiny. Na pravé straně je výraz, který vede na podmínku probuzení korutiny (vede na typ vyhovující konceptu awaiter)
  • co_yield - bývá součástí generátorů, na pravé straně má výraz, který předá volajícímu jako výsledek, zároveň ale po novém vyvolání korutina poběží od tohoto místa dále
  • co_return - použijete místo return, oznamuje konec korutiny, předává výsledek.

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í.

Jak je to implementované

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<>?

Promise

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:

  • initial_suspend() – volá se před spuštěním
  • final_suspend() – volá se na konci
  • get_return_object() – volá se před spuštěním k vyzvednutí futury
  • unhandled_exception() – volá se při výjimce
  • return_value()/return_void() – volá se pro uložení výsledku do futury

Awaiter

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

  • await_ready() – ptá se, jestli už je výsledek k dispozici
  • await_suspend() – volá se po uspání korutiny
  • await_resume() – volá se po obnovení korutiny, má vrátit výsledek

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ěť.

std::coroutine_handle<>

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:

  • zavolat handle.resume() - čímž obnovíte běh vybrané korutiny -
  • zavolat handle.destroy() - čímž korutinu zničíte – a teď pozor! paměť si – nečekaně – musíte řídit samy. Když zapomenete zavolat destroy na dokončenou korutinu tak máte memory leak! Pokud náhodou zavoláte destroy na běžící korutinu, tak máte use after free. Představte si to jako delete na korutinu.

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.

Problémy

Zde jsem si nechal prostor pro shrnutí věcí se kterými budete zápasit

  • co_await a co_yield lze použít jen v korutině. Pokud zavoláte obyčejnou funkci v ní už co_await použít nejde, musíte se vrátit do korutiny, nebo z té funkce udělat také korutinu. Tím přicházíte možnost psát si malé pomocné inlinovatelné funkce, které by za vás dělali věci, které nechcete kopírovat několikrát do kódu a obsahovaly by co_await. Leda se vrátit k makrům (doufám, že tohle bude někdy v C++ relaxovat). Naštěstí, pokud to jde, lze například zavolat korutinu v normální funkci, její návratovou hodnotu vrátit z funkce do korutiny a tam teprve udělat co_await. A nebo si napsat vlastní awaiter, který má definovaný kód před zavoláním a po vrácení z korutiny, tím nahradit naši inlinovou funkci. A na tento awaiter lze pak v korutině zavolat co_await.
  • Každá aktivní korutina musí alokovat paměť na heapu. Lze si ale napsat vlastní alokátor, a ten může tenhle proces nějak urychlit – třeba použít před-alokovaný prostor. Problém akorát je, že překladače zatím neumí dopředu zjistit, jak velký prostor bude korutina potřebovat (snad v budoucnu). Taky se připravte na správu paměti, nějaké chytré ukazatele a vědět stav korutiny, ať třeba nemáte tendenci ničit běžící korutinu – zničit lze pouze pozastavenou korutinu a naštěstí, v tomto případě se poctivě zavolají všechny destruktory všech lokálních proměnných (takže v destruktoru lze na to ještě nějak reagovat).
  • resume hell - jedna korutina obnoví jinou, ta obnoví další, ta obnoví další, atd… a zjistíte, že to je stejné jako by člověk volal korutiny rekurzivně, roste vám zásobník a to i v případě, že zdánlivě žádnou rekurzi v kódu nemáte. Je třeba si uvědomit, že resume není nic jiného, že volání normálního podprogramu (call), vytvoření stack rámce a spuštění run() naší korutinové třídy. Na tohle existuje řešení, ale bez přípravy a nějakého vlastního frameworku se neobejdete
  • multithreading - není problém obnovit korutinu v jiném vlákně. Ale jak je to s bezpečností (mt safe)? Není, žádnou nečekejte. Pokud vám dvě vlákna zavolají resume paralelně, máte problém. Neexistuje interní mechanismus, který by tomu zabránil, jen nějaký váš vlastní framework. Hlídejte si, co běží a co neběží.
  • zámky - to se týká výše zmíněného problému. Zamknout zámek v korutine a pak zavolat co_await si koledujete o malér! Potřebujete zámek který podporuje korutiny. Obecně cokoliv je vázáno na aktuální vlákno, nepatří do korutiny.
  • blokující volání - stejně jako u zámků, korutiny a blokující volání může být problém. Pokud lze blokující volání převést na asynchronní volání, udělejte to
  • dva světy – svět korutin a svět mimo korutiny - jakmile se váš program dostane do korutiny, bude mít trochu problém s okolním světem. Synchronizace korutiny s okolním světem tedy není zrovna jednoduchá věc. Je to podobné jako v javascriptu. Pokud funkce je označena jako async, nelze ji zavolat jen tak, musíme použít await jenže funkce, která použije await musí být také async, atd…

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).

Knihovna

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 futurepromisemutexthre­ad_poolschedulerqueue, 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

  • na task lze čekat co_awaitem
  • task lze joinovat (join())
  • task ohlídá paměť korutiny, sám se postará o její zničení
  • generátor může být synchroní i asynchroní
  • je tu k dispozici mutex, který je kompatibilní s korutinami
  • potřebujete aby korutina reagovala na data z různých zdrojů? použijte frontu (queue)
  • agregujte generátory do jednoho generátoru
  • knihovna řeší resume hell i více-vláknovou bezpečnost
  • atd… – v sekci examples najdete několik příkladů

Výhody korutin.

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.

Závěr

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.

Sdílet