Kategória: Python

Zmenené: 24. máj 2017

Pygame a asyncio

Pretože potrebujem skombinovať prácu so sériovým portom a grafické rozhranie pomocou pygame, rozhodol som sa zrealizovať to pomocou modulu asyncio. Asynchrónne programovanie je pre mňa novinkou, a pygame nie je pripravené na asynchrónne spúšťanie, no podarilo sa.

Tento článok v žiadnom prípade nie je návodom na programovanie v pygame a ani sa hlbšie nezaoberá podstatou asynchrónneho programovania, ani jej implementáciou v Pythone. Je to len a len prostá ukážka, ako spojiť asynchrónny program s knižnicou, ktorá asynchrónna nie je.

Pre tých čo nevedia

pygame je knižnica Pythonu, ktorá poskytuje prostredie na tvorbu grafických interaktívnych hier v Pythone. Po viacerých neúspešných pokusoch s Gtk, Qt i wxWidgets (v každom som sa nakoniec vždy stratil) som sa rozhodol jednoduché grafické rozrhanie robiť v pygame a zistil som, že je to perfektná (pre mňa) voľba – a to najmä s ohľadom na použitie ako prostého riadiaceho panela (dotykové mini LCD).

asyncio je súčasť jazyka Python (od verzie 3.4), ktorá poskytuje možnosť spúšťať viacero úloh „naraz” – konkurenčne. Je to výborné riešenie, hoci Python už dávno poskytuje prostredie na programovanie viacvláknových aplikácií pomocou modulu threading (výrazne obmedzené možnosti, pretože program Python beží vždy v jednom vlákne) alebo viac-procesových aplikácií pomocou modulu multiprocessing (náročné na zdroje a problémy s komunikáciou, pretože procesy majú oddelený pamäťový priestor). Modul asyncio, poskytuje konkurenčné spúšťanie úloh v jednom vlákne, a teda neprináša reálne využitie výkonu súčasných viacjadrových procesorov, a na dosiahnutie „paralelnosti” vyžaduje špeciálny návrh aplikácie, ale pri práci s pygame a V/V (v tomto prípade sériovým portom) poskytuje reálne fungujúci paralelizmus.

Čisté pygame

Najprv základný príklad programu v pygame. Nebudem popisovať podrobnosti, pretože tento článok nie je o programovaní v pygame), ale príklad poslúži ako štartovací bod na porovnávanie výsledkov.

Program v pygame vyžaduje vytvoriť si vlastnú slučku, ktorá opakovane prekresľuje displej aplikácie a reaguje na rôzne udalosti. V tomto príklade ukážem len kostru, v ktorej nebudem nič vykresľovať, len nechám slučku zopakovať pri snímkovej rýchlosti 10 snímkov/s (10 fps). Na dosiahnutie požadovanej snímkovej rýchlosti poskytuje pygame objekt Clock() a jeho metódu tick(), ktorá pozastaví vykonávanie programu na takú dobu, aby slučka neopakovala činnosť častejšie ako zadaná snímková rýchlosť.

Samotná slučka nerobí nič, ale simuluje nejakú činnosť čakaním (time.sleep()):

import time
import pygame

pygame.init()

# take 1/10 sec for one frame
FPS = 10
# do >= 10 frames
max_count = 15

clock = pygame.time.Clock()

def main():
    count = 0
    while count < max_count:
        frame_start = time.time()

        # simulate execute something blocking
        time.sleep(0.03)

        # slowdown to FPS
        clock.tick(FPS)

        frame_end = time.time()
        # frame time in ms
        frame_time = round(frame_end - frame_start, 3) * 1000

        # print timming result
        print ("%2d: %10d %10d %10d" % (count + 1,
                                        frame_time,
                                        clock.get_time(),
                                        clock.get_rawtime()
              ))

        # increase counter
        count += 1

main()

# get real FPS (requires at least 10 frames
print ("\nReal FPS: %.2f" % clock.get_fps())

Program, po inicializácii pygame a deklarácií niekoľkých premenných, vykoná slučku 15 krát (max_count), v každom cykle slučky vykoná nejakú činnosť (time.sleep()), počká zvyšnú dobu potrebnú na dosiahnutie žiadanej snímkovej rýchlosti, aby potom vypočítal a zobrazil aké trvanie slučky nameral pomocou modulu time, nasledované rovnakým údajom z objektu Clock() (oba údaje i s čakaním na snímkovú frekvenciu) a posledný údaj ukazuje koľko trvalo vykonanie tela slučky (teda bez čakania na FPS). Na konci vypíše, aká bola dosiahnutá reálna snímková frekvencia:

python3 "pyg_serial.py"
 1:         99        100         31
 2:        100        100         30
 3:        100        101         30
 4:         99        100         31
 5:        100        100         30
 6:         99        100         31
 7:        100        100         30
 8:        100        100         30
 9:         99        100         31
10:        101        100         29
11:        100        101         30
12:        100        100         30
13:        100        101         30
14:         99        100         31
15:        100        100         30

Real FPS: 10.0

Výsledok ukazuje, že slučka trvá vždy približne 100 ms (rozdiely dávam na vrub zaokrúhľovaniu a zdokumentovanej nepresnosti metódy tick()), teda tak ako som zadal, pričom vidno, že telo cyklu trvá okolo 30 ms (tiež ako je zadané), takže v každom cykle program 30 ms „pracuje” a 70 ms čaká...

Poznámka

V mojom reálnom programe (ktorý vykresľuje grafické prvky ovládacieho panela a reaguje na pohyby myši a pod.) používam 30 snímkov/s a z meraní mi vyplýva, že program „pracuje” 1 – 2 ms a zvyšok do 33 ms čaká.

Metóda tick() je navrhnutá tak, že ak telo cyklu trvá dlhšie ako je požadované na dosiahnutie FPS, nečaká a okamžite skončí, pretože na dosianutie zadanej snímkovej frekvencie nie je potrebné žiadne spomalenie. Ak predĺžim čakanie na 200 ms (time.sleep(0.2)) dosiahnem len 5 snímkov/s:

python3 "pyg_serial.py"
 1:        200        200        200
 2:        200        201        201
 3:        200        201        201
 4:        200        201        200
 5:        200        199        199
 6:        200        201        200
 7:        200        200        200
 8:        200        201        201
 9:        200        201        201
10:        200        199        199
11:        200        201        201
12:        200        200        200
13:        200        202        202
14:        200        200        200
15:        200        200        200

Real FPS: 5.0

Teraz vidno, že posledné dva stĺpce sú (približne) rovnaké, čiže metóda tick() program nespomaľuje. Je to výhodná implementácia, čo ma viedlo k úvahe, ako toto celé prerobiť do asynchrónneho tvaru, bez reimplementácie metódy tick() a pritom využiť čas čakania v tick() na inú prácu.

Asynchrónne základy

Naozaj krátky a prostý úvody do asynchrónneho programu v Pythone, keďže tak ako pri pygame, ani k asyncio nechcem písať návod na jeho použitie.

Základom fungovania asynchrónnej aplikácie je tzv. slučka udalostí (event_loop) a tzv. korutiny (coroutine). Slučka udalostí dokáže spúšťať zadané časti programu a pozastaviť ich vykonávanie, aby počas tohoto pozastavenia mohla spustiť inú časť programu. Lenže, aby dokázala spustenú časť programu pozastaviť, musí byť spustená funkcia korutinou, ktorá toto dokáže urobiť napr. pomocou kľúčového slova await. Korutinu z bežnej funkcie urobíte prostým nahradením kľúčového slova def za async def.

Slučka udalostí dokáže spúšťať aj bežné funkcie, ktoré však nedokáže pozastaviť, takže spúšťanie iných častí programu je blokované, kým táto funkcia neskončí, čo ukážem hneď nasledujúcom príklade.

Asynchrónne blokovanie

Asynchrónny program nemá rád čakanie, ktoré neumožní vykonávanie iného kódu (tzv. blokujúce), a to je presne to, čo robí metóda tick(), na demonštráciu toho som urobil do prvého programu niekoľko malých zmien:

  • previedol som funkciu main() na asynchrónnu korutinu (pomocou async def)
  • pridal som funkciu ticker(), ktorá má v každom cykle slučky vypísať jednoduchý text
  • pridal som inicializáciu a spustenie asynchrónnej slučky udalostí (event_loop)
import time
import asyncio

import pygame

pygame.init()

# take 1/10 sec for one frame
FPS = 10
# do >= 10 frames
max_count = 15

clock = pygame.time.Clock()

def ticker():
    print ("tick", end=" ")

# coroutine
async def main(loop):
    count = 0
    while count < max_count:
        frame_start = time.time()

        # run
        loop.call_soon(ticker)

        # simulate execute something blocking
        time.sleep(0.03)

        # slowdown to FPS
        clock.tick(FPS)

        frame_end = time.time()
        # frame time in ms
        frame_time = round(frame_end - frame_start, 3) * 1000

        # print frame result
        print ("%2d: %10d %10d %10d" % (count + 1,
                                        frame_time,
                                        clock.get_time(),
                                        clock.get_rawtime()
              ))

        # increase counter
        count += 1

# get and set event loop
loop = asyncio.get_event_loop()
loop.run_until_complete(main(loop))

loop.close()

# get real FPS (requires at least 10 frames
print ("\nReal FPS: %.1f" % clock.get_fps())

Spustenie tohoto programu ukáže, že premena bežnej funkcie na korutinu ju síce umožní spustiť v slučke udalostí, ale blokujúca metóda tick() bráni spusteniu iného kódu (ticker() naplánovaný pomocou call_soon()), takže reťazec tick je vypísaný 15 x naraz na konci, pričom som chcel aby bol vypísaný v každom cykle slučky:

python3 "pyg_async0.py"
 1:         99        100         31
 2:        100        100         30
 3:        100        100         30
 4:         99        100         31
 5:        100        101         30
 6:        100        101         30
 7:        100        100         30
 8:        100        100         30
 9:         99        100         31
10:        101        100         29
11:        100        101         30
12:        100        100         30
13:        100        100         30
14:         99        100         31
15:        100        101         30
tick tick tick tick tick tick tick tick tick tick tick tick tick tick tick
Real FPS: 10.0

Asynchrónne čakanie

Aby mohla byť korutina pozastavená a umožnila tak spustenie iného kódu, musí niekde použiť kľúčové slovo await, lenže toto await nemožno spustiť s bežnou (blokujúcou) funkciou. Najprv som vytvoril vlastnú (asnychrónnu) implementáciu obdoby metódy tick(), ale potom som to zavrhol – veď načo vymýšľať vymyslené. Našťastie asyncio poskytuje spôsob ako asynchrónne čakať na blokujúcu funkciu tak, že je táto funkcia spustená v inom vlákne alebo i v inom procese.

Poznámka

V prípade metódy tick() sa mi nepodarilo spustiť ju v samostatnom procese, pretože Python nedokázal serializovať objekt Clock() pomocou pickle, no hlbšie som sa tým nezaoberal.

Aby teda mohla byť korutina main() pozastavená, treba nahradiť priame volanie clock.tick() jej volaním vo vlákne, o čo sa postará konštrukcia:

future = loop.run_in_executor(None, clock.tick, FPS)
await asyncio.wait_for(future, None)

Prvý riadok sa postará o spustenie zadanej funkcie v samostatnom vlákne (prvý parameter None udáva použiť predvolený spúšťač, tj. concurrent.futures.ThreadPoolExecutor()) a vytvorí objekt, ktorého výsledok bude známy až v budúcnosti (future). V ďalšom riadku potom asynchrónne čaká na výsledok tohoto future (None tentokrát hovorí, aby čakal bez časového obmedzenia).

Program teraz vyzerá takto:

import time
import asyncio

import pygame

pygame.init()

# take 1/10 sec for one frame
FPS = 10
# do >= 10 frames
max_count = 15

clock = pygame.time.Clock()

def ticker():
    print ("tick", end = " ")


async def main(loop):
    count = 0
    while count < max_count:
        frame_start = time.time()

        loop.call_soon(ticker)

        # simulate execute something blocking
        time.sleep(0.03)

        # slowdown to FPS
        future = loop.run_in_executor(None, clock.tick, FPS)
        await asyncio.wait_for(future, None)

        frame_end = time.time()
        # frame time in ms
        frame_time = round(frame_end - frame_start, 3) * 1000

        # print frame result
        print ("%2d: %10d %10d %10d" % (count + 1,
                                        frame_time,
                                        clock.get_time(),
                                        clock.get_rawtime()
              ))

        # increase counter
        count += 1

# get and set event loop
loop = asyncio.get_event_loop()

loop.run_until_complete(main(loop))

loop.close()

# get real FPS (requires at least 10 frames
print ("\nReal FPS: %.1f" % clock.get_fps())

A po jeho spustení vidno, že reťazec tick je vypísaný pred časovou informáciou, tzn. funkcia ticker() je spustená pri každom vykonaní slučky – a to počas čakania na výsledok metódy ticker():

python3 "pyg_async.py"
tick  1:        100        100         32
tick  2:         99        100         32
tick  3:        100        101         31
tick  4:        100        100         31
tick  5:        100        100         31
tick  6:        100        101         31
tick  7:        100        100         31
tick  8:        100        100         31
tick  9:        100        100         31
tick 10:        100        100         31
tick 11:        100        100         31
tick 12:         99        100         32
tick 13:        100        100         31
tick 14:         99        100         32
tick 15:        100        100         31

Real FPS: 10.0

Záver

Musím uznať, že asynchrónne programovanie v Pythone je pohodlné. Problém je, že je to relatívna novinka, a tak existuje len málo návodov a postupov. Dokumentácia modulu je síce rozsiahla a poskytuje aj viacero príkladov, ale dlho som sa v tých korutinách, budúcich výsledkoch, úlohách a slučke udalostí strácal. Prehľadnosti neprispieva ani to, že raz je volaná funkcia zadávaná ako funkcia (teda so zátvorkami a prípadnými argumentami v nich) a inokedy je volaná funkcia zadávaná ako premenná (teda bez zátvoriek a prípadných argumentov).

No napriek počiatočným problémom vidím v tomto spôsobe programovania potenciál, a to najmä v prípadoch ako je tento môj príklad s pygame, kde aplikácia viac čaká ako niečo skutočne robí, a tak možno čakanie využiť na vykonanie inej činnosti. Pevne verím, že sa v module asyncio budem strácať čím ďalej tým menej a nakoniec pochopím aj systém, ktorý je v syntaxe volania jednotlivých funkcií, či korutín.

Ak vám pri čítaní napadlo, že je to celé vlastne zbytočné, pretože to celé (vrátane sériovej komunikácie) môžem naprogramovať synchrónne, máte vlastne pravdu. Avšak, zistil som, že použitie asynchrónneho modulu sériovej komunikácie je oveľa jednoduchšie (a prehľadnejšie) ako použitie jeho synchrónneho rodiča, a tak som ho chcel spojiť s pygame.