Vlákno názorů ke článku Zamyšlení se nad korutinami v C++20 od MarSik - Barvení funkcí (dva světy) je známý problém i...

  • 28. 4. 2025 9:43

    MarSik

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

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

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

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

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

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

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

  • 28. 4. 2025 12:09

    Ondřej Novák

    Ahoj, dík za reakci

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

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

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

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

  • 28. 4. 2025 13:02

    Ondřej Novák

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

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

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


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

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

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

  • 28. 4. 2025 13:39

    MarSik

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

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

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

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

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

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

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

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

  • 28. 4. 2025 14:28

    Ondřej Novák

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

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

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

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