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.
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.
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.
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).
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.
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.
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.
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.
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
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); }
K 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);
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čů
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.
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?
Takže odpověď je: nelze. Obecně by se dalo říct, že velikost rámce korutiny se pro představu dá odhadnout z :
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.
> zásobník není vhodným kandidátem pro uložení stavu
Dovolím si nesouhlasit. Samozřejmě záleží na tom, jak se to udělá, ale jak Goroutiny, tak virtuální vlákna z JDK 19 ukládají stav normálně na zásobník.V okamžiku přepnutí se prostě jen změní hodnota SP.
Výhody jsou značné:vnení potřeba dvo\u "kategorií" funkcí - žádné async
, await
či suspend
(jako v Kotlinu). Vše se překládá úplně stejně. Zásobník je rychlejší než halda.
Přijde mi zvláštní, že po tom, co Go ukázalo, jak se ko-routiny mají dělat, a Java ukázala, že je lze přidat bez jakýchkoli změn jazyka, tak to ještě někdo zkouší se speciálními klíčovými slovy a s alokací na zásobníku!
Tak jistě mohlo se to udělat pomocí stackful korutin. Znamená alokace zásobníku po korutinu (nebo gorutinu?) Je to určité strategické rozhodnutí. Problém je, že nikdy dopředu nevíš, jak velký zásobník chceš alokovat. Takže nastřelíš nějakou hodnotu a doufáš, že to bude stačit.
Netuším, jak to go ma realizované. A netroufnu si říct, že to je lepší. C++ šlo cestou stackless, kdy nejsi povinný alokovat zásobník pro korutinu. Pokud tvá korutina dále volá normální funkce, pak se normálně používá zásobník vlákna, ve kterém zrovna běží. Samotná korutina zabírá jen to minimum co potřebuje k uložení stavu.
Některé embedded systémy navíc mají pouze jeden zásobník, alternativní zásobníky nelze používat, nelze jen tak změnit registr SP. Stackless korutiny v tomhle systému budou bez problému fungovat, stackful tam nezrealizuješ. To asi bylo hlavní kriterum pro rozhodování, co se do normy dostane. Co se týče stackful korutin z hlediska podpory OS, tak nikdo programátorovi nebrání použít makecontext/ConvertThreadToFiber dostupný už na úrovni C.
Javascript například má stackless korutiny.
TinyGo, verze pro embedded, řeší (k|g)orutiny takto: https://aykevl.nl/2019/02/tinygo-goroutines
Vnitřnostem moc nerozumím, ale jako uživatel jazyka mám jasného favorita.
Ikdyž, pro použití TinyGo bych se ale asi nerozhodl, ale to kvuli správě paměti, a to už je zase jiná kapitola.
Ja jsem se znalosti C++ par let pozadu, ale kdyz vidim ty zapisy asynchronniho kodu v C++ (navic s generickymi typy) a srovnam to s js, ve kterem ted delam vic, tak v js je to pro me o hodne citelnejsi. Samozrejme js neumoznuje to co c++, ale ta slozitost novinek c++ je pro me odrazujici...
Tak nejste typický programátor knihoven.
Z hlediska uživatelů se nic moc nemění, generika akorát nahrazují type erasure, které se používá v C. V javascriptu chápu, že se neuvádí typu async, protože tam není typová kontrola.
Pro lidi, co knihovny používají se snažím navrhovat jednoduchá API.
task<T> - toho bych se fakt nebál. Místo T se samozřemě dá typ, takže task<int> je prostě korutina, která vrací int, akorát není dostupný hned. Lze použít i task<void>
V javascriptu, pokud uvedu klíčové slovo async, tak vím, že mi z funkce vypadne Promise. Pokud ho neuvedu, může vypadnout cokoliv, (včetně Promise). V C++ musím vždycky uvádět typ. A dává mi smysl, aby třeba některá moje funkce vracela future<int> - to je celkem dobře popisné, je to proměnná, která bude dostupná v budoucnosti a bude typu int. (dokonce v jednom návrh jsem používal šablonu async<typ> podle javascriptu). Použití pak se nelíší od javascriptu. Akorát místo await píšu co_await (nová klíčová slova se špatně zavádí do jazyka, kde už mohou existovat tyto slova jako identifikátory)
Ještě dodám, že o návrhu čitelnější API a korutin chystám nějaké povídání. Samotný si tu cestu procházím a hledám jak optimálně navrhnout API knihovny, aby budoucí uživatel nemusel vidět background, pokud nechce a přitom jen z popisu interface dostal potřebné informace o tom, jak to používat.
Přikláním se k názoru, sice sleduji vývoj C++17, C++20, C++23, ale třeba corutines mě minuly (proč? nevim, asi protože nejsou / nebyly v překladačích). V poslední době jsem přesedlal na C (na C89/C90 pro extra masochismus). Takže bych uvítal příklad mechanického převodu z C++ corutine na C struct + stavový automat. Prosím. Ať si všichni uvědomíme, co všechno je třeba ukládat mezi ... čím vlastně ... co_yeld/co_await/apod.
Jo, dekuji. Mate pravdu, nejsem tvurce knihoven v C++. Asi to v tom chce proste delat :)
V súvislosti s tým, či sa implementácia nejakého konceptu v jednom jazyku používa v porovnaní s implementáciou toho konceptu v inom jazyku jednoduchšie, je nutné zamyslieť sa aj nad tým, či majú obe implementácie podobné funkčnosti.
Vždy je niečo za niečo a človek v našej branži môže mať buť pohodlný život alebo kontrolu nad všetkým, čo sa dá. Ale oboje naraz asi iba zriedkavo, ak vôbec. A C++ je skôr na strane umožnenia maximálnej kontroly.
Čo sa toho ostatného týka, tak korutiny v C++20 napríklad značne sprehľadňujú a zjednodušujú písanie kódu na báze asio, kde si autor knižnice dal prácu a tie veci tam dopísal, aby sa to dalo dobre používať. Takže aj keď človek nepíše knižnice, mal by mať nejaké povedomie o tom, o čo sa jedná, aby si vedel vybrať spôsob používania knižnice, ktorý mu uľahčí život. Pokiaľ samozrejme tá knižnica umožnuje použitie s korutinami.
A potom sa dá nájsť aj video, kde tuším Gor Nishanov ukazuje program na báze korutín, ktorý je rýchlejší ako program písaný bez nich, takže niekedy je to abstrakcia so zápornou cenou.
A teraz neviem, či to je v tom videu alebo inde, ale má to byť kvôli tomu, že interne si prekladač prepíše kód, podobne ako to robí aj pri cykle for založenom na rozsahoch, vznikne tým jedna funkcia a tak má prekladač lepšie povedomie, čo sa deje a môže to lepšie optimalizovať.
super clanek, autor je king, ale chtel jsem tohle vsecko? jakoze to musim v c++ resit, ale zacina mi to pripominat honicku zajice a zelvy jeste k tomu v bubnu,
ktery se zrychluje.
c++ je moderni assembler co se preklada v assembler, uz me ta strasna komplexita stve, kdyz se to zkompiluje
tak z pohledu assembly v tom nic zajimaveho neni.
autor je moc chytry, ale je lapen v aktualnim svete beze snu.
a tak jsem si zjednodusil zivot a co muzu, delam v go.
uz si zas stezuju a to proto, ze lidi zahodili plan9 a go.
tak si holt zaslouzi c++, kubernetes, vmware, a pritom mohli mit plan9 cputerm, distribuovany hurd, amoebu.
Ano omlouvam se, píšu to z pozice programatora knihoven. Asi proto, že s verzí 20 se moc knihoven o korutinach ,nedoručilo. Tak jsem chtěl popsat svou cestu.
Na druhou stranu stěžovat si na komplexitu je trochu levné. Tam žádna velká komplexita není. Straší vás asi jen příliš mnoho kostek LEGA. Holt nekdo si vystačí s duplo nebo s lego friends, někdo není spokojen ani s Lego Technik. Z kostiček lze postavit mnoho a je ale třeba vědět, k čemu každá slouží
Ale jo, beru to. Už chystám článek který ten bordel uklidi. Bude to něco o mě představě jednoducheho a čitelného API, ktere by mělo tvořit pevný zaklad pro něco skutečně užitečného a zároveň efektivního
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 063×
Přečteno 23 939×
Přečteno 22 871×
Přečteno 20 952×
Přečteno 17 760×