Po určité době jsem se rozhodl revidovat svou knihovnu pro práci s korutinami v C++20. Během používání jsem si zapsal postřehy z dosavadního vývoje a tak vznikla nová knihovna která se snaží řešit některé problémy předchozí verze.
Protože ani C++20 ani C++23 a vypadá to že ani C++26 nepřinese standardní nástroje na práci s korutinami a asynchronními operacemi, mým cílem bylo přijít s vlastním řešením v podobě této knihovny. Přitom jsem se snažil doručit minimum viable product s důrazem na maximální efektivitu a minimální množství tzv jalového kódu (to je kód, který pouze zajišťuje soudržnost navržených struktur)
Co se designu knihovny týče, snažím se používat programovací konvence STL knihovny, jako by knihovna měla ambice stát se součástí standardu (hodně teoreticky samozřejmě). Velký důraz je kladen na používání šablon a tím rozšíření možností při používání v různých algoritmech. Knihovna také nemá podobu frameworku, jako spíš propojení několika „užitečných tříd“.
Knihovna je navržena jako header-only. Po instalaci ať už jako system-wide modul nebo jako submodul by měl stačit jeden hlavičkový soubor
#include <coro.h>
Všechny deklarace najdete v namespace coro
. Například coro::future
atd
Design knihovny je navržen s ohledem na vyšší míru abstrakce. Na low-level úrovni se při práci s korutinami setkáváte s coroutine_handle
, awaitery
, suspend_always
, suspend_never
, funkcemi jako jsou await_ready
, await_suspend
a await_resume
, u generátorů s await_transfer
a yield_value
, a třeba s problematikou symetrického transferu. To všechno nepomáhá k jednoduchému uchopení tématu a proto se setkávám s tím, že mnoho programátorů se korutinám v C++ vyhýbá, protože jim prostě nerozumí. Mou motivací tak bylo celé téma přenést do abstraktnější úrovně a technické detaily implementace přenechat knihovně. I když si myslím, že i tyto detaily jsou důležité, rozhodně není je potřeba znát z počátku.
Různá designová rozhodnutí při implementaci byla provedena s ohledem na minimalizaci využívání heapu, resp. aby to kdy se použije heap, měl v plně v rukou uživatel. Pokud třeba používáte korutiny v prostředí, kde nelze alokovat paměť – myslím teď embedded prostředí, nebo prostředí s vysokou mírou bezpečnosti, kde třeba platí, že „po startu motoru se nesmí alokovat paměť“, pak libcoro se toto snaží maximálně naplnit. Já vím, že třeba samotné korutiny předpokládají nějakou alokaci, ale i zde je možnost využít například alokátorů a rámce korutin nechat vytvářet v předalokovaných blocích.
Poznámka: V textu budu často srovnávat s knihovnu se starší revizí, která se na githubu jmenuje cocls
. Pro novou revizi byl zvolen jiný název i namespace, protože jde prakticky o úplné přepsání „na zelené louce“
Určitě zde nehledejte třídu „task“, která se jinak vyskytuje ve všech průvodcích korutinami, ačkoliv ve standardní knihovně se nikdy žádný std::task
neobjevil. Pokud zabrousíte po internetu ve snaze najít konkurenční implementaci korutin, tak zcela jistě narazíte na lewissbaker/cppcoro (3.200 stars), kde najdete i implementaci „task“, ale třeba i „shared_task“ a když se ponoříte do zdrojového kódu a do návodu, jak se task používá, zjistíte takovou podivnou schizofrenii. Například si budete klást otázky, kdo je zodpovědný za život korutiny, kde je uložen výsledek korutiny, co se děje s korutinou, která skončila, atd… Podrobnou analýzou běhu korutiny například zjistíte, že korutina vlastně nemusí nikomu patřit, protože buď běží, pak je její lifetime jasně definován, nebo čeká, pak patří tomu, kdo je zodpovědný ji probudit, nebo skončila, přičemž se sama umí zničit. Jakmile odpovědnost za lifetime předáte externímu objektu, často se vám stane (jako mně), že korutinu zničíte v době, kdy na něco čeká (a ten někdo pak pracuje s dangling referencí)
Další problém je, že speciálně u cppcoro je výsledek uložen v rámci korutiny. A tak musíte rámec korutiny držet do té doby, dokud si chcete pamatovat výsledek, i když tento rámec již není potřeba.
Task jako typ vám také moc nepomůže při návrhu API
virtual task<data> read_stream() = 0;
Tento řádek je učiněná katastrofa, pokud task je tedy chápán tak, jak je nadefinován v cppcoro, znamená to, že tato abstraktní funkce mne zavazuje k implementaci použít task. A to i přes to, že implementuji memory stream, který nemusí být implementován jako korutina.
Správné řešení (podle mne) nemám ze své hlavy, ale inspiroval jsem se v javascriptu. Doslova mi v C++ chybělo to, co javascript od určité verze má nativně.
javascript | libcoro |
---|---|
Korutiny se označují klíčovým slovem async | v knihovně libcoro se používá async<> nebo future<> |
Na výsledek async funkce se čeká await | na výsledek future<> se čeká co_await |
Místo korutiny mohu vracet new Promise | instance future<> mohu zkonstruovat ručně |
Na výsledek korutiny mohu použít .then() | instance future<> má funkci .then() |
async function do_something () { await…; return…; } |
coro::future<T> do_something() { co_await …; co_return …; } |
function do_something2() { return new Promise ((ok,err)=>{ calc_async([ok,err]); } } |
coro::future<T> do_something2() { return [ & ](auto promise) { calc_async(std::move(promise)); } } |
A tím bychom mohli skončit. Přesněji, končí tím podobnost. V Javascriptu je to v zásadě všechno, co potřebujete k životu, v C++ ale spoustu věcí chybí. Například Javascript má vlastní dispatcher a systém řízení korutiny je plně v rukou javascriptového engine. V C++ žádný dispatcher není, tam má řízení korutin v rukou programátor.
Předně je třeba upozornit na vlastnosti objektu coro::future. Tento objekt není kopírovatelný ani přesouvatelný. Ve většině případů to nevadí, pokud například vstupuje jako proměnná do co_await, a ještě k tomu typicky jako dočasná proměnná (co_await read_stream()), tak nic takového nemusíme řešit.
V prostředí mimo korutiny, kde nemáme co_await nabízí future možnost synchronního čekání
korutina: data d = co_await read_stream(); funkce: data d = read_stream(); //will block
Co si pod tím představit? Jakmile je korutina zavolána, její vykonávání probíhá v aktuálním vlákně, dokud nenastane potřeba korutinu uspat. Korutina spí a čeká na probuzení, ale co s aktuálním vláknem? Z pohledu volajícího se volání korutiny tváří jako volání funkce, která se ukončila (vrátila) s prvním uspáním korutiny. V té době výsledek ještě není k dispozici. Protože se předpokládá, že uspání korutiny je v důsledku čekání na asynchronní operaci, která bude pokračovat v jiném vlákně, je synchronní čekání zařízeno zastavením celého vlákna a vyčkání na výsledek, jako by tam byla condition_variable. Tady je třeba si dát pozor, aby korutina nečekala na akci, která se má stát v tomto vlákně, pak vyrobíte deadlock.
Třída coro::future tak nabízí ještě jednu možnost, jak mimo korutinu počkat na výsledek a to je pomocí funkce then a nastavení callbacku
auto fut = read_stream(); fut.then([&]{ data d = fut; });
Je potřeba si dát pozor na další omezení a to, že instanci future není povoleno destruovat před nastavením hodnoty. Nedodržení tohoto pravidla vede na std::terminate
. Typicky je to problém právě u then
, kdy sice nastavíme callback, ale pak kód opustí blok platnosti proměnné a program je přerušen. Naivním způsob řešení tohoto problému je alokovat proměnnou na heapu
auto fut = new auto(read_stream());
fut->then([fut]{
data d= *fut;
delete fut;
});
Další možností je použití ve třídách, které potřebují čekat na nějaké future a umí si „nějak“ zajistit svou vlastní existenci. Instance future mohou být deklarované jako členské proměnné třídy
class Foo { public: coro::future<data> f; void process() { f << [&]{return read_stream();}; f.then([this]{ data d = f; }) } };
Protože coro::future není přesouvatelná, nebude fungovat přiřazení (přiřazení je buď kopií nebo přesunem, zde by se jednalo o přesun z dočasné instance představující výsledek volání asynchronní funkce, do existující instance). Rozhraní třídy coro::future nabízí přetížený operátor << který zde má význam jako „přesměrování výsledku“ a na své pravé straně přijímá funkci (invocable), která vrací instanci coro::future přesně stejného typu. Kód operátoru si funkci zavolá sám, zároveň však zajistí, aby se návratová instance konstruovala uvnitř existující proměnné.
Třída coro::future představuje základní stavební kámen pro synchronizaci asynchronní operace mezi volajícím a volaným bez ohledu na to, jestli na obou stranách jsou korutiny. Uživatel má plně ve svých rukou výběr technologie, kterou „nasadí“ na té které straně podle svých požadavků na efektivitu kódu. A nemusí jít jen o jednoho uživatele, dva programátoři se mohou snadno domluvit na asynchronním předávání výsledků aniž by jedna strana diktovala způsob implementace straně druhé.
Poznámka: Ještě je tu jedna odlišnost od Javascriptu, jedná se o zavedení limitu jednoho čekajícího. Zatímco u Javascriptu můžete objekt Promise kopírovat a můžete libovolně přes then
registrovat další a další callbacky, u libcoro
lze na jednu future
může být pouze jeden čekatel současně.
Shrnutí
Zatímco future je objekt se kterým pracuje volající, promise je objekt, se kterým pracuje volaný. Tento objekt představuje „dálkové ovládání“ vrácené future a umožňuje nastavit výsledek ve správný okamžik, jakmile je výsledek k dispozici. Objekt promise lze použít pouze jednorázově, jakmile je hodnota nastavena, objekt promise se stane neaktivní. Objekt lze přesouvat, přiřadit (přesunem) nebo dokonce zničit předčasně. Předčasné zničení je signalizováno na future jako speciální třetí stav, kdy svázaná future ukončí režim „pending“, ale zůstane bez hodnoty. Přístup pak vede na výjimku (await_canceled_exception), existuje ale způsob, jak tento stav detekovat i jinak.
Objekt promise se chová jako callback, takže jej lze snadno wrapnout například do std::function (invocable). Funkce přitom přijímá parametry ve stejném prototypu jako konstruktor typu, který future/promise konstruují.
Asynchronní operace ale nemusí dopadnout správně, může dojít k výjimce a bylo by dobré tuto skutečnost sdělit přes future čekající korutině/funkci. Promise tak nabízí funkci .reject(výjimka) nebo .reject() v catch handleru. Výjimka je zachycena a když čekající požádá o výslednou hodnotu, obdrží výjimku ve formě rethrow_exception
V předchozích mých pokusech jsem se dost natrápil s plánovaním probouzení korutin, viz můj článek „jak se budí korutiny“. Toto jsem se snažil v libcoro vyřešit lépe, i když to znamená, že částečně zodpovědnosti je přenesena do rukou uživatele programátora.
Nejprve ale v čem je problém? Nastavením promise zpravidla dojde k aktivaci korutiny a exekuci kódu, který následuje po co_await
. Otázkou je, v rámci jakého kontextu se tento kód bude exekuovat?
Ve výchozím stavu to bude právě v kontextu toho vlákna, které provedlo nastavení hodnoty a to v ten okamžik, kdy došlo k nastavení. To není vždy žádoucí, protože okamžité probuzení korutiny může nějakým způsobem ovlivnit aktuální kontext, nebo aktuální kontext nemusí být vhodný pro běh kódu korutiny. Například může být aktivní zámek, který může blokovat současného exekutora, takže nemůže přijímat nové požadavky. A pokud probuzená korutina chce zařadit další asynchroní požadavek, dojde k deadlocku (je třeba zamknout exekutora k registraci čekající promise, ten je ale již zamknuty). Bylo by vhodné korutinu probudit až po odemknutí zámku. Ale zase by bylo vhodné nastavit hodnotu uvnitř zámku.
Knihovna libcoro toto řeší oddělením operace nastavení hodnoty od operace zaslání signálu a probuzení korutiny (notify).
Nastavením hodnoty (nebo výjimky) způsobí, že je vrácen objekt promise::notify který můžeme uložit a přenést mimo prostor zámku a tam notifikaci doručit. Pro doručení notifikace stačí objekt zničit, doručení se provede v destruktoru. Nebo můžeme objekt poslat do thread_poolu a tím korutině přidělíme vlákno a korutina tak běží v samostatném vlákně.
Následující příklady ukazují různé způsoby odeslání signálu notify
void on_result_done(coro::promise<int> &prom) { std::lock_guard lk(_mx); prom(_result); //dojde k probuzení pod zámkem }
---
auto on_result_done(coro::promise<int> &prom) { std::lock_guard lk(_mx); return prom(_result); //probudí až volající - mimo zámek }
---
void on_result_done(coro::promise<int> &prom) { //vytvoř thread a v něm probuď
std::lock_guard lk(_mx); coro::promise<int>::notify ntf = prom(_result); std::thread thr(std::move(ntf)); thr.detach(); //detach threadu }
Objekt coro::promise::notify se chová jako funkce, jejímž zavoláním dojde k doručení signálu notifikace – a tedy k probuzeni korutiny nebo spuštění callbacku v aktuálním vlákně. Pokud je objekt zničen bez zavolání, signál notifikace se doručí z destruktoru. Tento objekt vrácený z funkce lze tedy bezpečně ignorovat s tím, že dojde k doručení notifikace na konci výrazu.
Shrnutí
Odložená future je novinkou oproti předchozí revizi. Výhodou odložené future je ta, že dokud není hodnota požadována, není výpočet ani zahájen.
Třída coro::deferred_future byla zavedena ze dvou důvodů. Prvním důvodem je zlepšení čitelnosti kódu. Pokud totiž v deklaraci rozhraní vidím deferred_future okamžitě vidím, že výpočet ještě nezačal a tedy neexistuje žádná reference na tuto proměnnou, není zde žádná paralelní úloha, která může do této future přistupovat (zatím).
Z tohoto důvodu jsou uvolněná některá omezení, například deferred_future je přesouvatelná a podporuje operátor přiřazení (=). Tento objekt lze také předčasně zničit, pak se odložený výpočet neprovede (zruší se).
Odložený výpočet je spuštěn jakmile je zahájeno čekání na výsledek, ať už přes co_await nebo přes synchronní čekání nebo přes then. Jakmile je odložený výpočet spuštěn, už se nesmí future přesouvat nebo zničit před dokončením výpočtu
Výhodou deferred_future je také to, že pokud obě strany rozhraní jsou korutiny, pak deferred_future zajišťuje předání řízení přes symmetric transfer a to oběma směry. Ani jedna strana přitom nemusí tuto informaci znát.
Z hlediska vztahu k coro::promise se nic nemění. Tento objekt používá stejný typ promise jako coro::future
Sdílená future (coro::shared_future) představuje future, kterou lze sdílet kopírováním. Funguje to stejně jako shared_ptr
. Každý držitel kopie pak smí svou kopii využít stejně jako by to byla jeho soukromá instance coro::future, tedy může čekat nebo nastavit callback. Toto je taky jediný objekt, který umožňuje aby na jednu proměnnou čekalo víc „čekatelů“, podmínkou je, že každý musí mít svou kopii sdílené future (na normální future smí čekat pouze jeden čekatel).
Sdílená future také umožňuje předčasné zničení, tedy v době, kdy výsledek ještě není k dispozici. Je to realizováno tak, že během čekání je držena jedna (soukromá) reference navíc, takže destrukcí všech veřejných referencí se nespustí destrukce future. To se stane až po nastavení hodnoty, kdy tato poslední reference je opuštěna
Z hlediska vztahu k coro::promise se ani tady nic nemění, Tento objekt používá stejný typ jako coro::future
Určitou novinkou je slučování promisí do jedné centrální promise, která pak ovládá mnoho čekajících future. Nejedná se přitom o shared_future, ale o obyčejné coro::future jejichž promisy jsou sloučené do jedné. Podmínkou k využítí je, aby typ T, který se tímto způsobem předává byl copy_constructible. Pak stačí nastavit jednu promise a hodnota se rozdistribuje všem čekajícím future.
Ke sloučení promisí se použije operátor + prostě jako by se chtěly promise sčítat. Definován je i operátor +=, tedy přidání další promise do kolekce
coro::promise<int> kolekce; kolekce += a; kolekce += b; kolekce += c; kolekce(42); //nastaví hodnotu všem třem promise a,b,c
Využití této vlastnosti se hodí například pro implementaci cache. Dokud není objekt v cache dostupný, například ve stavu, kdy se počítá, klíč v cache ale musí existovat. Tento klíč obsahuje promise prvního requestu, který výpočet požadoval. Pokud mezitím přijde další request na stejný objekt, je jeho promise sloučena s tou první promise a tím je operace vyřízena. Jakmile se výpočet dokončí, výsledek se uloží do cache a zároveň se nastaví hodnota uložené promise, čímž jsou všechny čekající požadavky vyřešeny. Každý další požadavek je pak okamžitě vyřešen z cache
Jak tedy psát korutiny? Uživatel knihovny nad tím nemusí přemýšlet. Prostě napíše funkci, která vrací jednu z výše uvedených future a uvnitř funkce použije co_await nebo co_return. (k vrácení hodnoty z korutiny přes future se právě hodí co_return. Pro coro::future<T> vracíme v co_return typ T. (pro coro::future<std::string>, vracíme v co_return std::string, případně cokoliv co lze do std::string převést)
coro::future<void> prazdna_korutina() { co_return; }
Knihovna libcoro nabízí možnost deklarovat korutinu u které není dopředu rozhodnuto, přes jaký typ future se bude vracet výsledek (a zda vůbec). Taková korutina vrací typ coro::async<T>
coro::async<int> moje_coro() { co_return 42; } int main() { coro::future<int> f = moje_coro(); int result = f; std::cout << result << std::endl; }
Zavoláním korutiny vznikne objekt async<T>. Tělo korutiny se nespustí, ale korutina je připravená k běhu. Pro spuštění korutiny stačí vrácený objekt překonvertovat na future, deferred_future, nebo shared_future. V případě konverze na deferred_future také nedojde ke spuštění, ale bude možné korutinu spustit jakmile začne někdo na tomto typu future čekat. Po konverzi se objekt async<T> stane prázdný, a je možné jej zdestruovat.
coro::async<int, coro::pool_alloc> moje_coro() {...};
I tato korutina se spouští převedením na future, deferred_future nebo shared_future
Třída async dále podporuje možnost spustit korutinu v detached režimu. Tím se rozumí režim, kdy na dokončení korutiny není třeba čekat, kdy tedy korutina běží nezávisle na zbytku kódu. V tomto režimu není návratová hodnota dostupná, takže se ignoruje. Ignorují se i jakékoliv případné výjimky, které v korutině vzniknou.
moje_coro().detach();
Pro psaní korutin, které jsou vždycky spouštěné v detached režimu je deklarována korutina coro::coroutine. Toto je vůbec nejjednodušší korutina a i z hlediska množství jalového kódu jej obsahuje naprosté minimum
coro::coroutine jednoduchá_korutina() { co_await...; co_return; }
Korutinu spustíme jako obyčejnou funkci. Korutina nic nevrací. Pozor na to, že korutina je považována jako by byla deklarována noexcept.
jednoduchá_korutina(); //a běží hned
Knihovna libcoro nabízí i standardní generátory. Generátory jsou korutiny, které používají klíčové slovo co_yield. Tato operace zastaví korutinu a předá výsledek volajícímu. Generátor lze pak zavolat znovu a běh korutiny pokračuje dál k dalšímu co_yield a výsledek je opět vrácen volajícímu. Klíčové slovo co_yield tak funguje jako co_return s tím rozdílem, že výpočet může pokračovat dál při dalším zavolání.
coro::generator<int> fibo(int count) { unsigned int a = 1; unsigned int b = 1; for (int i = 0; i < count; ++i) { co_yield a; int c = a+b; a = b; b = c; } } int main() { for (int v: fibo(10)) { std::cout << v << std::endl; } }
Knihovna libcoro umožňuje používat i asynchronní generátory, tedy generátory, které používají i co_await ve svém těle. Generátor lze volat jako funkci, která vrací coro::deferred_future a je tedy možné na výsledek generátoru čekat přes co_await
coro::future<void> main_async() { auto gen = fibo(10); auto f = gen(); while (co_await !!f) { //čekání a test, ze je vrácena hodnota int v = f; //teď už synchronně std::cout << v << std::end; f = gen(); //deferred_future lze přiřadit } }
poznámka: Operátor !
vrací true, pokud future nemá hodnotu. Příznak lze negovat, takže !!
vrací true, pokud future má hodnotu. Aby bylo možné příznak testovat, musí být future ve stavu resolved. Protože je toto často používaná operace, je `co_await !!f ` syntaxtická zkratka, která umožňuje počkat na vyresolvení future a zároveň získat tento příznak jako jedna operace.
V tomto případě se k přepínání mezi korutinami používá symetrický transfer.
Pokud mám víc generátorů, které vrací stejný typ, pak je mohu agregovat.
auto gen = coro::aggregator(gen1(), gen2(), gen3(),...)
Agregátor má využít pokud je použit s asynchronními generátory, tedy kdy agregátor vždy vrací hodnotu toho generátoru, který dřív spočítá svou hodnotu přičemž hodnoty dalších generátorů se řadí v pořadí jejich dokončení. Vždy po vyzvednutí hodnoty se generátor, jehož výsledek byl předán, znovu spustí aby vygeneroval další výsledek
I generátor může použít alokátor: generátor<T, Allocator>. Aggregátor také přijímá alokátor, v takovém případě je předán jako první argument
Určitou novinkou je možnost použít future jako obecný awaiter k jakémukoliv uživatelskému objektu. Jedná se zde o možnost použít future jako návratovou hodnotu přetíženého operátoru co_await
future<int> operator co_await() {return [&](auto promise) {...};}
Objekt, který definuje tento operátor se pak sám chová jako plnohodnotný awaiter, takže uživatel knihovny se vyhne programování vlastního awaiteru, což není triviální operace. Jako příklad uvedu mojí třídu na Websocket klienta, který navazuje spojení se serverem a vrátí websokectové spojení, když je handshake úspěšný. (jen interface)
class WebsocketClient { public: WebsocketClient(HttpClientRequest &req); future<ws::Stream> operator co_await(); }; Použití: { HttpClientRequest req = co_await client.GET("https://..."); req.add_header("Authorization","..."); ws::Stream stream = co_await WebsocketClient(req); //handshake .... }
Tato vlastnost je umožněna zejména tím, že objekt coro::future nepoužívá přetížení operatoru co_await(), ale chová se přímo jako awaiter. To umožňuje objekt vracet jako výsledek přetíženého operátoru co_await() v jiné třídě.
Řízení korutiny se neomezuje jen na zavolání a počkání na výsledek. Samotná korutina může být řízena dalšími nástroji, které jsou součástí výbavy libcoro
sleep_for
a sleep_until
, přičemž čekání neblokuje vláknoco_await coro::pause()
co_await coro::condition()
a coro::notify_condition()
Úplný seznam funkcí a tříd lze najít v referenční příručce u projektu.
Myslím si, že toto je ideální místo, kde rychlé představení knihovny ukončit. Jít do detailu by zabral mnohem více místa a článek by byl velice dlouhý. Pro podrobnější studii tedy doporučuji navštívit stránky projektu na githubu. Budu rád za jakoukoliv kooperaci ve vývoji. Knihovna je a zůstane open source v licenci MIT.
Od C++ jsem roky pryč, tak je možná můj komentář netypický, protože takhle se tam věci dělají, ale...
Neměl by být jeden interface future<> a případně několik implementací jako shared_future<> a deferred_future<> ? Jako klienta mě typicky nezajímá, jestli ještě někdo chce tu future<> využít, případně, jestli se pustí dřív či později.
Kromě toho mi přijde deferred_future<> trochu proti principu, kdy se očekává, že kód skutečně poběží non-blocking.
Jde převést future na shared_future na straně volajícího, tedy funkce vrací future ale já chci tam dat shared_future, tak mohu. Akorát je problém v tom, že to nejde udělat bez move operace, protože v tu dobu už někde existuje reference na již existující instanci. Takže to jde udělat jediné tak, že shared_future inicializuju lambdou která vrací future. Využívá se toho, ze shared_future si necha takto zinicializovat interni instanci future která se pak sdílí
Absolutně v tomhle systému nejde použít jakékoliv interface, protože v tu chvíli tam mám heap alokace
Deferred future nemusí nutně být blocking, on ten odložený výpočet může predstavovat jen jeho zahájení a dál jede jako future, Deferred future je dokonce potomkem future
Ta třída je takovým kompromisem kdy autor funkce si je vědom jak se bude obtížně pracovat s výsledkem, pokud by vracel future. Prostě ta informace je o tom, že volající ví, že po návratu z funkce ještě pořád neběží nic paralelně, že si může zvolit ten okamžik spuštění sám podle sebe. A pak je tam ten symetrický transfer, kdy lze takto přepínat korutiny - třeba právě asynchronní generátory
Chápu, že jde zabalit jedno do druhého. Na druhé straně je to značně netransparentní pro ostatní, bo nějaká úroveň musí najednou vědět, jestli někdo výš bude potenciálně potřebovat sdílet výsledek. Pokud bych už šel takovou zkratkou, tak bych udělal tu základní věc jako sdílenou.
Argument ohledně heap alokací mi přijde zcestný, neboť coroutines vyžadují heap alokace už z principu (celý stack je na heap, lambda je snad taky na heap (?) ).
Z toho deferred_future<> jsem teď zmatený - takže je to prakticky lambda, která teprve pustí výpočet a vlastně vrací future<>, akorát je to celé dáno do jednoho objektu? Vzhledem k tomu, že je záměrně netransparentní pro volajícího, možná by mělo smysl ty dvě věci rozdělit...
Korutina je heap. Ale jen jedna alokace na celou korutinu.
Callback je alokace, jedna alokace na každý callback. - mimochodem coro::function ktera se tam používa alokuje až když callback má víc než 24 bajtů (3x pointer), jinak se obejde bez alokace.
Jakmile by ten objekt alokoval jako callback, pak by to postrádalo smysl, protože by použití korutin nepřinášelo žádnou výhodu, snad kromě čitelnějšího kódu za cenu horší performance.
Tady je možnost právě umístit sdílená místa do frame korutiny, který je jedenkrát předalokované, ale já mohu v korutine mít cyklus a milionkrát vytvořit a zahodit future.
A v tom je celý point.
"Z toho deferred_future<> jsem teď zmatený - takže je to prakticky lambda, která teprve pustí výpočet a vlastně vrací future<>, akorát je to celé dáno do jednoho objektu? Vzhledem k tomu, že je záměrně netransparentní pro volajícího, možná by mělo smysl ty dvě věci rozdělit.."
V zásadě ano. Ovšem, kolikrát a v jakém formátu vracíš z funkce lambdu? Napiš mi nějaký prototyp deklarace funkce, co vrací lambdu.
Určitě použiješ std::function - heap allocation
(deferred_future - tedy použije coro::function, ten do 24 bajtů nealokuje, a handle korutiny má 8 bajtů, tam se vejde)
Na std::function neuděláš co_await, musíš z toho tedy nejprve vyextrahovat tu future.
na deferred_future uděláš co_await a všechno se to stane v tomto jednom kroku!
- zavolá lambdu (zapamatuje se vrácené handle, pokud je to korutina)
- předá jí promisu
- zaregistruje handle korutiny
- přepne do korutiny, která teď zahájí výpočet (pokud to není korutina, provedlo se to jako první bod)
jasně že by se to dalo rozepsat do víc kroků a navrhnout to takto jako separátní objekty. Budeš to takhle pak rozepisovat všude tam kde to použiješ? Nebo dáš přednost jednomu objektu? To je princip programování skládáš větší objekty z menších.
ještě k deferred_future
pokud napíšu
co_await foo()
tak pokud foo() je korutina nebo generátor, tak samotné zavolání funkce přepnutí nezpůsobí. Přepnutí způsobí až co_await, ten tedy vyžaduje aby foo() vrátila awaiter. tady právě deferred_future je ten awaiter který teprve v co_await zahájí operaci a dojde k přepnutí.
pokud by foo vracela pouze future, pak co_await nemůže nic přepínat, protože z jeho pohledu už výpočet běží. Takže pouze zaregistruje korutinu k probuzení na výsledku a vrátí řízení o level výš. z hlediska efektivity je lepší, když dvě korutiny se přepínaji na deferred_future než na future
abych zachoval určitý level abstrakce, tak i samotna future umi deferred režim a jak jsem psal v článku, smyslem deferred_future je hlavně zlepšení čitelnosti a umožnění move toho objektu, tedy pohodlnější zacházení (protože tam je move možný), pokud volaný 100 procent ví, že tam bude vždy korutina, muze tuhle informaci tímto způsobem předat volajícímu a tím mu rozvázát ruce,
Sám korutiny taky nevyužívám a nevyhledávám, ale taky proto, že nakonec skoro nejpodstatnější je ten backend pro asynchronní oprace pro sockety, soubory atd. Na to navazující podpora protokolů http, websocket atd. Samotný korutiny mi ani nepřišly, že by měly moc využití. Ale každopádně hodně štěstí s vývojem.
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 50 905×
Přečteno 23 811×
Přečteno 22 798×
Přečteno 20 814×
Přečteno 17 682×