8. cvičenie – Asynchrónne programovanie

V tomto cvičení preberieme základy synchrónneho programovania. Začneme jednoduchým synchrónnym príkladom, ktorý budeme postupne upravovať až z neho na záver bude asynchrónny program. V druhej časti cvičenia budeme aplikovať asynchrónne programovanie na posielanie HTTP žiadostí. Porovnáme tiež zrýchlenie tejto implementácie oproti synchrónnej verzii.

Cvičenie je vypracované podľa learnpython.com.

Synchrónne programovanie

Začneme jednoduchým príkladom, v ktorom máme definovanú jednoduchú úlohu reprezentovanú funkciou task(). Táto úloha vyberá z fronty dáta na spracovanie (v našom príklade sa jedná o celé čísla) a po vybratí z fronty ich spracuje (v cykle spočíta jednotky a vypíše na obrazovku). Ak je fronta prázdna, úloha sa ukončí.

V hlavnej funkcii programu potom vytvoríme dve úlohy, ktoré následne v cykle spustíme.

Z výstupu vidíme, že na začiatku sa práce chopila prvá úloha, ktorá spracovala všetky údaje z fronty a ukončila činnosť. Druhá úloha už nemala s čím pracovať. Úlohy idú za sebou synchrónne.
Výstup:

Jednoduchá kooperatívna konkurencia

V tejto časti budeme implementovať vlastnú kooperatívnu konkurenciu. Potrebujeme k tomu urobiť dve veci. Funkciu task() zmeníme na generátor. To nám umožní prerušiť vykonávanie úlohy zavolaním výrazu yield. Oproti predošlému kódu nevoláme task() ako samotnú úlohu, ale najprv vytvoríme generátorový objekt a až ten voláme.

Druhá vec, ktorú potrebujeme je riadiaci cyklus, ktorý bude postupne vyvolávať úlohy a predávať im riadenie pomocou volania funkcie next(). Práve sme implementovali kooperatívny multitasking, v ktorom máme niekoľko úloh, ktoré sa samy prerušia, aby mohla ísť druhá úloha. Riadiaci cyklus predstavuje primitívny plánovač, ktorý zavolá ďalšiu úlohu.

Výstup:

Uvažujte: Z výstupu vidíme, že síce úloha 1 začala pracovať prvá, ale prvý súčet mala hotová úloha 2. Prečo je to tak?

Môže sa zdať, že sme vytvorili asynchrónny program, ale zatiaľ je všetko synchrónne – úlohy sa striedajú.

Kooperatívna konkurencia s blokujúcimi volaniami

V ďalšej ukážke pridáme do kódu blokujúce volanie – v našom prípade volanie time.sleep(). Blokujúce volanie spôsobí, že procesor prestane vykonávať kód nášho programu a bude čakať, kým nepríde odpoveď z tohto volania. Obvykle sa jedná o komunikáciu s vstupno-výstupnými zariadeniami alebo sieťovými soketmi. sleep() simuluje takéto volanie.

Ďalej sme do kódu pridali meranie času. Jedno je v úlohe, ktoré meria dĺžku práce jednej úlohy a druhé v hlavnej funkcii, ktoré meria celkovú dĺžku behu úloh. time.perf_counter() vráti hodnotu najpresnejšieho počítadla dostupného v systéme.

Výstup:

Poradie behu úloh je také isté ako v predchádzajúcom príklade. Tentoraz ale úlohy museli čakať na výsledok blokujúceho volania. Pomohla nám kooperatívna konkurencia? Vidíme, že súčet dĺžok behu jednotlivých úloh je rovnaký ako celková dĺžka behu úloh. Jednotlivé úlohy boli blokované a pred blokovaním neodovzdali riadenie, takže sme nič nezískali.

Kooperatívna konkurencia s neblokujúcimi volaniami

V poslednej časti kód transformujeme na plne asynchrónny kód. Využijeme k tomu modul asyncio, ktorý implementuje event loop a metódy na prácu s korutinami a príkazy async a await, ktoré označujú asynchróny kód.

Funkcia task() je označená príkazom async. Znamená to, že sa jedná o natívnu korutinu. Je možné ju prerušiť pri objektoch označených príkazom await. V našom príklade je to na dvoch miestach:

  • pri vytiahnutí objektu z fronty (všimnite si, že sme vymenili Queue za asyncio.Queue, ktorá je asynchrónne čakateľná (angl. awaitable))
  • počas spánku (podobne, implementácia bola vymenená za asynchrónnu)

Aj funkcia main je korutina. Je prerušiteľná na dvoch miestach:

  • Pri vkladaní objektu do fronty.
  • Pri samotnom spustení úloh (viď. nižšie).

Vyskúšajte: Čo sa stane, ak nepoužijeme príkaz await pri vkladaní objektu do fronty?

Zavolaním task("one", work_queue) vytvoríme objekt korutiny. Zatiaľ je to iba objekt v pamäti, ktorý nebeží, podobne ako generátorový objekt. Pripravené úlohy spustíme funkciou asyncio.gather(). Táto funkcia z nich najprv vytvorí objekty Task (tým ich naplánuje) a potom ich konkurentne spustí.

Výstup:

Z výsledku môžeme vidieť, že celkový čas behu je menší ako súčet behu všetkých úloh spolu. Je to vďaka tomu, že úlohy teraz naozaj bežali konkurentne.

Zatiaľ to boli všetko iba ukážkové príklady. Poďme sa pozrieť na kód, ktorý pracuje so skutočnými vstupno-výstupnými volaniami.

Synchrónne (blokujúce) HTTP volania

Vyskúšame pripojenie na niekoľko webových stránok. Adresy týchto stránok nahradili čísla vo fronte. Na pripojenie k stránke používame metódu zo štandardnej knižnice urllib.request.urlopen().

Úloha task je znovu generátor a spracovanie ďalšej stránky voláme pomocou next() v cykle. Výsledkom je sekvenčné spracovanie jednotlivých požiadaviek. Každá úloha je pri volaní metódy urllib.request.urlopen() zablokovaná a celkový čas behu programu je rovnaký ako súčet behu jednotlivých požiadaviek.

Výstup:

Asynchrónne (neblokujúce) HTTP volania

Na asynchrónne HTTP požiadavky použijeme externý modul aiohttp. Musíte si ho doinštalovať do vášho prostredia (napr. pomocou pip3 install aiohttp). Z úlohy task sa znova stane korutina vďaka príkazu async. Najprv v nej vytvoríme session, v rámci ktorého sa budeme pripájať. Všimnite si príkaz async
with
. Umožní prerušiť vykonávanie korutiny pri vstupe a výstupe z with bloku. Čakaním na objekt response.text() prečítame telo odpovede.

Zvyšok programu je rovnaký ako pri poslednej asynchrónnej ukážke. Vytvoríme dve úlohy a odmeriame čas ich vykonávania. Keďže teraz sa môžu korutiny striedať pri čakaní na odpoveď, celkový čas je menší ako pri synchrónnej ukážke – skoro o polovicu. Dosiahli sme to vďaka asynchrónnemu programovaniu v jednom vlákne.

Vyskúšajte vytvoriť jednu úlohu na jednu URL. Aký bude celkový čas vykonávania?

Výstup:

Domáca úloha

Prepíšte tento kód, aby boli požiadavky zasielané asynchrónne: