Zamyšlení se nad korutinami v C++20

25. 4. 2025 23:10 (aktualizováno) Ondřej Novák

V tomto příspěvku se vrátím ke korutinám, shrnu v jakém stavu je C++  5 let po zavedení do normy a jaké osobní zkušenosti s korutinami mám.

Korutiny v C++ – udělej si sám

Stav korutin v době vydání do verze 20 krásně vystihuje tento obrázek

Cörutin

Jestli někdo doufal, že se v tomto směru něco změnilo od té doby, tak ne, nezměnilo. V C++23 přibyl synchronní generátor, který však spadá pod < ranges> a to je vše. 

Při hodnocení „stavu komunity“ se budu opírat o video z CppConu Deciphering C++ Coroutines Part 2 – Mastering Asynchronous Control Flow – Andreas Weis – CppCon 2024. Doporučuji shlédnout i první díl. Na začátek chci říct, že se mi prezentace líbí, minimálně ta první část, kde jde o vysvětlení celého systému, tedy jak funguje i včetně ukázky, jak vznikají propojení zásobníky jednotlivých korutin. Ale mám k videu výhrady, a týkají se druhé části. Tam už se řečník nevypořádal s podle mne důležitými otázkami, týkající se zejména probouzení korutin. Legrační vypadá jeho návrh předávání referencí na plánovač skrze promise. Nevypořádal se ani s otázkou, proč v C++ byla zvolena varianta stackless korutin. Asi bych čekal, že řečník má v s korutinami praktické zkušenost a bude vědět víc nad rámce své přednášky – pokud tedy to co přednáší také prakticky aplikuje – což nemusí být pravidlem u akademiků. Pokusím se s tím vypořádat já, ale hezky postupně

Jestli někdo četl mé předchozí články, tak si všiml jistého patternu. Vyvinu knihovnu, napíšu testy, napíšu článek a začnu knihovnu prakticky používat. A zjistím, že to dře, není to ono, nepoužívá se to intuitivně. Takže vyvinu novou knihovnu, napíšu testu, napíšu článek a zase začnu knihovnu prakticky používat. A zase to dře. A další cyklus nemusím opakovat. I tak ale mám pocit, že jsem se posunul, a mohu tedy aspoň potvrdit, to co tvrdí Andreas Weis ve svém prvním díle, že korutiny jsou těžké. 

Takže asi znám důvod, proč v STL nenajdeme žádné další nástroje. Korutiny lze uchopit různě a ukážu vám, že můj pohled se dnes liší o toho, co se běžně objevuje v komunitě, ať už na CppCon, v různých návodech na Webu, ale třeba s organizací knihoven cppcoro nebo  libunifex

Úvodní shrnutí: stackless korutiny v C++

Ač jsem už princip korutin vysvětloval, přesto si myslím, že by to chtělo stručně shrnout a zopakovat

  • Korutina je funkce, která se může v průběhu své exekuce přerušit a být posléze znovu zavolána, s tím, že pokračuje v místě přerušení
  • Korutina je vždycky přerušena z vlastní vůle, tedy korutina se sama rozhodne, kdy bude přerušena. Její další běh je však již v rukou třetí strany
  • Korutina si musí udržovat svůj stav

Zejména kvůli třetímu bodu je zřejmé, že korutina není obyčejná funkce, protože ty bývají bezstavové. Z ostatních nástrojů, které v C++ známe se korutiny nejvíce podobají lambda funkcím, které mohou mít svůj stav uložený v closure. Nicméně lambda funkce se nemůže přerušit a kdykoliv je zavolána, vždy se spustí od začátku. Korutinu lze ale lambda funkcí simulovat

auto corosim=[state=1](Args ... args) mutable {
  switch (state) { case 1 : // first part // .... state=2; break: case 2 : // second part // .... state=3; break; case 3 : // third part // .... state=4; break; //a tak dále } };

Pro náročnější čtenáře jsem si připravil funkční příklad generátoru (korutina s přerušením na yield), který je napsán v C++17, tedy bez podpory korutin z verze 20. Tam se právě používá lambda funkce (a pár maker)

Rozdíl mezi takto napsanou lambdou a korutinou je v zásadě jen v tom, že korutinu vytvoří překladač z kódu, který používá příkazy korutin co_return , co_await a co_yield. Tyto příkazy představují body přerušení. Překladač tak vytvoří kód, který obsahuje různé cesty od místa zavolání do místa návratu, přičemž oba konce (zavolání a návrat) jsou body přerušení v korutině. Navíc kód optimalizuje s ohledem na to, že si může udělat analýzu celé korutiny a zjistit, kdy se přerušovat bude a kdy ne a tyto znalosti právě využit při optimalizaci

Příklad jednoduché korutiny

             constructor
                  │
                  ▼
             ┌─────────┐
    ret      │ suspend │
             └─────────┘
             ┌─────────┐
resume_pt1() │ resume  │
             └────┬────┘
                  │◄─────────┐
                  │          │
                  ▼          │
             ┌─────────┐     │
    ret      │ suspend │     │
             └─────────┘     │
             ┌─────────┐     │
resume_pt2() │ resume  │     │
             └────┬────┘     │
                  │          │
                  ▼          │
                 / \         │
                /   \        │
               /cond?\───────┘
               \     /
                \   /
                 \ /
                  │
                  ▼
             ┌─────────┐
   store     │co_return│
   result    └────┬────┘
                  ▼
             ┌─────────┐
   ret       │ suspend │
             └─────────┘
              destructor

Všimněte si, že překladač nemá problém vygenerovat skok z prostředka těla druhé funkce doprostřed těla první funkce.

Z výše uvedeného jednoznačně vyplývá, že korutina je objekt, který překladač vyrobí podle obsahu korutiny. Na rozdíl od lambd však tady nevstupuje do role typový systém, všechny korutiny jsou dynamická záležitost. Neexistuje tedy typ korutiny jako je typ lambda funkce a ke korutinám se přistupuje skrze abstraktní rozhraní – virtuální funkce – k tomu se dostanu.

Kdykoliv volám korutinu, vznikne nová instance – s novým stavem – a ta zaniká, když korutina skončí. To mi umožňuje v korutinách používat rekurzi, každá další úroveň rekurze je novým stavem

Implementace překladači

Norma C++ nedefinuje způsob implementace a pokud o něm mluví, tak jen jako doporučení. Zadefinované je, že každá korutina má coroutine state, což je kus paměti alokované v heapu, případně někde jinde, pokud přetížíme alokátory. Představa je taková, že v okamžiku, kdy se korutina rozhodne přerušit – uspat – proměnné výpočtu a lokální proměnné se uloží do toho stavového paměťového rámce. V okamžiku probuzení kourinty se pak stav obnoví. Na cppreference se doslova píše

The coroutine is suspended (its coroutine state is populated with local variables and current suspension point).

Prakticky to ale takhle nefunguje. Problematické je tady slovo „populated“, což evokuje operaci přenesení lokálních proměnných do stavového objektu a případně zpět při probuzení. Problém je v tom, že v C++ nemůžete jen tak přesouvat proměnné dle libosti. Objekty lze přesouvat jen přes move nebo copy konstruktory a jak by se naložilo s objekty, které mají přesun zakázaný. Třeba takový std::mutex jako proměnná korutiny je zcela určitě možný. Překladače na to jdou jinou cestou, a každý z velké trojice volí jinou strategii

Microsoft (MSVC)

Microsoft na to šel asi nejjednodušší cestou. Díky tomu, že norma nedefinuje formát stavu korutiny, rozhodli se v Redmondu, že nechají překladač spočítat a realizovat alternativní zásobník. Zásobník je alokovaný v takové velikosti, aby pokryl všechny možné cesty kódu korutinou. Korutina je tedy přeložena tak, že všechny lokální proměnné, ale i proměnné které vznikly během výpočtů (temporary), se ukládají v tomto alternativním zásobníku. Samotný proces přerušení korutiny tak nepřináší žádné operace navíc. Handle korutiny jednoduše obsahuje adresu tohoto alternativního zásobníku (spolu s povinnými metadaty). 

Výsledkem tohoto rozhodnutí je, že korutiny v podání Microsoftu mají největší rámce vůbec. Bavíme se zde o 3× a 10× větší paměťové náročnosti, než je u GCC nebo Clang. 

V tomhle systému jsem také objevil jeden (doposud neopravený) bug, který může vést k chybě use-after-free (vytvořené překladačem). Chyba se projeví, pokud korutina provádí zničení sebe sama na konci svého běhu a zároveň provádí symetrický transfer do jiné korutiny. Sebedestrukce korutiny není považováno za UB, je zhruba na stejné úrovni jako delete this. Chybu lze demonstrovat právě na pseudokódu, který delete this používá

(pseudokod)
handle on_finish_execution() {
    handle nx = get_next_coro();
    delete this;
    return nx;
}

Předpokládejme, že on_finish_execution() je poslední funkce, která se volá na objektu, který bude zničen. Z tohoto hlediska kód neobsahuje chybu. Problém je, že lokální proměnná nx je realizovaná v paměti stavu korutiny, protože se zde uplaťňuje RVO (return value optimization). Tím pádem příkaz delete this uvolní paměť proměnné nx a v závislosti na optimalizaci dalšího kódu buď dojde ke zničení hodnoty nx nebo k zápisu do uvolněného stavu. 

Naštestí, tahle chyba se objevuje jen v DEBUG verzi. V RELESE se výsledek předává v registru a k zápisu do stavu nedojde.

GNU (GCC)

V GCC na to šli trošku chytřeji. I v tomto případě některé proměnné „žijí“ v stavovém rámci, takže přerušení korutiny neznamená žádnou operaci navíc. I tady handle korutiny obsahuje adresu alokovaného rámce, který obsahuje stav a povinná metadata. Ve stavu korutiny se realizují pouze proměnné, které je třeba zachovat mezi přerušeními. To se týká nejen lokálních proměnných, ale i proměnných, které dočasně vzniknou během vyhodnocování výrazu co_await. Naopak příkladem proměnných, které se realizují na zásobníku mohou být třeba proměnné uvnitř nějakého strukturovaného bloku, který neobsahuje  co_await

Foo f; //f : coro state
co_await f.read();
for (int x: f) {  //x : stack, iterator : stack
    std::cout << x << "\n";
}

Tato jednoduchá optimalizace ušetří spousta místa ve stavovém rámci. Je ale dobré překladači nekomplikovat život složitými co_await výraz. Například následující dva výrazy mohou znamenat totéž ale druhý generuje menší rámec

// 1.
co_await foo(bar(get_param1(),get_param2(), get_data())).read_async();

// 2.
auto foo = bar(get_param1(),get_param2(), get_data());
co_await foo.read_async();

Jde o to, že všechny návratové hodnoty z funkcí get_param1() , get_param2(), get_data() se musí udržet zkonstruované během co_await , takže budou alokované ve stavovém rámci. V druhé variantě se zdestruují již v první řádce a jediné co je třeba zachovat je proměnná foo a případně její awaiter. (disclaimer: budoucí verze mohou toto lépe optimalizovat)

GCC pro každou takto alokovanou proměnnou má vyhrazený prostor v rámci stavového objektu. Pokud jsou k dispozici debug informace, budeme v gdb vidět i jejich generované názvy

Clang 

Clang volí podobnou cestu jako GCC, ale optimalizuje ještě více a jeho rámce jsou zatím nejmenší. Clang využívá faktu, že pouze jeden bod přerušení je vždy aktivní, a tedy pokud jde o dočasné proměnné ve výrazu co_await, tak pouze jeden co_await je třeba držet, stavy ostatních ne. To umožňuje znovu-používat prostor pro uložení proměnných, které dočasně vznikají a zanikají během co_await. Vzniká tam takový obrovský union, který slučuje stavy všech bodů přerušení do jednoho prostoru. 

Nezjistil jsem, jestli to vede na efektivní kód – je možné že ano, protože menší rámce se spíš vejdou do CPU cache. Ale v clang 15+ (nezkoušel jsem později) jsem narazil na špatně přeložení kód v -O2 u jednoho projektu, kde se masivně používalo RVO u awaiterů. V jednu chvíli si clang neuvědomil, že awaiter z jiného co_await je ještě aktivní a použil jeho prostor pro uložení jiného awaiteru, čímž mu zničil stav. Program tam pak záhadně padal. Pokud jsem kód trochu modifikoval, například jsem přidal ladící výpisy, chyba se přestala projevovat. Nepodařilo se mi najít žádné UB, takže jsem to zařadil mezi chybu překladače. Později jsem kód stejně přepsal a chyba se už neprojevila.

Ovládání korutiny pomocí handle

Každý, kdo drží handle korutiny, jí může ovládat. Třída std::coroutine_handle<T> zpřístupňuje několik metod, které umožňují nějakým způsobem spolupracovat se stavem korutiny. Typ T v našem případě představuje typ tzv promise_type  – viz dále. Nicméně znalost T není kritická jako třeba typu lambdy. Existuje netypová variant handle std::coroutine_handle<>, se kterou bude zbytek kódu (muset) pracovat.

Ať už typová nebo netypová varianta nabízí metody resume() a destroy(). Tyto metody tedy umí korutinu probudit nebo zdestruovat její stav. Je třeba si dát pozor na to, že tyto metody lze volat pouze pokud je korutina v uspaném stavu. Nedodržení tohoto pravidla je UB (a je to obecně kritická chyba). 

Typová verze pak nabízí funkce from_promise() a promise() které umí přistoupit do instance promise_type což je uživatelem definovaná instance asociovaná se stavem korutiny. Tady pozor, neexistuje žádná typová kontrola, takže komu se podaří převést std::coroutine_handle< X> na std::coroutine_handle< Y>, koleduje si o UB.

Existence netypové verze však ukazuje na jednu věc a to na existenci ovládacího rozhraní. Každá korutina, nezávisle na svém obsahu, musí definovat nějaké virtuální funkce, které implementuji resume() nebo destroy(). A skutečně, je tomu tak, i když z pohledu normy C++ je to implementační detail. Dokonce je to tak, že všechny překladače velké trojky implementují toto rozhraní stejně, takže pokud se váš kód zaměří na vlastnosti tohoto rozhraní, tak bude perfektně přenositelný, navzdory tomu, co říká norma (ale opatrně)

Na začátku každého stavového rámce se nachází dva ukazatele 

struct CoroFrame {
    void (*resume)(std::coroutine_handle<>);
    void (*destroy)(std::coroutine_handle<>);

    promise_type promise;
    char state[/*různé*/]; //prostor pro uložení stavu
};

Pokud tedy voláme resume(), předá se řízení první funkci, pokud destroy() tak té druhé. Myšlenkou za tímto návrhem je, že první funkci se mění adresa při každém uspání na základě toho, kde bude korutina příště pokračovat – ukazuje na vstupní bod pro probuzení. Pokud korutina již skončila, je zde nullptr  – ukončená korutina se nedá probudit a pokud to přesto uděláte, program vám skočí na adresu 0.

Prakticky se to takto nepoužívá (aspoň ne v GCC ani v Clang). Vypozoroval jsem, že každá korutina má jeden vstupní bod, ve kterém následně dochází k vyhodnocení skoku na správné místo v kódu. Možná je to rychlejší z hlediska CPU a predikce skoků

V každém případě znalost uspořádání frame korutiny lze využít k určité formě hackování. Vždycky mi bylo líto, že nemohu rozhraní popsané v coroutine_handle rozšířit o vlastní implementaci, která by se chovala jako korutina. Zjednodušilo by to mnoho míst v programu, například bych do awaiteru do funkce await_suspend dodat místo handle korutiny adresu callbacku a v okamžiku dokončení operace by se callback zavolal. Jedinou cestou bylo adhoc korutina, která by callback zavolala, což ovšem znamenalo alokace jejího miniframe na heapu. S použitím tohoto hacku to však už není nutné, mohu si snadno vytvořit „fake“ frame a příslušný pointer naprogramovat na jakoukoliv funkci potřebuji. Když pak adresu převedu na std::coroutine_handle, pomocí standardní funkce from_address(), bude se handle chovat, jako by šlo o korutinu

Kam uložit výsledek?

Vrátím se teď přednášce a poukážu na jednu hlavní odlišnost mezi tím jak „se učí“ korutiny a jak si korutiny organizuji já. Ten zásadní rozdíl je v návrhovém vzoru řešící, kam uložit výsledek korutiny. Běžně se totiž zavádí objekt task<>, který slouží jako „rukojeť“ k běžící korutině a přes který se nakonec vyzvedává výsledek. Ale tento objekt bývá často implementován pouze jako pointer, který buď drží adresu promise_type, nebo typovaný handle korutiny. Typovaný coroutine_handle< T> umožňuje skrz metodu promise() přístup do promise_type korutiny, a tak první co se nabízí je výsledek držet v promise_type. Typickou implementaci task< T> vám vygeneruje kdejaká umělá inteligence

template<typename T>
struct task {
    struct promise_type;
    using handle_type = std::coroutine_handle<promise_type>;

    struct awaiter {
        handle_type coro;

        bool await_ready() const noexcept {return false;}
        auto await_suspend(std::coroutine_handle<> awaiting) {return coro;}
        T await_resume() {return coro.promise().value.value();}
    };

    struct promise_type {
        std::optional<T> value;

        task get_return_object() {
            return task{handle_type::from_promise(*this)};
        }

        std::suspend_always initial_suspend() noexcept { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }

        void return_value(T v) noexcept {
            value = v;
        }

        void unhandled_exception() {
            std::terminate();
        }
    };

    handle_type coro;

    task(handle_type h) : coro(h) {}
    ~task() { if (coro) coro.destroy(); }
    task(const task &) =delete;
awaiter operator co_await() { return awaiter{coro}; } T get() { coro.resume(); return coro.promise().value.value(); } };

Když jsem poprvé viděl korutiny, rozčiloval jsem se, proč taková třída není součástí STL už od počátku. Dnes už se nacházím ve stavu, kdy jsem naopak rád, že tam není. Byť je to doporučená implementace, trpí mnoha problémy, a tak v mých knihovnách ji nenajdete. Zde následuje výčet problémů:

  • Korutina po skončení stále zabírá místo - když korutina skončí, stále alokuje celý rámec, přestože jej už nepotřebuje jen proto, že musí být držen výsledek. Je přitom zřejmé, že když je k dispozici výsledek, korutina už neběží, a jen velmi krátký čas jsou potřeba oba, tedy jak frame korutiny tak výsledek
  • Třída task<T> vyžaduje implementaci korutinou. - Pokud se tato třída objevuje jako výsledek metody na nějakém abstraktním rozhraní, snižuje to míru abstrakce, protože předepisuje, aby metoda byla implementovaná jako korutina
  • Problém více vlastníků - Jakmile je instance třídy task< T> z destruovaná, destruuje se i korutina, ta přitom nemusí být ve stavu vhodnou pro destrukci. Jakmile korutina čeká na nějaké co_await operaci, její handle drží ještě nějaký jiný objekt, typicky awaiter na kterém čeká. V tu chvíli existují dva vlastníci jednoho objektu, kteří o sobě nemusí vědět. Když se jeden rozhodne pomocí handle korutinu zničit, druhý skončí s UB.

Pamatuji si, jak jsem se do toho tehdy zamotával, kdy jsem nevěděl, jestli korutinu sdílet přes shared_ptr, nebo unique_ptr, jestli umožnit víc čekajících nebo to omezit jen na jeden. A nejvíc mne ovšem trápil druhý bod. Na hodně místech v rozhranních jsem často operaci vyřešil synchronně a chtěl jsem vráti bool true nebo false. To například vedlo na to, že jsem měl dvě ukončené korutiny, jedna skončila s výsledkem true a druhá s false a tu jsem vracel jako výsledek té operace.

class IAsyncService {
    virtual task<int> get_value() = 0; //BAD!
};

Tenhle návrhový vzor jsem postupně opustil a dneska pracuji výhradně s dvěma objekty. Tak jak v přednášce Andreas připomíná, že třída task<> je zároveň awaiter a plný dvojí roli, tak to v mém případě už neplatí. Já na to mám dvě třídy

  • coroutine -představuje jen deklaraci korutiny, která odevzdává výsledek do awaitable a automaticky se dealokuje jakmile je ukončena.
  • awaitable -awaiter, který řídí asynchronní operaci a slouží jako místo pro uložení výsledku

Samotný objekt coroutine není ničím zajímavý a běžný programátor s ním nepřijde víceméně do styku (jen pokud potřebuje alokátor). Dal bys použít task<>  s tou úpravou, že před spuštěním korutiny do její promise_type vepíšu pointer na čekající awaitable. Objekt awaitable je napsán univerzálně, takže se dá použít jako návratová hodnota z korutiny (jako async v javascriptu), ale dá se inicializovat mimo korutinu a použít pro spouštění asynchronních operací. V rozhraní vypadá mnohem lépe

struct IAsyncService {
    virtual awaitable<int> get_value() = 0; //GOOD!
};

Ono to tedy vypadá jako prkotina, jen jsem to přejmenoval. Ale v tom to trochu je. Třída task je v komunitě dost ustálena a všichni vědí, co dělá. Název awaitable jsem vybral proto, aby jasně specifikoval, co s návratovou hodnotou udělat – přesně tak – co_await. Přitom nic neříká o tom, co je na druhé straně rozhraní.

                           task VS awaitable

┌──────────┐   ┌──────────────┐     ┌──────────┐   ┌──────────────┐
│  task    ├──►│ coro header  │     │awaitable │   │ coro header  │
└──────────┘   ├──────────────┤     ├──────────┤   ├──────────────┤
               │ promise_type │     │          │   │ promise_type │
               │  ┌────────┐  │     │ result ◄─┼───┼──            │
               │  │ result │  │     │          │   ├──────────────┤
               │  └────────┘  │     └──────────┘   │    state     │
               ├──────────────┤                    │              │
               │    state     │                    │              │
               │              │                    │              │
               │              │                    └──────────────┘
               │              │
               └──────────────┘

Kopyto na awaitery

Vyvinout si vlastní typ korutiny není velký problém. To se jednou naprogramuje a může se to používat do nekonečna. Horší situace je právě s awaitery.

Připomeňme si, co je awaiter. Jedná se o třídu, která definuje 3 veřejné metody

  • await_ready – vrací false, pokud je třeba korutinu uspat.
  • await_suspend – oznamuje, že korutina byla uspána a předává její handle awaiteru
  • await_resume - volá se po probuzení a musí vrátit výsledek operace  co_await

Na chvíli zapomeňte, že jsem vám ukazoval awaitable. Jak by vypadalo takové I/O rozhraní, kde máte operaci čtení a zápis?

```
struct ReadResultAwaiter {
   bool await_ready();
   void await_suspend(...);
   std::string_view await_resume();
};

struct WriteResultAwaiter {
   bool await_ready();
   void await_suspend(...);
   bool await_resume();
};


virtual ReadResultAwaiter read() = 0;
virtual WriteResultAwaiter write(std::string_view) = 0;


```
  • Pro každou asynchronní operaci musíme deklarovat speciální awaiter. To je velké množství kódu! Je třeba říct, že pro speciální, výkonově kritické operace se to určitě vyplatí, zvlášť když kód awaitera je viditelný pro překladač (je inlinován). Tam překladač může celou operaci co_await navrhnout s maximální efektivitou a například eliminovat zbytečné operace při uspávání a probouzení. Avšak v „běžném“ kódu na to budeme nadávat, upíšeme se k smrti (jestli mi tedy nepomůže copilot)
  • Všimněte si, že funkce jsou virtuální, to znamená, že implementovat je musí někdo jiný, ale oba awaiteři virtuální být nemohou. Třída která implementuje obě funkce se bude muset přizpůsobit existující implementaci awaiterů, kterou jsou nejspíš definované na abstraktním rozhraní (neabstraktně). 
  • Z názvu třídy není poznat co je vlastně návratovou hodnotou - Poznáte že ReadResultAwaiter vrací std::string_view?   Poznáte to, že na tom máte dát co_await?

    A poznáte to všechno u awaitable<std::string_view>

To vedlo k tomu, že jsem hledal vhodné kopyto na awaitery. Nějakou šablonu, která by mi umožnila tento kód generovat překladačem. A zároveň abych si mohl přetížit awaiter v implementační části kód bez zásahu do interface. A k tomu právě awaitable< T> vznikl (odkaz)

virtual awaitable<std::string_view> read();
virtual awaitable<bool> write(std::string_view);
  • Třída awaitable<T> je takovým pseudo-polymorfním objektem něco jako std::variant. V danou chvíli může obsahovat přímo výsledek operace, nebo výjimku (pokud operace skončí s chybou), nebo může být bez hodnoty jako std::optional, ale také může obsahovat lambda funkci, která iniciuje asynchronní operaci. Případně může tato funkce aktivovat jinou korutinu, která pak dodá výsledek
  • Třída implementuje všechny tři metody s možností polymorfní abstrakce – což je výhodné hlavně v abstraktních rozhraní s virtuálními funkcemi. 
  • Funkce await_ready() se dá jednoduše ovládat konstruktorem. Pokud nastavíme výsledek při návratu z funkce, je automaticky vrácen true – výsledek je ready. Pokud však nastavíme lambda funkci, nebo korutinu, výsledek je false, a předpokládá se zahájení suspend
  • Funkce await_suspend() přímo volá nastavenou lambdu. Lambda dostává placeholder object „result“, do kterého posléze může kdykoliv zapsat výsledek, což způsobí probuzení čekající korutiny. Pokud je v objektu nastavená korutina, dojde k přepnutí (symmetric transfer)
  • Funkce await_resume() pak jednoduše uložený výsledek vrací.

Hlavním přínosem je tady ovládáním await_suspend() lambdou. To dává implementační funkci možnost definovat vlastní implementaci nezávisle na definici původního rozhraní, kde to nešlo, jak jsem výše ukazoval. Díky možnosti použít funkci s closure pak může být awaiter asociován s nějakou další stavovou informací.

Instance třídy awaitable<> používá small-object-optimization tak, že pro malé lambda funkce se ani nealokuje extra paměť na heapu. Minimální prostor je zvolen tak, aby se do closure vešel pointer na this a 1× std::string, přitom pokud je vracený výsledek větší, o to větší může být i closure lambdy.

Kromě toho lze awaitable<> ovládat i v normální funkci, protože nabízí metody pro synchronizaci s korutinou (blokující čekání).

Jak se budí korutina?

Opět se vrátím k přednášce ve které řečník víceméně _neukázal_ problematiku buzení korutin. Zmiňuje se jen o nějakém plánovači, ale stejně jako třídu task<>, tak ani žádný bájný plánovač v C++ nenajdete. Ten si musíte napsat. Pokud ho chcete.

Podívejme se na to v širší souvislosti. Některé programovací jazyky, jako například JavaScript, mají plánovač. Někdy se mu říká dispatcher. Najdete ho hlavně u systémů, které jsou řízené událostmi. Pokud nastane událost, je vložena do fronty plánovače a ten ji vyzvedne a zvolá uživatelem definovanou funkci, která událost vyřizuje. Kód se zase vrací do plánovače. Existence plánovače pro javascriptové klíčové slovo await je přímo kritická, protože garantuje, že čekající korutina je probuzena přímo z plánovače i v případě, že použíjeme Promise a v nějaké hluboké rekurzi předáme do promise výsledek. Korutina se ale probudí až když aktuální event-handler je ukončen.

V C++ není vestavěný plánovač, pokud jej chceme, musíme si jej naprogramovat. To celé má problém v tom, že celý systém bude pouze specifikum naší codebase. Druhým problémem je, že ne všechny algoritmy lze použít s plánovačem. A za třetí si myslím, že pokud C++ nemá standardní plánovač, nemohu tento prvek zakomponovat do knihovny korutin, bylo by lepší se obejít bez plánovače

Korutinu lze probudit, pokud máme instanci std::coroutine_handle <> a na ní zavoláme metodu .resume().

std::coroutine_handle<> h = ...;
h.resume();

Problémem může být, že korutina se spustí v rámci exekuce funkce resume(). Kód korutiny bude pokračovat do dalšího co_await nebo do dokončení korutiny a pak teprve dojde návratu z resume() – tedy ne vždy! Protože dalším způsobem, jak probudit korutinu je přepnout na ní z jiné korutiny pomocí symetrického transferu

std::coroutine_handle<> await_suspend(std::coroutine_handle<> h) {
    _awaiting = h;
    h = get_ready();
    return h;
}

Tzv. symetrický transfer je omezen pouze na funkci await_suspend a zajišťuje probuzení jedné korutiny, zatímco jiná se právě uspala. Prakticky je to řešeno pomocí instrukce jmp h->resume, přičemž se předává hodnota h jako parametr – viz CoroFrame popsané výše.

V každém případě to znamená, že pokud přes h.resume() probudíme korutinu, může dojít k sérii přepnutí, vše se bude odehrávat v rámci exekuce této metody, a teprve ukončením poslední korutiny v řetězu nebo jejímu uspání bez přepnutí dojde k ukončení exekuce resume().

Problém

Představte si následující smyšlený kód, který kompletuje asynchronní operaci (například se volá, když některá asynchronní operace je dokončena a cílem je zjistit, která to je a předat výsledek). Vidíte kde je problém?

void complete_async() {
    std::lock_guard lk(_mutex);
    auto async_state = get_completed_state();
    async_state->awaiter->set_result(async_state->result);
    async_state->awaiter->get_handle().resume();
    destroy_async_state(async_state);
}

Problém se nachází právě v drženém zámku. Zřejmě asi není zrovna bezpečné probouzet korutinu, když je držen zámek zajišťující exkluzivní přístup k nějakém asynchronnímu providerovu. Tady si koledujeme o deadlock. Toto je ještě jednoduchý příklad, ale reálná kompletace asynchronní operace může být mnohem složitější. Například kompletace přes Windows IOCP (CompletionPort) může vést přes několik úrovní zámků až k místu, kde se z OVRELAPPED bufferu vyzvednou data a přepošlou se správnému awaiterovi. Ale patřičnou korutinu by bylo dobré probudit mimo tuhle vnořenou strukturů volání, ideálně těsně před dalším GetQueuedCompletionStatus – nebo vůbec nechat korutinu probudit v jiném vlákně – ale v případě IOCP to není potřeba, protože nad IOCP portem může v danou chvíli čekat celý thread pool, tam je vláken habaděj.

Objekt „připravená korutina (k běhu)“

Měl jsem mnoho návrhů, napsal jsem o tom dokonce článek zda na rootu. Všechno se nakonec bylo moc komplikované (takže už neplatí). Nejlepším řešením se ukázalo řešení v podobě třídy, jejíž instance drží handle korutiny, která se považuje za připravenou k běhu (těsně před probuzením). Jmenuje se prepared_coro

Třída se doslova chová jako unique_ptr<std::coroutine_handle<> >. (pozor, takhle to nejde napsal). Instanci lze přesouvat, ale ne kopírovat. Drží handle jedné korutinu tak dlouho, dokud není instance zdestruována. Destrukce přitom korutinu probudí (resume() se zavolá v rámci destruktoru). Tímto způsobem mohu z funkce, která provádí completion asynchronní operace vracet připravenou korutinu a pozdržet její probuzení do bodu, kdy to bude vhodné. Příklad:

prepared_coro complete_async() {
    std::lock_guard lk(_mutex);
    auto async_state = get_completed_state();
    prepared_coro p = async_state->awaiter->set_result(async_state->result);
    destroy_async_state(async_state);
    return p;
}

Předpokládejme, že funkce set_result  na awaiteru zároveň vrátí handle spící korutiny v podobě prepared_coro. Tento objekt je vráce z funkce. Pokud volající funkce výsledek nevyzvedne, je korutina probuzena po návratu z volané funkce – což už je mimo zámek. Pokud však volající funkce nechce probouzet korutinu, může výsledek převzít a  vrátit ho ještě o úroveň výš. Takto lze vrátit korutinu až na základní úroveň dispatchera, kde už je bezpečné ji nechat probudit

Určitým rozšířením časem přibylo prepared_coros< N>, které umožňuje vracet až N korutin současně. To se hodí například u epoll dispatcheru, který může v danou chvíli vystavit čtecí a zároveň zápisový event nad jedním socketem. Tam se použije prepared_coros< 2>.

Toto je tedy moje ultimátní řešení na buzení korutin. 

Třída prepared_coro může fungovat i jako callback, takže zařazení nějaké její instance do thread poolu způsobí probuzení korutiny právě v rámci vlákna tohoto pool. 

Třída samozřejmě má podporu i pro symetrický transfer

Proč stackless?

Poslední, na co bych chtěl reagovat je nevypořádání se s otázkou, proč v C++ bylo vybráno stackless řešení. Odpověď, „že takhle se to prostě dělá“ nikoho moc neuspokojí.

Je třeba říct, že stackful korutiny nejsou žádným výdobytkem moderní doby. Chystám článek o jednom projektu z roku 1998, kde už jsem korutiny používal – ty stackful. Problém stackful korutin je, že vyžadují podporu operačního systému. A zejména operační systémy s premtivním multitaskingem mohou mít se stackful korutinami problém.

Vemte si například takový mutex. Pokud jej použijeme ve funkci, která se nakonec v nějaké vnořené úrovni rozhodne o přerušení, zůstaneme se zamčeným mutexem v uspaném stavu. A běda (je to velmi pravděpodobné), pokud dojde k probuzení v jiném vlákně. Z toho důvodu třeba vznikl jazyk Go, který se právě snaží řešit tyhle dosti specifické problémy svým runtime (gorutiny)

Co se samotné podpory operačních systémů týče, tak plnohodnotnou podporu stackful korutin najdeme ve Windows, koho to zajímá víc, nechť si prostuduje Windows Fibers

Co se Linuxu týče, tak tam existují funkce makecontext, swapcontext. To by úpně stačilo, kdyby mi man stránka v mé verzi linuxu nepsala toto: History: glibc  2.1. SUSv2,  POSIX.1–2001.   Removed  in  POSIX.1–2008, citing portability issues, and recommending that applications be rewritten  to  use POSIX threads instead.- rozumný programátor tedy musí považovat tyhle funkce za velmi deprecated – podpora stackful korutin v linuxu tedy není. (odkaz).

V případě MacOS je to podobné, Apple víceméně rozhranní ucontext odstraňuje, nebo označuje za zastaralé

Samozřejmě můžete stáhnout a nainstalovat knihovnu libXYZ (například Boost.Context), které nějak fungovat budou. Tyto knihovny bývají velice citlivě napsané na každou platformu a její specifickou variantu a umí tedy korutiny do systému „nahackovat“. Dlouhodobě to ale podle mne není řešení.

To jsou operační systémy. Pokud se podíváme na platformy bez operačních systémů, například programování mikročipů, tam nám stackful korutiny nepoběží vůbec. A některé mikročipy s hardvardskou architekturou mohou mít problém už jen v přepínání zásobníků, takže ani emulovat to nejde.

Oproti tomu stackless korutiny, tak jak je zavádí C++ podporu operačního systému nepotřebují. Z hlediska CPU a OS se jedná o normální kód, který normálně používá hlavní zásobník. Korutiny jsou prostě jen na míru přeložené objekty se sadou funkcí, které představují jednotlivé cesty mezi body přerušeními. Žádná speciální služba, žádný složitý runtime, žádné speciální instrukce nejsou potřeba.

Přímo pro mikročipy může být omezením používání heapu. Mikročip nemusí podporovat heap operace (staré AVR, ne?). Toje malá, ale ne nutně neřešitelná komplikace, protože korutiny mohou mít vlastní alokátory a není tedy problém rezervovat pro korutinu nějaký statický prostor v paměti SRAM a alokaci si tam realizovat svépomocí. Pokud SRAM je málo, sáhněte radši po Clang, který generuje miniaturní rámce.

Takže to je hlavní důvod, proč bylo vybráno stackless  řešení.

Dva světy

To že se do C++ dostala stackless varianta korutin má důvody ve zvoleném technickém řešení. Otázka zní, zda to má i nějaké nevýhody.

Co se týká strukturování kódu při použití korutin, tak tam velké překážky nejsou. Díky tomu, že korutiny si nativně vytváří zásobník jako spojový seznam svých rámců, lze v korutinách realizovat i komplikované rekurzivní algoritmy, přičemž zůstává zachována schopnost korutinový „řetěz“ uspat v kterémkoliv bodě a po probuzení pokračovat přesně tam, kde došlo k uspání se zachováním celého zřetězeného stavu

Určitou komplikací může být, že korutiny významně využívají heap, a alokace v heapu nebývá nejrychlejší. Toto je řešitelné pomocí alokátorů, lze třeba použít alokátory z std::pmr, například unsynchronized_po­ol_resource pro znovu-používání již alokovaných rámců a tím výrazně redukovat alokace. Pro kratší korutiny pak možná postačí monotonic_buffer_resource, který se alokuje na začátku dostatečně velký aby se do něj vešly všechny rámce. Tam pak volání korutin může být ještě rychlejší (ale pozor, paměť se neuvolňuje).

Mnohem větší problém u stackless korutin je s generickými algoritmy. Jde zejména o již existující algoritmy v STL používající predikát, které nebyly napsány pro korutiny. Je třeba si uvědomit, že korutiny v C++ vytváří dva světy. Svět normálních funkcí a svět korutin. Při volání korutiny z jiné korutiny je nutné všude používat co_await a pro vrácení výsledku co_return. Ve světě normálních funkcí se funkce volají přímo a výsledek se vrací  return

Jako příklad uvedu myšlenku, že bych pro vyhledání určitého prvku v poli použil std::find_if, s tím, že predikát by byl korutina. Takové použití není možné, protože find_if bude vyhodnocovat vráceného awaitera jako obyčejný objekt a hledat v něm vyhodnocení true nebo false. Pokud to awaiter neumí, pak se to nepřeloží. Pokud to ale umí, jako například mé awaitable<>, pak se to přeloží, ale bude to fungovat jinak než by člověk očekával. Predikát se bude vyhodnocovat synchronně, bude to blokující volání. Aby std::find_if uměl hledat s predikátem jako korutinou, musel by být napsaný dvakrát, jednou jako normální funkce a jednou jako korutina

I v tomto směru se dá komplikace obejít, ale jen pro naše nově vytvořené algoritmy a asi pouze tam, kde to pro nás bude mít smysl. Generickou funkci lze napsat s různými variantami vyhodnocení podle toho zda predikát vrací awaitera nebo ne. S výhodou využijeme  if constexpr

bool r;
if constexpr(is_awaitable<std::invoke_result_t<Pred, T> >) {
    r = co_await pred(value);
} else {
    r = pred(value);
}

Tímto způsobem lze mít oba světy v jedné generické funkci. (budeme k tomu potřebovat koncept is_awaitable, který z nějakých mne neznámých důvodů není součástí STL). 

Závěr

Korutiny jsou těžké a já se po několik „skoro“ let snažím navrhnout něco, co práci s jednodušší. Knihovnu, která k tomu vznikla (a je to cca 4 verze téhož) je na githubu. Sám již tuhle knihovnu používám nejen pro IO operace. Další využití mám rozpracované u obchodního robota, kde lze korutiny použít ke komunikaci s burzou. Například mohu vyslat pokyn k nákupu akcie a přes co_await počkat až se pokyn vykoná. Mohu takto jednoduše obchodovat tisíce titulů, aniž bych můj robot musel mít tisíce aktivních vláken. 

Co se videa a směřování komunity, mám z toho takový nemasný a neslaný pocit. Nejsem si jist, že se tomu všichni věnují naplno. Mnoho dalších řečníků na přednáškách používá korutiny ke konkrétním účelům, tam bývají rozhraní upravené na míru jejich problému. Zatím se ke mne nedostaly žádné snahy o obecné knihovní řešení, které by měly šanci se dostat do STL.

Sdílet

  • 28. 4. 2025 9:43

    MarSik

    Barvení funkcí (dva světy) je známý problém i z jiných jazyků. Stejně tak problémy ohledně držení mutexů a dalších zdrojů přes yield point. To stejné nastává u async/await modelu v Pythonu i Rustu.

    V non-async kontextu se async funkce dají volat jen blokujícím způsobem přes lokální executor. V Rustu https://docs.rs/futures/latest/futures/executor/fn.block_on.html a v Pythonu je to třeba https://docs.python.org/3/library/asyncio-runner.html#asyncio.run - a koukám, že v C++ to je stejné (což mě nepřekvapuje, kdyby existovalo geniální řešení, tak ho ostatní převezmou taky).

    Popravdě, ten vygenerovaný handle, korutina nebo Future objekt jsou si ve všech těch jazycích taky hodně podobné. Ve výsledku překladač vytvoří polling generátor a nějakou metodou (await) se provede další krok.

    Interní implementace awaiteru v Rustu je taky vtable a taky specifická pro executor - konkrétně https://doc.rust-lang.org/core/task/struct.Waker.html . Jen mi to přijde, že Rust má hezčí abstrakci pomocí waker.wake() :)

    Jednu věc jsem z blogu nepochopil, co přesně dělá await_suspend? A jak se to liší od co_await?

    Ta analýza struktury pro uložení stavu korutiny dle překladače je ovšem moc zajímavá. Jelikož async Rust používám na malých mikrokontrolerech, tak mě vždycky zajímalo jak je to optimalizované. Ale Rust používá clang, takže to vypadá, že jsem na tom dobře.

    Oddělení executoru mimo standard je taky stejné jako v Rustu (Python má batteries included), pro jednoduché funkce to není problém, ale nastává tam problém s nejednotným api pro registraci nových korutin (spawn / schedule) a pro synchronizační promitiva (mutexy, fronty s podporou await), která potřebují podporu svého executoru. V Rustu je několik soupeřících projektů - tokio, async-std, embassy... - což komplikuje přenositelnost kódu (stejně jako v C++?).

  • 28. 4. 2025 12:09

    Ondřej Novák

    Ahoj, dík za reakci

    Metoda await_suspend implementuje co_await. Ten operator pouze volá postupně ty tři funkce jak jsem napsal, přičemž await_suspend oznamuje awaiteru že korutinu uspal, tady máš její handle a teď rozhodni co dál. V jiném jazyce by se to jmenovalo 'on_suspend' nebo 'after_suspend'

    Awaiter pak může buď říct, že řízení se má vracet volajícímu, nebo dodat handle jiné korutiny, která bude pokračovat, nebo klidně může to přijaté handle vrátit což způsobí okamžité probuzení právě uspané korutiny, to se může hodit, když se mezitím async operace dokončí.

    Dokonce je povoleno vlézt do nějaké hluboké rekurze a v rámci ní zavolat nad handle resume.

    Prostě await_suspend je notifikace o tom, že korutina je uspaná. Já často teprve teď zahajuji async operaci protože mám jistotu, že kód korutiny nemá šanci mi do exekuce zasáhnout takže pak třeba nepotřebuji zámek

  • 28. 4. 2025 13:02

    Ondřej Novák

    Ještě mě teď napadlo, že možná jsem to napsal zmateně

    co_await -> volá postupně tři metody await_ready, await_suspend, await_resume

    Pokud se ale nacházím v kódu, kde nemohu udělat co_await a nějaká funkce mi vrátí awaitera, pak mi nic nebrání tyto funkce volat ručně


    auto awt = stream.read();
    if (awt.await_ready()) {
        auto data = awt.await_resume();
        //....zpra­cování dat ...
    } else {
        awt.await_sus­pend( /* tady musím dodat něco, co implementuje resume */)
    }

    Z hlediska definice nejde o korutinu, ovšem z pohledu awaitera vůbec nemusí být poznat, jestli ten kdo ho ovládá je co_await, nebo mnou ručně napsaný kód.
    Často lze udělat to, speciálně, když mám async operaci, která v drtivém případě může skončit synchronně, tedy await_ready() je většinou true - například zápis do socketu - že tuto operaci volám v normální funkci s optimistickým předpokladem právě synchronního dokončení. Pak ovládam awaitera ručně. Ale pokud by náhodou await_ready() vrátil false, pak si musím vytvořit korutinu v uspaném stavu, a její handle dodat při ručním volání await_suspend() jako parametr. A async operace se pak dokončí v té korutině.

    Jde zpravidla o performance. Korutina se někde musí alokovat, ale synchronní operace alokaci nepotřebuje.

  • 28. 4. 2025 13:39

    MarSik

    Aha! Tady je ta podobnost a rozdíl v tom jak to dělá C++ a jak to dělá Rust. V Rustu není nic jako ready nebo suspend, ta samotná korutina to dělá v rámci svého kódu. V C++ to jsou oddělené metody.

    V Rustu totiž sice může vypadat jednoduchá async funkce takto, ale ta jen používá jiné async metody.

    async fn read() -> Result<...> {
      serial.await // tedy ekvivalent co_await
    }

    Ale taky se dá naimplementovat pomocí nízkoúrovňové Future struktury (zjednodušuju typy..), když potřebuje implementovat to čekání:

    impl Future for StreamRead {
    
      // poll se zavolá, když někdo zavolá await nebo zaregistruje instanci
      // do executoru - což je cca to samé jako await_resume, ale bez
      // návratové hodnoty, Rust se neumí chovat jako iterátor v rámci jedné
      // instance korutiny, prostě čeká na výsledek a pak vytvoří novou korutinu, když chce další
      fn poll(&mut self, ctx: Context) -> Poll {
    
        if Some(data) = serial.read() { // neblokující čtení..
          return Poll::Ready(data);
        }
    
        // někam se musí zaregistrovat waker, to někde ho zavolá, když
        // nastane vhodná událost a to informuje executor, aby znovu zavolal
        // StreamRead::poll
        // funkci jsem si totálně vymyslel, závisí na runtime
        serial.wake_on_interrupt(ctx.waker());
        return Poll::Pending;
      }
    }

    Takže await_suspend je to samé jako vrácení Poll::Pending v Rustu, ale Rust executory očekávají, že ten Waker už byl někam zaregistrovaný tou samotnou korutinou.

    await_resume je víceméně ekvivalent Future::poll. A await_ready nemáme, protože je to detail executoru a schovaný v tom Wakeru. Executor to může zkoušet pořád dokola, nebo nějak vhodně čekat na to, až někdo zavolá Waker::wake. Což někde v executoru poznačí, že korutina se může vzbudit a může to být propojené s nějakou wake-up instrukcí, které třeba na ARM Cortex-M ukončí WFE (Wait for Event), kvůli šetření energií.

    Ten přístup není zase tak odlišný, jen to API je jinak rozhozené mezi tu korutinu a executor.

  • 28. 4. 2025 14:28

    Ondřej Novák

    Jasně, no já jsem při psaní nechtěl kopírovat cppreference, kde to rozebírají a odkud pochází ta věta o "populated with local variables".

    Ale v C++ to fakt není složité, resp je to jednodušší, než je napsáno v dokumentaci, protože celá magie kolem korutin je věc překladače a uspořádání toho kódu, rozsekání na úseky mezi body přerušení.

    Takže ano, jakmile uděláš co_await <výraz>, tak <výraz> musí vrátit něco, co má ty tři funkce. První se udělá await_ready a pokud je true, tak se zavolá await_resume a to se vrátí jako výsledek. Pokud await_ready vrátí false, zavolá se await_suspend a pak se zpravidla dojde k instrukci RET (návrat k volajícímu). Nebo se tedy přepne někam jinam. A ten kdo drží handle (má ho přes await_suspend) zavolá resume, když už je to hotovo. V rámci resume se pak zavolá await_resume a výsledek té funkce se vrátí jako výsledek co_await a korutina pokračuje.

    Jo, je to jak kdyby to byl callback. Nic víc v tom není. Fakt hloupý callback - tady bacha, pokud ho člověk chce volat z přerušení nebo ze signal handleru, nedoporučuju, je lepší si event poznačit a vyzvednout ho v nějakém dispatcheru v hlavním vlákně. Tuhle jsem třeba v linuxu řešit co_await wait_for_break(), což opravdu čeká na SIGINT nebo SIGTERM. Musel jsem si přes eventfd poslat signal do hlavního vlákna a na ten to pak reagoval. Volat resume v signal handleru je sebevražda.

  • 28. 4. 2025 10:05

    Baloun

    Pro nějaký větší použití korutin podle mně C++ potřebuje následující.

    1) Executory, nebo ten Sender/Receiver framework, s návody jak to používat s korutinama.
    2) Implementaci využití bodu 1) pro síťovou komunikaci, nebo ideálně obecně s descriptorama.
    3) Vyřešení, nebo návody, jak řešit sdílení resourců při vícevláknovém zpracování. Např. něco jako strand v asio.

    Jinak si tohle vše musí člověk napsat sám, nebo se rozhodnout pro nějakou knihovnu. Napsat si něco robustního sám je dost časově náročný, a těch knihoven taky není zas tak moc. Tedy aspoň těch co by se nějak víc používaly.

  • 29. 4. 2025 16:06

    mikro

    Vdaka za zaujimavy clanok. "Smekam" pred odbornostou autora, ze tomu tak do hlbky rozumie. Ako dlhorocnemu C++ programatorovi mi je ale z tej syntaxe na grc. Je neuveritelne, ako priserne vie C++ kod vdaka korutinam vyzerat. Cize osobne sa im zatial snazim vyhybat, resp. predstieram, ze neexistuju.