C++20: kde bydlí korutiny

17. 2. 2023 17:25 Ondřej Novák

V předchozích článcích jsem se snažil vysvětlit co jsou to korutiny v C++20, jak jsou implementované interně, jak je správně budit a napsali jsme si šablonu pro jednoduchou korutinu. Bystrý programátor by se však měl také zajímat o tom, kde je uložen stav korutiny a jak probíhá alokace paměti pro tento stav.

Halda

Nebudu chodit kolem horké kaše a rovnou odpovím na otázku. Korutiny většinou bydlí a žijí na haldě. Dává to smysl. Jestliže mohu korutinu v kterýkoliv okamžik přerušit, což znamená, že volající funkce to “vnímá” jako by se kód korutiny ukončil, a později opět korutinu zavolat, přičemž korutina pokračuje tam, kde byla přerušena, znamená to, že zásobník není vhodným kandidátem pro uložení stavu. Z dalších možností nám zbývá halda … a to je všechno. Je samozřejmě možné pro korutiny vyhradit speciální prostor, kde budou uloženy stavy všech běžících korutin, standard takovou možnost připouští, ale není to věc automatická a bude to nejspíš doménou jen specializovaných, typicky embedded systémů,  kde není k dispozici halda. Na klasických PC a spol. je halda jediný kandidát.

Alokace rámce pro korutinu probíhá při prvotním zavolání korutiny a je to vůbec první operace, která se provádí. Na schématech v předchozím článku jsem tuto část vynechal. Zde ji vynechávat nebudeme. Kód začátku korutiny vypadá zhruba takto: (jedná se o pseudokód, který překladač generuje)

task<T> korutina(...) {
        auto *frame = new coro_frame<task<T>::promise_type>;
        auto ret = frame->promise.get_return_object();
        auto awt = frame->promise.initial_suspend();
        frame->set_resume_point(frame->_coro_start);
        if (awt.await_ready() || !awt.await_suspend(frame->get_handle())) {
                  frame->resume();
        }
    return ret;
}

Samotná dealokace korutiny se provádí automaticky, když korutina není uspána na final_suspend(), případně kdykoliv ručně, zavoláním handle.destroy() – tedy musíme si život korutiny hlídat stejně jako jakéhokoliv dynamicky alokovaného objektu. Pozor na to,že destrukci korutiny lze provést jen tehdy, když je korutina uspaná, ale nemusí to být uspání na final_suspend(). Můžeme beztrestně korutinu zničit pokud spí na jakémkoliv jiném awaiteru (musíme také awaiteru zabránit pokoušet se takovou korutinu později budit). Pokoušet se zničit korutinu, když běží, znamená, že běžícímu kódu sebereme podlahu, výsledkem bude … hádejte co.

A výkon trpí

Představme si tento kód – nazval jsem ho asynchronní transformátor

for (auto iter = begin; iter != end; ++iter) {
    *out = co_await do_transform(*iter);
    ++out;
}

Pokud tomu předložím třeba vektor s milionem prvků a za předpokladu, že do_transform je korutina, pak se milionkrát provede alokace a milionkrát dealokace. Samozřejmě výrazně záleží na tom, co do_transform dělá, pokud je to náročný asynchronní výpočet, pak alokace může stát titěrný výkon procesoru oproti výkonu pro do_transform. Ale pokud by například funkce pouze převáděla znaky čtené ze streamu na velká písmena, tam se výkonový dopad alokací projeví velice výrazně

Druhým příkladem mohou být parsery, které pro parsování dat provádí rekurzivní sestup – typicky pro LL(1) gramatiku. Jejich implementace právě využívá zásobník programu pro implementaci zásobníkového automatu. Pokud by v kterékoliv části bylo třeba například číst stream a čekat na výsledek přes co_await(), pak v důsledku toho, že C++ korutiny jsou stackless, každá úroveň zásobníku je alokovaná na haldě, vstup do další úrovně znamená alokaci a výstup z úrovně dealokaci.

V následující části článku se tedy zaměříme na způsoby jak zefektivnit korutiny redukcí alokací, nebo jak se alokacím úplně vyhnout. Je třeba na místě upozornit, že se často jedná o over-optimalizaci, která má uplatnění pouze někde. Pokud píšete program, kde použití korutin váš problém výrazně zjednoduší a zefektivní jeho provádění tak jako tak, pak tyto optimalizace možná ani potřebovat nebudete, nebo se jimi nemusíte zabývat. Je to asi na stejné úrovni, jako v programu, který masivně používá mapy, vektory a string, se snažit o nějaké optimalizace alokací korutin, když přihlédneme k tomu, jak vlastně neefektivní jsou výše vyjmenované objekty z hlediska práce s haldou. 

Coroutine (allocation) elision

Tuto optimalizaci za nás udělá překladač. Jedná se o možnost zmíněnou ve standardu, kdy překladač při optimalizaci kódu zjistí, že je schopen pro rámec korutiny najít efektivnější umístění, například na zásobníku nebo v rámci volajícího. Pak je schopen vygenerovat kód, kde k alokaci nedochází vůbec. Je to optimalizace, kterou nelze jednoduše ovládat, záleží skutečně na překladači a jeho schopnosti. Jako programátor máte možnost omezeným způsobem překladači pomoci k tomu, aby se taková optimalizace povedla, ale nemáte jistotu, že se tak stane. Vždy bude záležet na aktuální situaci v kódu – stejně jako nelze moc dobře ovlivnit, zde volání funkce bude realizováno pomocí call, jmp, nebo se funkce inlinuje

K tomu aby allocation elision bylo uplatněno je třeba, aby překladač dokázal vysledovat životnost korutiny – provést escape analýzu – a měl by znát i velikost rámce korutiny – což je docela oříšek pro leckterý překladač. Velikost rámce záleží na množství proměnných, která korutina deklaruje a efektivitě optimalizace kódu korutiny (některé proměnné lze držet v registrech a není třeba je zachovávat přes body obnovy).

  • Aktuálně je stav podpory této optimalizace u překladačů následující
  • Nasazuje se pouze u optimalizací -O2 a více. V debug překladu se vždy alokuje na haldě
  • Podpora pozorována (neoficiální informace) u Clang++ a MSVC. GCC to zatím asi neumí.
  • Lze víceméně použít jen u synchronních generátorů, jejichž existence se omezuje jen na aktuální funkci
  • Zatím určitě nefunguje, pokud volajícím je korutina

Ne všechno musí být korutina

Tuhle optimalizaci může udělat programátor a je relativně zadarmo. Jedná se případ, kdy korutina obsahuje pouze jedno použití co_await a to spolu s co_return na konci kódu

task<T> do_work() {
   //… nějaký kód…
   co_return co_await do_more_work(a,b,c);
}

Výše zmíněnou korutinu lze nahradit obyčejnou funkcí

task<T> do_work() {
   //… nějaký kód…
   return do_more_work(a,b,c);
}

Je třeba při tom dát pozor na několik věcí. Musíme se totiž ujistit, že proměnné, které předáváme do funkce do_more_work(…) budou existovat i po tom, co funkce do_work() skončí. To je problém, pokud je předáváme referencí, pointerem nebo předáváme hodnotou objekt, který se chová jako reference. Například následující kód je špatně a způsobí krach, nebo UB.

task<T> bad(std::string text, int val) {
   std::string_view data(text);
   const int &v = val;
   return coro(data,v);
}

Korutina obdrží v parametrech string_view na text a hodnotu jako referenci a pak je přerušena. Následně tato funkce bad() skončí a obě hodnoty zničí. Volající pak provede co_await na task<T> a korutina bude pokračovat prací s již zničenými objekty. Proto je vhodné, když korutina přijímá parametry hodnotou a tak, aby parametry byly jejím vlastnictví. Pokud už přijímá reference, musí volající zařídit čekání na výsledek korutiny, než referovaný objekt zničí a pokud by do_work() takovou referenci musela použít, pak se musí implementovat jako standardní korutina.

Když to jde synchronně, nemusí to být korutina

Vraťme se k našemu asynchronímu transformátoru

for (auto iter = begin; iter != end; ++iter) {
    *out = co_await do_transform(*iter);
    ++out;
}

Náš do_transform může být implementován takto

cocls::future<T> do_transform(const T &val) {
    std::optional<T> res = try_sync(val);
    if (res.has_value()) return cocls::future<T>::set_value(*res);
    else return try_async(val) //korutuna
}

V příkladu používám mou třídu cocls::future<T>, která umožňuje nastavit výsledek pomocí funkce set_value() aniž by bylo třeba volat korutinu. Pokud volající pak použije co_await na cocls::future, ihned obdrží výsledek. Výše uvedená optimalizace výrazně ušetří alokace, pokud je zřejmé, že většinou bude možné operaci vyřídit synchronně a pouze občas bude nutné spustit korutinu a čekat.

Použijte generátor

O generátorech jsem zatím v tomto seriálu nepsal, ale jako možnost to zmíním. Generátor se totiž alokuje jen jednou, na začátku a pak může obsahovat smyčku, ve které přijímá požadavky a vrací výsledky. Výše uvedený transformátor by se tedy dal naprogramovat tak, že do_transform převedu na generátor, kterému budu s každým voláním posílat prvek,který chci převést a generátor bude vracet jeho převedenou hodnotu. Samotný generátor je korutina a může také volat co_await (pokud jde o asychronní generátor. Synchronní verze to mají zakázáno). V knihovně cocls by se to dalo zapsat takto

cocls::generator<T *(T)> transform_gen() {
      T val = co_yield nullptr; //pošli prazdný výsledek aby se vyzvedl první prvek
      do {
          // transformuj val -> result
          T result = /* async transformace(arg) */
          val = std::move(co_yield &result);
      } while (true);
}

---

auto gen = transform_gen();
for (auto iter = begin; iter != end; ++iter) {
    co_await gen.next(*iter);
    *out = *gen.value();
}

Příklad používá oboustranný generator (dostupný v cocls), který umožňuje posílat data do generátoru a číst data z něj a vždy přes co_yield. Prototyp generatoru <T *(T)> předepisuje, že generátor přijímá T a vrací pointer na T. To ale znamená že co_yield bude vracet T a očekává pointer. Tohle jsem zvolil z toho důvodu, že generátor, aby si načetl první prvek, musí do co_yield něco dodat, a proto dodává nullptr. V cyklu, kde generátor voláme tuto hodnotu nepoužijeme (je dostupná ihned po vytvoření generátoru), místo toho nad prvním prvkem zavoláme co_await gen.next() a předáme prvek. Tady k žádné alokaci nedochází a generátor pokračuje za co_yield s tím, že drží předaný prvek. Provede konverzi a zavolá co_yield z výsledkem a zůstane stát (proto můžeme předávat pointer na lokální proměnnou). Tento pointer je k dispozici na gen.value() , přes který si vyzvedneme hodnotu, uložíme a pokračujeme dalším prvkem. 

I když generátor obsahuje nekonečnou smyčku, lze jej ukončit a to destrukcí generátoru během jeho uspání. Což v tomto případě ani jinak nejde, protože generátor a naše korutina se střídají v běhu na jednom vlákně, takže když běží korutina, generátor spí, lze jej také zničit.

Předávám promisu

Tato optimalizace využívá mé implementace cocls::future<T>, ale je to podobné jako trigger z minulého článku. Pokud mohu funkci implementovat tak, že do dalšího kódu předávám objekt promisy, což představuje referenci na čekající futuru, nemusí zbytek kódu být korutina.

cocls::future<int> do_work(...) {
    return [&](auto promise) {
        //dělej něco asynchroně
        //… třeba spusť vlákno
        std::thread thr([promise = std::move(promise)]{
            try {
                //… tady něco spočítej do “result”
                promise(result);
            } catch (...) {
                promise(std::current_exception());
            }
         });
         thr.detach()
    };
}

Ve výše zmíněným příkladě se využívá toho, že cocls::future<int> lze inicializovat funkcí, která obdrží promise a tu může dál předávat, přičemž na vrácenou future můžeme čekat přes co_await. Je to hodně podobné tomu, jak se inicializuje promise v javascriptu – poznáváte ten pattern?

function do_work() {
    return new Promise((ok,err) => {
        try {
            //… tady něco spočítej do “result”
            ok(result);
        } catch (e) {
            err(e);
        }
    });
}

Tento mechanismus jsem v cocls knihovně ještě rozšířil o možnost některé korutiny převést na třídu s virtuální funkcí resume(), šablonu najdete pod názvem abstract_listening_awaiter. Jedná se vlastně o emulaci korutiny třídou a její výhodou je, že překladač dopředu zná velikost té třídy a lépe se tedy hledá místo pro její frame. Dá se například sloučit s cocls::future_with_context<T, Context> kde Context je naše třída která se chová jako korutina, ale celá se instanciuje ve frame volajícího, není tam jediná alokace. Takto například mám na svém serveru realizován http handler, kdy handler requestu lze naprogramovat tak, že sice masivně závisí na asynchronní povaze sítě, ale pro jednoduché requesty (typu GET) na cestě k odpovědi serveru není jediná alokace. Popis tohoto nástroje je ale nad rámec článku.

Vlastní (custom) alokátory 

A dostávám se k hlavní části článku a to jsou alokátory korutin. Schválně jsem organizoval dosavadní text od jednoduché až po šílené optimalizace, kdy korutiny používám tak, … že se jim vlastně vyhýbám. Tohle ale má být o korutinách.

Standard přináší možnost definovat vlastní alokátory, tedy alokace se pak neprovádí na haldě, ale místo toho je zavolána naše implementace a očekává se od nás, že náš kód někde vlastními silami najde prostor pro umístění rámce. Tohle je nutné hlavně pro embedded systémy, kde alokace rámců musíme mít ve svých rukou, kde například neexistuje dvojice funkcí malloc/free. Lze třeba deklarovat statický buffer a v něm spravovat rámce korutin.

Alokátory deklarujeme na promise_type. Používáme stejný syntax jako pro alokátory v třídách

struct promise_type {
    void *operator new(std::size_t sz) {...}
    void operator delete(void *ptr, std::size_t sz) {...}
    // další funkce promisy, například get_return_object,
    // initial_suspend, atd…
};

Pokud je taková korutina zavolána, místo alokace na haldě se zavolá naše implementace new, a stejně při destrukci se zavolá naše implementace delete. V obou případech nám překladač dodá požadovanou velikost frame – což se určitě hodí zejména pro delete (viz dále)

Tak jako u tříd, lze u korutin operátor new dále přetěžovat, a dodat další parametry, jako například kontext alokátoru. A tady to začne být zmatené. Jak kontext vlastně předat, když neexistuje žádné _placement_ new pro korutiny? Překladač bude požadovat, aby se kontext předával jako parametr korutiny, dokonce vyžaduje, aby přetížený operátor new měl stejný počet parametrů jako parametry samotné korutiny, plus parametr pro velikost. Takže pokud naše korutina má parametry 

task<T> my_coro(allocator&, int, std::vector<char>, std::string &);

musí přetížený operator new mít tuto deklaraci

void *operator new(std::size_t, allocator&, int, std::vector<char>, std::string &);

Naštěstí můžeme použít šablonu!

template<typename ... Args>
void *operator new(std::size_t, allocator &inst, Args && ...);

Překladač pak použije tento operátor pro všechny korutiny tohoto typu, které budou jako první parametr předávat alokátor. Ten sice ve vlastním kódu korutiny ignorujem, ale způsobí předání kontextu alokátoru do přetíženého operatoru new()

Pro korutiny které jsou metodami tříd je třeba ještě přidat jeden přetížený operator new()

template<typename This, typename ... Args>
void *operator new(std::size_t, This &, allocator &inst, Args && ...);

… protože metoda má jako první parametr referenci na instanci třídy, jejíž metodu voláme (this – ne, není to kupodivu pointer), takže alokátor vystupuje jako druhý parametr

Problém s operátorem delete

Kontext alokátoru sice dostaneme do new() ale nedostaneme ho do delete(). Jak se delete má dozvědět, co vlastně kde má dealokovat? Nemá šanci, musíme si referenci na kontext zapsat do alokovaného bloku svépomocí

+--------------------------------+-------+
| frame korutiny                 | a_ptr | (a_ptr - ukazatel na alokátor)
+--------------------------------+-------+ ^ | +-- začátek bloku

Je vhodné alokovat blok delší o velikost jednoho ukazatele. Do tohoto prostoru vložíme ukazatel na alokátor. Při zavolání delete nám překladač dodá velikost frame korutiny. Z toho snadno spočítáme adresu našeho ukazatele, vyzvedneme pointer a zavoláme alokátor pro dealokaci bloku

template<typename ... Args>
void *operator new(std::size_t sz, allocator &inst, Args && ...) {
    void *ptr = inst.alloc(sz+sizeof(allocator **));
    allocator **alloc_ptr = reinterpret_cast<allocator **>(reinterpret_cast<char *>(ptr)+sz);
    *alloc_ptr = &inst;
    return ptr;
}
void operator delete(void *ptr, std::size_t sz) {
    allocator **alloc_ptr = reinterpret_cast<allocator **>(reinterpret_cast<char *>(ptr)+sz);
    (*alloc_ptr)->dealloc(ptr,sz);
}

delete se váže ještě jeden problém a to ten, že nezáleží na způsobu, jak byla korutina alokována, operator delete se zavolá vždy tento. To například znamená, že pokud dojde k alokaci korutiny standardním new, pak náš delete bude hledat na konci bloku referenci na alokátor, který tam není. Tohle se může stát, pokud někdo korutinu deklaruje bez alokátoru. Pak máme dvě možnost. Jedna možnost je přetížit i standardní new a obohatit alokovaný blok o prostor pro pointer, kam můžeme například zapsaním nullptr a tím upozornit náš operator delete, že nemá hledat alokátor. Nebo můžeme standardní new deklarovat jako private a tím způsobit chybu při překladu, pokud někdo takto korutinu deklaruje 

private:
    void *operator new(std::size_t sz);

Šablona with_allocator a reusable_storage

V mé knihovně naleznete šablonu with_allocator, která by měla umožnit vlastní alokátor pro všechny korutiny (nebo aspoň pro všechny z knihovny)

template<typename Allocator, typename Base>
class custom_allocator_base: public Base {
public:

    using Base::Base;

    template<typename ... Args>
    void *operator new(std::size_t sz, Allocator &storage, Args && ... ) {
        return storage.alloc(sz);
    }

    template<typename This, typename ... Args>
    void *operator new(std::size_t sz, This &, Allocator &storage, Args && ... ) {
        return storage.alloc(sz);
    }

    void operator delete(void *ptr, std::size_t sz) {
        Allocator::dealloc(ptr, sz);    //uložení reference se deleguje na alokator
    }

private:
    void *operator new(std::size_t);
};

template<typename Allocator, typename Task>
class with_allocator: public Task {
public:

    using Task::Task;
    with_allocator(Task &&arg):Task(std::move(arg)) {}

    using promise_type = custom_allocator_base<Allocator, typename Task::promise_type>;


};

Korutinu, kterou chceme alokovat pomocí alokátoru, pak deklarujeme např. takto

with_allocator<MyAllocator, task<int> > my_coro(MyAllocator &, int a, int b, int c /*,...*/) {...}

Použití alokátoru si ukážeme na alokátoru cocls::reusable_storage. Tento alokátor předpokládá nasazení na jednu korutinu, která se opakovaně volá, přičemž místo pro korutinu se alokuje pouze jednou a opakované volání se znovu používá již alokovaného místa

class reusable_storage {
public:

    reusable_storage() = default;
    reusable_storage(const reusable_storage &other) = delete;
    reusable_storage &operator=(const reusable_storage &other) = delete;

    ~reusable_storage() {
        ::operator delete (_ptr);
    }
    void *alloc(std::size_t sz) {
        if (sz > _capacity) {
            ::operator delete (_ptr);
            _ptr = ::operator new(sz);
            _capacity = sz;
        }
        return _ptr;
    }
    static constexpr void dealloc(void *, std::size_t) {}

protected:
    void *_ptr = nullptr;
    std::size_t _capacity = 0;
};

Když se vrátím k našemu asynchronímu transfomátoru, bylo by možné zvýšit výkon této korutiny použitím do_transform s alokátorem

template<typename Alloc>
with_allocator<Alloc, subtask<T> > do_transform(Alloc &, const T &);

---

void do_work(/*...*/) {
//...
reusable_storage storage;
for (auto iter = begin; iter != end; ++iter) {
        *out = co_await do_transform(storage,*iter);
        ++out;
}
//...
}

K tomu ještě poznámku. Je možné, že v budoucnu překladače budou schopny tento pattern detekovat a budou tedy provádět jednu alokaci před cyklem a pak znovu používat stejný kus paměti. Pak taková optimalizace bude vlastně zbytečná. V současné době však nemám informace, že by se tak dělo. Musíme si počkat na nové verze překladačů

Stackfull

Korutiny v C++ jsou stackless, znamená to, že korutinou je pouze jedna funkce a jen ta může používat co_await. Funkce, které korutina vola jsou obyčejné funkce a ty nemohou používat co_await. Pokud to potřebují, musí se stát korutinami. Potom je nutné na jejich výsledek čekat přes co_await

+-foo()
    + co_await bar()
                + co_await baz()
                            + co_await do_async()
                                            +---- suspend
                                                    ----
                                            <-  resume
                            <- co_return
                <- co_return
    <- co_return

U stackfull korutiny by žádná volaná funkce nemusela vědět, že je součástí korutiny a přerušení by proběhlo jen na úrovni do_async(), kdy stavem korutiny je pak celý zásobník. Standard C++ však šel jinou cestou a není to jen výsada C++. Stackless korutiny najdeme i v jiných jazycích, například javascript, kde naprosto totéž platí u async-await

Ve výše zmíněném diagramu prostě nahradíme co_await za await a každá funkce je deklarovaná jako async

Stackless korutiny se lépe spravují protože odpadají problémy s alokací stacku. Simulovat stackfull korutiny zřetězením co_await není zas takový problém. Až na řízení paměti. Každá úroveň volání znamená alokaci rámce korutiny jen na ten okamžiky než se volaná část vyřídí a dojde k návratu. Naivně jsem si myslel, že coroutine elision se uplatní i zde a překladač bude schopen všechny rámce předalokovat třeba na úrovni jedna. Nebuďte stejně naivní. Vlastně to moc nejde. Navíc překladač by musel mít k dispozici kód všech vnořených korutin aby mohl spočítat velikost prvního frame.

(To že coroutine elision je možný u generátorů je v zásadě důsledek fungování funkce alloca(), která umí alokovat prostor na zásobníku v dopředu neznámé velikost)

(Možná teď prozradím šokující informaci, alloca nefunguje v korutinách – to jen tak naokraj. Nepoužívejte alloca. Proto nejde stejný mechanismus použít v korutině)

V knihovně cocls najdete korutinu která se jmenuje stackfull<T>. Tato korutina jako první parametr očekává instanci třídy  coro_stack, což je třída, která implementuje alokátor pro alokaci virtuálního zásobníku. Protože korutina vidí svůj stack, jelikož ho dostala jako první parametr, může tento alokátor předat do další úrovně, a takto může předávat zásobník do dalších úrovní.

+-foo(stack,...)
   + co_await bar(stack,...)
               + co_await baz(stack,...)
                           + co_await do_async(stack,...)
                                           +---- suspend 
                                                    ----
                                            <-  resume 
                           <- co_return
               <- co_return
   <- co_return

Vlastní instanci coro_stack je třeba předem inicializovat dostatečně velkým prostorem, ten se uvádí v počtu očekávaných úrovních – ono je totiž problém určit dopředu velikost korutiny, takže zadávat tam bajty nemá moc smysl. Místo toho se zadává počet úrovní a velikost zásobníku se spočítá s velikosti první alokace krát počet úrovní. A kdyby náhodou vyhrazená paměť došla, třída si zaalokuje další blok podle potřeby. Vhodným nastavením se ale dá redukovat počet alokací a dealokací při přecházení mezi úrovněmi a tím se simuluje stackfull korutina.

Kolik vlastně korutina zabírá místa?

Možná vás napadlo, jak velké místo bych měl rezervovat v nějakém statickém bufferu, kdybych se chtěl vyhnout alokaci úplně. Lze určitě zvolit nějaké dostatečně velké arbitrární číslo? Lze odhadnout velikost?

  • Velikost vám neprozradí překladač, protože ji neví. Dozví se to až po optimalizaci kódu, když nelze vygenerovaný kód výrazně měnit. Vlastní hodnota je k dispozici až v runtime
  • Clang generuje menší rámce než GCC asi o 10%
  • Kód v debug režimu má rámce až 2× větší
  • MSVC generuje rámce zhruba 2.5× větší než GCC (z nějakého, mne neznámého důvod)
  • Velice závisí na platformě. 32bit platforma bude mít menší rámce než 64bit platforma

Takže odpověď je: nelze. Obecně by se dalo říct, že velikost rámce korutiny se pro představu dá odhadnout z :

  • velikost promise type
  • velikost argumentů korutiny
  • velikost proměnných v korutině (které je třeba zachovat mezi body obnovy).
  • každá proměnná může mít u sebe flag, který informuje o tom, jestli proměnná byla inicializována, tak aby případný destroy() byl schopen zavolat destruktor – něco jako std::optional<>
  • velikosti všech awaiterů (ale překladač může prostor aliasovat, tedy vyhradit jen prostor pro největšího awaitera, protože aktuálně se čeká pouze na jeden současně)
  • prostor pro dočasné proměnné (temporary) které vzniknou při vyhodnocení co_await v kterékoliv části korutiny.
  • velikost interních proměnných udržující mnoho informací o stavu korutiny, například mapu různých pointerů na různé části rámce, pointery na funkce resume() a destroy(), příští bod obnovy, což může být relativně velká struktura, atd. 
  • A to všechno vynásobte 2.5× u MSVC (je dost možné, že se to týká hlavně interní části).

Závěr

A to je pro dnešek všechno i když si myslím, že téma není vyčerpáno, nechám pracovat vaši invenci. Třeba s pomocí tohoto článku přijdete i na jiné způsoby jak zrychlit kód používající korutiny.

https://github.com/ondra-novak/coroutines_classes

Sdílet