Minulý článek vyzněl jako kritika implementace korutin v C++20. To jsem ani nechtěl, naopak si myslím, že je to skvěle vymyšlené s ohledem na užitečnost a vyjadřovací svobodu, kterou to přináší. Problémem je spíš neexistence pravidel a doporučení jak korutiny implementovat.
Nositelem takových doporučení je často právě STL, která ve verzi 20 toho v oblasti korutin mnoho nepřináší, a nejinak je i v nových verzích. V C++23 se chystá std::generator<>, který implementuje synchronní generátor – to je generátor, který nesmí použít co_await (mimochodem v mé implementaci coclasses toto omezení není). Malý krok v rámci pokroku. Neexistence nějakých jednotících pravidel pak může vést na chaos a nekompatibilitu mezi různými implementacemi knihoven. Jen si představte, že si z githubu stáhnete tři knihovny, které používají korutiny a zjistíte, že si mezi sebou nerozumí a pro zaintegrování do vašeho kódu si budete muset napsat čtvrtou knihovnu. Dalo by se to přirovnat například k výjimkovému systému v C++. Vyhazovat jako výjimku lze cokoliv, ale kompetentní programátor si dnes nedovolí vyhazovat výjimku, která nedědí std::exception, protože když taková výjimka vypadne ven, umí se aspoň představit a oznámit, co se vlastně stalo.
O jednom takovém “zádrhelu” je i tento článek. Než se k tomu dostanu, vrátím se k dotazu, který se objevil pod předchozím článkem v komentářích. Jen si ho trošku upravíme. Představte si kód
auto data = co_await socket.read();
Zápis znamená, že kód je součástí korutiny a předpokládá se asynchronní čtení ze socketu. Abych se vyhnul detailům, nebudu se teď zabývat v jaké formě jsou předána ta data. Může to být třeba std::vector<char>. Dotaz zněl, jak je tato operace implementovaná, jestli se třeba vytváří vlákno nebo jak?
Systém korutin tohle neřeší. Operátor co_await přináší mechanismus jak uspat právě běžící kód. Je to podobně jako například zastavení vlákna na condition_variable nebo na mutex. Rozdíl je v tom, že zde není vlákno zastaveno, pouze současná korutina je přerušena a její stav zachován a současné vlákno pokračuje kódem toho, kdo korutinu původně zavolal (nebo probudil, viz dále). Může se tak věnovat něčemu jinému. Například si představte server, který takto získá volné vlákno, aby mohl obsloužit dalšího klienta.
V tomto příkladě asynchronní čtení obstará funkce read objektu socket. Jak? Jak by se to dělalo bez korutin? Typicky nejprve se zkusí socket přečíst neblokovaně (nonblocking mode). Pokud jsou data již připravená, jednoduše se přenesou a je to hotové, žádné uspávání není třeba. Problém je, když data připravená nejsou, pak operace čtení vrátí chybu EWOULDBLOCK. K monitoringu socketu máme funkce select, poll nebo epoll. Zejména poslední vyjmenovaná se s výhodou používá k hromadnému monitorování socketů. K obsluze epollu budeme muset mít nějaký nástroj ve formě sdíleného objektu (sdílený všemi sockety). Takový má zpravidla vyhrazeno jedno nebo více vláken, nějakou mapu monitorovaných socketů a tedy náš socket-objekt nejspíš “outsourcuje” monitoring do takového nástroje. Pak, jakmile se na socketu objeví data, epoll se probere, zjistí jakého socketu se to týká a pošle mu notifikaci ve formě zavolání funkce nebo callbacku. Na základě této notifikace je pak korutina vzbuzena a pokračuje ve výkonu dalšího kódu.
Funkce read() musí implementovat “awaitera”. To je jednorázově vytvořený objekt, který žije ve exekučním frame korutiny počas jejího spánku. Objekt musí implementovat tři metody, které se volají postupně:
Možnosti awaiterů jsou široké. Norma C++20 dále definuje operator co_await kterým lze konvertovat jakýkoliv objekt na awaiter a tento může být definován jak na objektu, na který se bude čekat, tak i jako globální funkce (podobně jako jakýkoliv jiný operátor) – tam máme možnost “naučit” pracovat s co_await i objekt, který původně pro korutiny nebyl určen. Jako příklad (sice ne moc dobrý) by mohla být konverze std::future na awaitera přes:
template<typename T> auto operator co_await(std::future<T> &);
Chtěl jsem ukázat na awiaterech, jaké široké možnosti jsou k dispozici pro uspávání korutin. Ale jak je to s jejich buzením? Standard zde nabízí jedinou funkci
h.resume(); //kde h je typu std::coroutine_handle<>
případně ve variantě callable
h();
A to je vše přátelé. Konec článku.
Dělám si legraci, tam určitě bude nějaký zádrhel. To co tato funkce udělá je, že vytvoří nový zásobníkový rámec a v něm obnoví stav vybrané korutiny a tu spustí z místa, kde naposledy byla korutina uspána. Standard neřeší, v jakém kontextu to volání probíhá a zda vůbec je vhodné na tomto místě korutinu probudit. Vraťme se k našemu příkladu se socketem. V okamžiku, kdy se na socketu objeví data, první to zjistí epoll, který je provozován v nějakém vlákně. To nakonec pošle notifikaci objektu socket a ten probudí korutinu. To vše se děje ve stejném vlákně. Běda pokud ta korutina se rozhodne na základě vrácených dat spustit nějaký komplikovaný výpočet. Tím jsme si dokonale zablokovali náš monitorovací nástroj!
Jak tedy správně probudit takovou korutinu? Nelze ji probudit v původním vlákně (ve vlákně, ve kterém byla poprvé uspána), protože to vlákno již provádí jiný kód. A neexistuje způsob, jak tomu vláknu říct, aby se teď chvíli věnovalo probuzené korutině. Leda že by vlákno obsahovalo nějakého dispečera (angl. dispatcher), tak jak bývá zvykem v jiných programovacích jazycích, třeba v Javascriptu, nebo v Javě. Typicky hodně jazyků, které se chtějí vyhnout složitému programování vícevláknových aplikací převádí problém na korutiny s jedním vláknem implementující dispečera. Korutina je vložena do fronty dispečera a jakmile dispečer převezme řízení, vyzvedne korutinu z fronty a tu probudí.
V C++ standardní dispečer není, a nikdy nebude (ale je ve WinAPI, můžete si handle korutiny přeposlat přes PostMessage a vyzvednout v GetMessage).
Náš monitorovací nástroj se může bránit tím, že má k dispozici thread pool a má možnost pro korutinu alokovat další vlákno (thread_pool má funkci alokace vlákna pro korutinu) Tohle je ještě jednoduché, ale představme si jiné programovací primitiva v rámci korutin. Například mutex nebo frontu (queue). Mutex podporující korutiny má funkci co_await lock(), který uspí korutinu dokud je mutex zamčený a probudí, jakmile je mutex odemčen. Fronta může mít funkci co_await pop(), která uspí korutinu, když je fronta prázdná a probudí ji, jakmile je do fronty vložen prvek. Obě primitiva řeší svou funkcionalitu a nejsou zodpovědné za systém buzení korutin. Kdy se vzbudí korutina, která čekala na mutex?
Původně jsem měl v plánu napsat článek, kde obhájím, jeden z výše vyjmenovaných systému. Ale pak mi můj kolega z práce navrhl, ať si všechny případy nadefinuji jako resumption policies (inspirace v execution policy). Každá korutina přitom může chtít být buzena jinak.
Problém je, že o buzení rozhoduje někdo jiný, někdo, kdo o korutině neví zhola nic. Mutex, queue, atd…
Nejbližší vztah s korutinou má její awaiter. A ze narážíme na nedotaženost standardu, protože neexistuje žádné pravidlo, žádná směrnice, jak tomu, kdo korutinu bude budit, předat informaci o tom, jak si daná korutina přeje být buzena. A nejspíš se bude stávat, že korutiny budou z jedné knihovny a awaitable primitiva budou z jiné knihovny.
V následujícím systému budu popisovat vlastní systém, tedy jde jen o jakýsi návrh nebo dočasné řešení, než se mezi programátory rozšíři nějaký jiný systém, ať už samovolně nebo to někdo nadefinuje v normě
Rozšiřme si definici třídy task<>
template<typename T, typename resumption_policy = void> class task;
Korutina s takovou deklarací si přes resumption_policy může definovat vlastní pravidla pro svoje buzení. Například
cocls::task<int, cocls::resumption_policy::dispatcher> example(int x) { co_await … neco… co_return 42; }
Výše uvedená korutina chce být buzena přes dispečera vlákna ve kterém byla vytvořena. V knihovně coclasses najdete třídu cocls::dispatcher, kterou můžete instanciovat v libovolném vlákně
Nadefinoval jsem několik policies, zde je jejich výčet, všechny se nachází v namespace cocls::resumption_policy
Jako programátor máte možnost si definovat vlastní policies, ale tím se teď nebudu zabývat
Implementace resumption policies ale není žádný standard, zde jsem si musel rozšířit pravidla o další body. Například pravidla kolem awaiterů. Normálně totiž neexistuje způsob, jak by korutina předala awaiterovi informaci o tom, jakou policy chce pro své probouzení použít. Dovolil jsem si tedy vedle tří povinných funkcí awaitera (viz nahoře) přidat čtvrtou funkci
template<typename Awt, typename Policy> static auto set_resumption_policy(Awt &&awt, Policy &&policy)
I když musí být funkce deklarovaná na awaiteru, je deklarovaná jako static a instanci awaitera obdrží jako parametr. To umožňuje dědit tuto funkci do potomků awaitera a přitom předat jeho posledního potomka. Druhým parametrem je instance vlastní policy. Funkce musí vytvořit kopii awaitera, který má nastavenou policy a slibuje tak, že při buzení bude tuto policy respektovat. Výsledný awaiter se použije pro funkci co_await. Jak má ale korutina řídit awaitery na které chce čekat, aniž by to musel řešit programátor ručně? Norma nám dává do ruky nástroj ve formě funkce deklarované ve své promise třídě.
template<typename X>
auto await_transform(X &&awt);
Tato funkce, pokud je definovaná, je volána jako první v případě, že kód narazí na co_await. A teprve to co tahle funkce vrátí se považuje za awaitera a na něho se “čeká”.
A teď si jen představte ten template hell, který tam vznikne. Pokud jde jen o zavolání set_resumption_policy, pak je třeba zjistit, jestli předaný objekt vůbec tuhle funkci deklaruje. Do toho je třeba simulovat standardní chování, například zkusit zavolat operator co_await, protože tohle volání se jinak automaticky volá jen v případě, že await_transform není definován. Atd. Tohle peklo jsem se pokusil vyřešit v coclasses.
Takže pokud korutina musí co_await na něco, co funkci set_resumption_policy nepodporuje, pak nemá šanci ovlivnit své probuzení. Je třeba s tím počítat při návrhu kódu. Policy v tomto případě není povinné dodržet, je to spíš takové “přání”. Pokud tedy korutina bude čekat na něco, co nemá nic společného s mým systémem, tak má smůlu. Ale naštěstí by neměl být problém takový kód aspoň přeložit.
Nicméně je třeba říct, že v některých případech nechceme policy dodržet. Třeba co_await nad thread_poolem je interpretováno jako přesun korutiny do thread poolu. To se realizuje jako uspání korutiny a její probuzení v jiném vlákně. V takovém případě asi nechceme aby nám do toho resumption policy mluvila. Stačí prostě výše zmíněnou funkci nedeklarovat.
System resumption policy je definován jen pro task<> a nepoužívám jej u generátorů. U synchronních generátorů vůbec není potřeba, protože tam se generátor spouští v kontextu vlákna, který ho volá. U asynchronních generátorů by to asi smysl mělo, nicméně tam se předpokládá, že generátor nakonec udělá co_yield, který ho přeruší a uvolní vypůjčené vlákno. Pokud se na asynchronní generátor čeká synchronně, volající po celou dobu blokuje své vlákno, a pouze v případě, že asynchronní generátor je volán z korutiny, která na něho čeká přes co_await, tam se resumption policy uplatňuje, protože generátor poskytuje kompatibilní awaiter.
Resumption policy má vliv i na spouštění korutiny. Na to lze hledět také jako na vytvoření korutiny a jeji probuzení – pak je probuzena na svém začátku. I tady se na to může aplikovat resumption policy. Tento případ se ale v rámci implementace policy řeší odděleně.
Problém nastává u resumption policy, která vyžaduje další parametry. Korutina neumožňuje jakkoliv definovat parametry policy při svém zavolání. Tam prostě není žádný prostor, jak parametry předat, předávají se pouze parametry vlastní korutiny.
K tomuto účelu jsem zavedl další metodu přímo ve třídě task<>
template<typename ... Args>
void initialize_policy(Args && ... args)
Příklad
cocls::task<void, cocls::resumption_policy::thread_pool> example(int) { … } auto pool = std::make_shared<cocls::thread_pool>(8); auto mytask = example(42); //korutina startuje uspaná mytask.initialize_policy(pool); //teď se korutina vzbudí v thread poolu
Ano, vypadá to velice složitě, taková “raketová věda”. Ale složité je to spíš pro tvůrce knihoven, nemělo by to být složité pro uživatele. Já jsem chtěl jen ukázat problémy, které vidím a navrhnout své řešení. To řešení naleznete v mé veřejně dostupné knihovně (coclasses). Můžete to používat (licence MIT), nemusíte, můžete se jen inspirovat.
Netvrdím, že se systém resumption policy dostane do nějakého budoucího standardu. Ale nějaké sjednocení pravidel nejspíš bude muset přijít. Do té doby si programátor bude muset vybrat vhodnou knihovnu pro práci s korutinami a výsledkem bude nejspíš chaos a zmatky kolem kompatibility. Ale což, nic na co bychom nebyli u C++ zvyklí léta. Vyjadřovací svoboda má své stinné stránky.
V nějakém dalším článku bych se podíval na praktické zkušenosti s korutinami – tam už bude méně toho “temného chaosu” pod povrchem. Na povrchu je vše hezky “sluníčkové”.
Priklad s WinApi je docela dobry - vsechny graficke toolkity, ktere znam (GTK, Qt, wxWidgets) obsahuji event loop a pripadne thread-safe posilani zprav. Nevidim zadny problem v tom, abych zaridil, zda se tento callback/timer provadi v hlavnim vlaknu nebo si pro to vytvorim vlakno zvlast a vysledky operaci vracim pomoci zprav hlavnimu vlaknu. Pro takove pripady je ve vsech toolkitech i thread pool, ktery bude mit pri vicenasobnem pouziti urcite mensi rezii, nez vytvareni a ruseni vlaken.
Ostatne Javascript funguje na podobnem principu.
Ok, dokážu si představit postmessage resumption policy pro winapi. Tam pak stačí, když se nadefinuje patřičná struktura a v ní funkce resume(h), která udělá PostMessage(hWnd, APP_RESUME_COROUTINE,0,(LPARAM)h.address()).
a jakmile přes GetMessage vyzvednu APP_RESUME_COROUTINE, převedu LPARAM na void* na to existuje funkce std::coroutine_handle<>::from_address() a z ní obdržím zpátky handle, na které zavolám h.resume().
(předpokládám, že předání hWnd a konstanta APP_RESUME_COROUTINE) si někde nadefinuju...
V coclasses je třída cocls::thread_pool, i cocls::dispatcher, takže i bez podpory nějakých knihoven si člověk vystačí.
C++11 pridalo novou syntaxi napr. &&, to by se mi libilo kdyby se primo do C++ pridal channel a goroutiny jako v Golangu :-) netusim jaka by byla vhodna syntaxe, ale ze by se to stalo organickou soucasti jazyka.
Channel a gorutiny mi pripominaji bash script :)
Korutina vraci future promennou, kterou muzes priradit kam chceš než získá hodnotu. Až když ji potřebuješ do výpočtu tak si ji vyzvedneš přes co_await. Neni to podobne? Nebo můžeš do korutiny nastrkat nekolik cocls::queue a pak v jine korutine ty fronty cist a máš to jako pipes
Channly v Gočku jsou prostě MPMC fronty. Přirovnávní s shellem není na místě, shell pipes jsou SPSC. Nejsem nějaký kdovíjaký fanoušek Gočka, ale ty jejich channely jsou implemetovány velmi dobře - mají velmi slušný výkon, ale přitom jsou poměrně flexibilní - je možné rozdělit frontu na producer/consumer nebo nechat v jednom a je možné specifikovat maximální kapacitu.
Vzehldem k tomu, že si sám píšeš MPMC frontu, Go channels bych nepodceňoval a podíval se na jejich implementaci, naivně implementovanou frontu+runtime pravděpodobně roznesou svým výkonem na kopytech :-) ... bez urážky. Stejnětak jsou hodny pozornosti channels v Rustu. Určitě by bylo záhodno nabízet zastropovatelnou frontu (kvůli propagaci backpressure).
naivně implementovanou frontu+runtime pravděpodobně roznesou svým výkonem na kopytech
Chtěl bych napsat _UKAŽ_. Trochu mi to tu zavání vírou a fundamentalismem, než nějakou technickou diskuzí. Co mi brání napsat frontu v C++ která se výkonově vyrovná? Jsem schopen napsat frontu rychleji jako prasárnu, nebo si C++ překladač poradí s výkonem i když použiju std::queue. Jen tak mimochodem, std::queue na tom s výkonem není vůbec špatně (oproti třeba implementaci rour ve Win32 :-D, které jsou realizované jako pole bajtů, které se šoupe pamětí - tedy stav ve WinXP, od té doby jsem byl v linuxu, tak nevím)
Ale můj argument je trochu jinde. Drtivá většina korutin, ale i threadů nepotřebuje na předání výsledku frontu. To musí být obrovský výkonový overhead, když pro předání výsledku musím alokovat prostor pro výměnu dat. Zrovna v korutinách si mohu předat jen pointer i kdyby výsledkem byla třeba komplikovaná struktura nebo velký objekt.
Zastropovaná fronta je problém. Jak řešíš její naplnění? Výjimkou. No a dál? Je to vlastně řešení? Nehledě na to, že si to můžeš řešit samozřejmě sám, zeptáš se na velikost fronty a pokud je moc velká, hodíš výjimku - třeba si můžeš ten objekt podědit a doimplementovat si to. Jasně, rozumím tomu, že zastropovaná fronta má jednodušší memory managment. Ale tam se získa promile výkonu, nestojí za optimalizaci - a vůbec, klidně mohu tu frontu udělat i genericky, jako že konkrétní implementaci fronty si dodá uživatel jak parametr šablony.
Trochu mi to tu zavání vírou a fundamentalismem, než nějakou technickou diskuzí. Co mi brání napsat frontu v C++ která se výkonově vyrovná?
Hlavně v klidu. Bylo to myšleno jako inspirace. V C++ samozřejmě je možné napsat rychlou frontu.
Ale můj argument je trochu jinde. Drtivá většina korutin, ale i threadů nepotřebuje na předání výsledku frontu.
Samozřejmě. Proto taky třeba ty gorutiny mají normální návratovné hodnoty. Fronta/kanál se použije pouze pokud to je potřeba.
Zastropovaná fronta je problém. Jak řešíš její naplnění? Výjimkou.
No to rozhodně ne. Jednak zmiňované jazyky výjimky ani nemají a jednak tím by se úplně popřel smysl zastropované fronty. Korutina, která se snaží zapsat do plné fronty je uspána, dokud se ve frontě neudělá místo. Tzn. když máš třeba skupinu producerů nějakých dat a nějaký consumer [nebo několik], obvykle je vhodné použít na to MPSC [MPMC] frontu se stropem, protože když z nějakého důvodu dojde třeba ke zpomalení consumerů, nechceš, aby fronta začala bobnat v paměti do závratných objemů. (Samozřejmě je možné tohle signalizovat producerům i nějak bokem / mimo frontu, ale často právě přes frontu to je dostačující a celkem elegantní řešení.)
Jasně, rozumím tomu, že zastropovaná fronta má jednodušší memory managment.
Není jednoduší, je to stejné. Zastropovaná neznamená konstantní velikost / konstantní alokaci, ta fronta je stále alokovaná úplně stejně dynamicky jako nezastropovaná, pouze je tam navíc ta garance, že nepřekročí nikdy určitou velikost (nevyleze mi z paměti, když se zaseknout consumeři).
Jako člověk, co pracuje s producenty typu "burzovní informace" nejsem s řešením pozastavení producenta moc spokojen
Go mimochodem hodí výjimku, vyzkoušeno (nebo možná chybu, nezkoušel jsem, jestli se dá zachytit)
Zastavování producera může vést k deadlocku. Například pokud je ta fronta oboustraná tedy ten vztah je symetrický a obě strany se přeplní. Takový pattern request-response, kdy requester vygeneruje tolik requestů, že zaplní request frontu zatímco responder zaplní response frontu. Pak requester místo aby vybíral respond frontu je bloklý na request frontě, která je plná. Znám moc dobře tyhle bolestivé situace z praxe.
Ale k tomu se možná spíš hodí publisher-subscriber, který tohle má řešeno různou formou, například přeskočením fronty na její top, nebo přeskakování na konci, když už subscriber (což je role konzumenta) nestíhá a fronta se mu pod rukama maže, začne přeskakovat data. Tohle si lze samozřejmě v nějakém option vybrat přímo na subscriberovi, s tím, že jedna z options je samozřejmě vyhození výjimky na straně subscribera, když nestíhý - aniž bych ovlivnil publishera.
Zpomalování producera/publishera nechci řešit na téhle základní úrovni, mimochodem by to znamenalo další frontu čekajících korutin, další signalizační mechanismus, další komplikace jinak jednoduchého kódu. Pokud to někdo potřebuje, ať si to podědí, a doimplementuje si to. Asi není těžký udělata awaitable operaci push, awaitera k tomu určenému a mechanismus probouzení čekající producerů, jakmile někdo udělá pop().
Jako člověk, co pracuje s producenty typu "burzovní informace" nejsem s řešením pozastavení producenta moc spokojen
Samozřejmě, existují producery, kde je kritické, aby produkovaly. Ale i v takovém případě bude nejspíše zastavení producera frontou menší katastrofa než bad_allocy vyskakující na X různých místech v kódu nebo si nechat sestřelit OOM killerem celý proces... Pokud dojde paměť, ten producer stejně tak jako tak nemůže pokračovat (nemá kam dávat výsledky)... Samozřejmě záleží na konkrétní situaci, reálně bude možná spíš řešením použít kafku nebo tak něco... Ale to už bychom se dostali někam úplně jinam...
Go mimochodem hodí výjimku, vyzkoušeno (nebo možná chybu, nezkoušel jsem, jestli se dá zachytit)
Výjimku Go opravdu nevyhodí. Možná ti to z nějakého důvodu panikuje, což může vypadat jako výjimka (a je to i implementováno podobně, ale <i>není</i> to mechanismus oštření chyb, spíš něco jako assert). Pokud ti to panikovalo, možná ses pokoušel zapisovat do zavřeného kanálu. Jinak zápis do plného kanálu by opravdu měl pozastavit korutinu, případně je možné se dotázat kanálu, jestli je možné aktuálně zapisovat (nevzpomínám si na syntaxi, ale jde to).
Zastavování producera může vést k deadlocku.
To je velmi obecný argument, v MT/multitask programování může vést k deadlocku leccos. S korutinami si můžeš uhnat deadlock i na jednom vláknu, a to dokonce i v tom Rustu (kde kompilátor umí eliminovat race-conditions, ale deadlocky ne). Prostě je potřeba si dát na deadlocky pozor obecně :-)
mimochodem by to znamenalo další frontu čekajících korutin, další signalizační mechanismus
Jistě. Což ale není žádná katastrofa. Různé implementace to řeší různě. V Rust Tokio frameworku jsou k dispozici dva různé typy kanálů (V C++ by se řeklo třídy, rust nemá třídy), zastropovaný a nezastropovaný. Anebo je možné to implementovat v jednom a mít třeba dva různé konstruktory a v tom bez stropu prostě nenaalokovat frontu tasků na vstupu. To už jsou implementační detaily...
Omluva za exhumaci, čtu diskusi po delší době od publikace.
A nepobírám korutiny, a jsem hobbík, ale kdysi jsem psal nějaký protokolový stack, který dělal request/response (modbusovou gateway). Takže chci zareagovat na toto:
Zastavování producera může vést k deadlocku. Například pokud je ta fronta oboustraná tedy ten vztah je symetrický a obě strany se přeplní. Takový pattern request-response, kdy requester vygeneruje tolik requestů, že zaplní request frontu zatímco responder zaplní response frontu. Pak requester místo aby vybíral respond frontu je bloklý na request frontě, která je plná. Znám moc dobře tyhle bolestivé situace z praxe.
Toto jsem rozdělil do dvou vláken. Jedno pro TX (request), druhé pro RX (response). Každé vlákno smí nést riziko "usnutí na neurčito" (zablokování) pouze v jednom bodě - a pokud se pracuje s frontou ošetřenou pomocí podmínkové proměnné, tak k tomu blokování třeba při čekání na I/O nesmí dojít při zamčeném mutexu této fronty+proměnné. Kritická sekce kolem "manipulace s frontou" má být minimalistická, má se vrátit bez zbytečného zdržování a bez rizika náhodných chybových stavů.
No ja radši používám nekonečné fronty. Nám takhle umřel server hry Prima Trefa ve špičce při reklamě během serialu "slunečná". Přišlo desitky tisíc requestů během sekundy a všechny fronty kolem všech vláken vydeadlockovaly, protože měly limit. Proč jsem na strojích s pamětí nekolik desitek GB daval limity? Asi jsem měl zatmění
No a je to tam, mohu si definovat jak implementaci vlastní fronty, tak fronty čekajících korutin (klidně si tam mohu dát i frontu o jednom prvku, jen na to zatím není patřičná třída, ale to je primitivní) a mohu si určit i implementaci zámku, takže v single thread prostředí si tam místo toho mohu dát prázdný zámek a nic se zamykat nebude.
https://github.com/ondra-novak/coroutines_classes/blob/master/src/coclasses/queue.h#L22
spam:
rob pike sice objasnuje, ze paralelizace neni konkurence, ale kolik mame ruznych metod paralelizace.
simd, thready, procesy se sdilenou pameti, vedatori maji mpi, openmp, cuda, opencl, korutiny, co tam mame dal?!
A? Je snad překvapivé, že máme paletu nástrojů s různýma vlastnostma?
A podle toho Pikeova dělení spadají korutiny pod konkurenci a ne paralelizaci.
Z mého pohledu zásadní problém C++ coroutines je, že typ, který korutina vrací, pochází z nějaké konkrétní korutinové knihovny, tzn. přímo deklarace každé korutiny je závislá na konkrétním runtime. Tohle bych čekal, že by se snad mělo dobudoucna vyřešit..??? Ale přitom už teď je ta koncepce poměrně složitá ... obecně z toho mam pocit, že to dopladlo klasicky á la C++ - za mnoho komplexity málo muziky. (Asi jako když byly přidány move semantics :-/ )
Ze stejného důvodu mi nepřijde moudré dávat do signatury korutiny resumption policy - korutinám by to mělo být víceméně jedno. Respektive bych resumption policy nahradil thread-safety příznakem (non-thread-safe korutina nemůže být přesouvána mezi vlákny v thread-poolu) a zbytek nechal na rozhodnutí/nastavení runtimu.
Vraťme se k našemu příkladu se socketem. V okamžiku, kdy se na socketu objeví data, první to zjistí epoll, který je provozován v nějakém vlákně. To nakonec pošle notifikaci objektu socket a ten probudí korutinu. To vše se děje ve stejném vlákně. Běda pokud ta korutina se rozhodne na základě vrácených dat spustit nějaký komplikovaný výpočet. Tím jsme si dokonale zablokovali náš monitorovací nástroj!
U nás v Rustu to máme zařízeno tak, že korutina (respektive v Rustu tomu říkáme Future) se nesnaží sama vymyslet, kde/jak se má probudit po awaitu, ale pouze zasignalizuje runtimu skutečnost, že by měla v dohledné době běžet přes callback (tzv. Waker) dodaný dynamicky runtimem, a je na runtimu, aby pak danou Future nějakým způsobem znovuspustil.
Nutno dodat, že ani async kód v Rustu často není reálně nezávislý na runtimu (většina lidí prostě použíje ten nejpopulárnější, nebudu jmenovat :D), což je nutné kvůli implementaci socketů, časovačů apod. Ale přecijen to oddělení je obecně lepší a např. v mnoha async aplikacích to mam tak, že při 'ostrém' běhu se použije plnohodnotný thread-pool, zatímco v testech ten stejný kód běží na jednovláknovém runtimu.
Co se týče složitosti runtimů, to je IMO nevyhnutelné. Pokud má runtime poskytovat dostatek featur pro psaní asynchronních aplikací (sockety a jiné I/O, časovače, fronty, sychnronizační primitiva, ...) a slušný výkon, tak IMO prostě moc KISS nebude...
A nebude ten thread safety příznak něco podobného? Policy ve skutečnosti se odkazuje na třídu, která dodatečně implementuje ona pravidla, která policy představují.
Rozšíření parametrů šablony není problém, nikdo vám nebrání udělat si
template<typename T> using dtask = task<T, resumption_policy::dispatcher>
a od toho okamžiku používat dtask<> místo task<>. Trochu záměrně nezmíněnou vlastností mé implementace je, že jakýkoliv task<T, policy> lze později převést na task<T, void>, ten se zkráceně dá zapsat jako task<T>, takže uživatel takové korutiny už nepotřebuje řešit policy,
Jak jsem psal, ten návrh jsem udělal namísto původně rozpracovaného jiného návrhu, který je nyní pod policy resumption_policy::queue, kde právě ono obnovení korutiny je signalizováno - korutina je vložena do lokální fronty - a jakmile je vlákno volné, může se věnovat frontě a postupně jednu po druhé provede. Ale takový systém je víc vázán na konkrétní, mnou dodaný runtime. Místo toho jsem pak šel cestou obecnější, kdy právě v návrhu umožňuji aby korutina si sama mohla vybrat jak bude buzena. Dokážu si představit implementacu dispatch-like aplikace, kde budu mít jedno hlavní vlákno a v něm se bude spouštět většina korutin všechny vázané na dispatcher policy a to aniž bych musel upravit jedinou čárku kódu v mé knihovně.
Právě snaha minimalizovat runtime, nechat implementační volnost je možná důvod, proč se toto v C++ neřeší.
Ještě k move semantics. Používám na denní bázi a nemyslím si, že by to bylo málo muziky. Naopak vytrhalo dost trnů z pat.
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 251×
Přečteno 24 066×
Přečteno 22 919×
Přečteno 21 133×
Přečteno 17 849×