[PYTHON] Async / await avec Kivy et tkinter

2018 a été un choc pour moi. J'avais l'habitude de jouer avec Kivy kivy de GUI Framekwork, mais j'ai trouvé que l'utilisation d'un générateur rend le code moche plein de fonctions de rappel étonnamment facile à lire. Après avoir essayé diverses choses, j'ai fini par comprendre le traitement asynchrone par async / await, qui était une magie que je ne connaissais pas jusque-là, et j'ai pu créer une petite bibliothèque de traitement asynchrone. Cet article est nouveau

――Le processus jusqu'à ce que je réalise la merveille du générateur et de la coroutine native qui en est née

Je veux le préciser.

(Pour réduire la quantité de texte, le générateur est abrégé en gen et la coroutine est abrégée en coro.)

La puissance cachée de gen

Gen comme appareil qui produit de la valeur

Je pense que de nombreux livres d'introduction présentent la gen comme un outil de production de valeur. (Le rendement est décrit comme «donner» pour éviter toute confusion avec «retour» qui signifie «retour»)

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, b+a

for i in fibonacci():
    print(i, end=' ')
    import time; time.sleep(.1)
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181 ...

Étant donné le nom de «générateur», c'était peut-être cet objectif à l'origine. En fait, jusqu'en 2018, je n'ai vu que ce genre de chose, et je pense qu'il était difficile pour moi de sortir de ce concept car j'avais l'habitude de récupérer la valeur avec for-in comme ci-dessus.

Récupérer des valeurs sans utiliser for-in

Certains livres d'introduction incluent également un exemple d'utilisation de send () pour récupérer une valeur de gen.

gen = fibonacci()
print(gen.send(None))
print(gen.send(None))
print('Une petite pause')
import time;time.sleep(1)
print('Fin de pause')
print(gen.send(None))
print(gen.send(None))
0
1
Une petite pause
Fin de pause
1
2

Je pense que cela donne un aperçu du "pouvoir caché" de gen, mais je ne l'ai pas remarqué à ce stade non plus. Mais qu'en est-il du prochain exemple?

Rien n'est né gen

def sub_task():
    print('sub:Processus 1')
    yield
    print('sub:Processus 2')
    yield
    print('sub:Processus 3')

def main_task():
    gen = sub_task()
    try:
        print('main:Processus 1')
        gen.send(None)
        print('main:Processus 2')
        gen.send(None)
        print('main:Processus 3')
        gen.send(None)
    except StopIteration:
        pass

main_task()
main:Processus 1
sub:Processus 1
main:Processus 2
sub:Processus 2
main:Processus 3
sub:Processus 3

Vous pouvez voir que les deux tâches progressent petit à petit avec gen.send () et yield comme points de commutation. N'est-ce pas ce ** traitement parallèle ** lui-même!

multi_tasking.svg.png

Pouvoir caché

C'était le "pouvoir caché" de gen. gen est

--Il peut être mis en pause ... (S'arrête au rendement et commence à se déplacer avec gen.send ())

C'était comme une fonction, et en raison de sa fonctionnalité stoptable, ** le traitement parallèle pouvait être effectué sans compter sur le multi threading **.

Les attentes pour la génération

Cela m'a donné de l'espoir quand je souffrais d'un code laid plein de fonctions de rappel. Parce que, par exemple, lorsque vous souhaitez faire ce qui suit avec Kivy

def some_task():
    print('Processus 1')
Attendez que le bouton soit enfoncé
    print('Processus 2')
Attendez 1 seconde
    print('Processus 3')

Le code réel est

from kivy.clock import Clock

def some_task(button):
    print('Processus 1')
    def callback2(button):
        button.unbind(on_press=callback2)
        print('Processus 2')
        Clock.schedule_once(callback3, 1)
    def callback3(__):
        print('Processus 3')
    button.bind(on_press=callback2)

Cela devient laid qui ne se voit pas. Vous devez attendre quelque chose =>Vous devez arrêter le traitement jusqu'à ce que quelque chose se passe=> Le traitement suivant doit être séparé en une autre fonction. Mais je veux que vous vous souveniez du sub_task () qui est sorti plus tôt.

def sub_task():
    print('sub:Processus 1')
    yield
    print('sub:Processus 2')
    yield
    print('sub:Processus 3')

De même, la fonction de rappel n'apparaît nulle part même si elle s'est arrêtée au milieu. J'ai donc commencé à me demander si gen pouvait être utilisé pour éliminer la fonction de rappel dans Kivy.

Éliminer la fonction de rappel (édition Kivy)

Je vais réfléchir à la méthode à partir de maintenant, mais je voudrais d'abord mentionner que la branche maître actuelle de Kivy peut déjà effectuer une programmation asynchrone à grande échelle en utilisant [asyncio] asyncio_doc et [trio] trio_doc. Donc, ce que nous faisons ici, c'est [réinvention des roues] [roue]. Cependant, à ce moment-là, ce n'était pas le cas, et j'étais simplement intéressé par la génération, alors j'ai choisi de faire quelque chose par moi-même.

Arrêtez-vous pendant un certain temps

J'ai décidé de mettre de côté le bouton une fois et de réaliser une fonction pour arrêter gen pendant ce laps de temps lorsqu'une valeur numérique est envoyée depuis gen. C'est parce que j'ai vu [la vidéo de BeeWare] video_beeware et j'ai pensé que c'était cool de faire une telle chose.

def some_task():
    print('Processus 1')
    yield 2  #Attendez 2 secondes
    print('Processus 2')
    yield 1  #Attendez 1 seconde
    print('Processus 3')

Considérez comment faire fonctionner la génération ci-dessus comme prévu. Avec les connaissances jusqu'à présent

Je le sais. Ensuite, "Pourquoi ne passons-nous pas la fonction de redémarrage de gen à Clock.schedule_once ()? "

from kivy.clock import Clock
from kivy.app import App
from kivy.uix.widget import Widget


def start_gen(gen):
    def step_gen(dt):
        try:
            Clock.schedule_once(step_gen, gen.send(None))  # C
        except StopIteration:
            pass
    step_gen(None)  # B


def some_task():
    print('Processus 1')
    yield 1  # D
    print('Processus 2')
    yield 2  # E
    print('Processus 3')


class SampleApp(App):
    def build(self):
        return Widget()
    def on_start(self):
        start_gen(some_task())  # A

if __name__ == '__main__':
    SampleApp().run()

C'était la bonne réponse. Ce code fonctionne comme suit.

  1. Un gen est créé au démarrage de l'application et il est passé à start_gen () (ligne A)
  2. start_gen () appelle immédiatementstep_gen ()(ligne B)
  3. step_gen () appellegen.send (), donc gen commence à fonctionner (ligne C)
  4. gen s'arrête à la première expression de rendement et envoie 1 (ligne D)
  5. Puisque le résultat de l'évaluation de gen.send (None) est 1, step_gen () se réserve d'être appelé à nouveau après 1 seconde (ligne C).
  6. Puisqu'il n'y a plus rien à faire, le processus retourne à la boucle d'événements de kivy.
  7. Une seconde plus tard, step_gen () est appelé et gen.send () est appelé, ainsi gen commence à se déplacer à partir de la position où il s'est arrêté la dernière fois. (Ligne C)
  8. gen s'arrête au point où il atteint la deuxième expression de rendement et envoie 2 (ligne E)
  9. (Omis ci-dessous)

C'était choquant de pouvoir attendre un moment sans utiliser la fonction de rappel en préparant une fonction avec seulement 7 lignes (start_gen ()). Motivé, je continuerai à améliorer cela.

Utilisez la valeur transmise à la fonction de rappel

Le temps réel écoulé est passé à la fonction de rappel passée à Clock.schedule_once (). Comme c'est un gros problème, je l'ai fait pour que le côté some_task () puisse le recevoir. Tout ce que vous avez à faire est de changer la partie gen.send (None) de start_gen () en gen.send (dt). Maintenant, le côté some_task () peut obtenir le temps réel écoulé comme suit (code entier).

def some_task():
    print('Processus 1')
    s = yield 1
    print(f"Quand j'ai demandé un arrêt pendant 1 seconde, il{s:.03f}Arrêté une seconde")
    print('Processus 2')
    s = yield 2
    print(f"Quand j'ai demandé un arrêt pendant 2 secondes, il{s:.03f}Arrêté une seconde")
    print('Processus 3')
Processus 1
Quand j'ai demandé un arrêt pendant 1 seconde, c'était en fait 1.089 secondes arrêtées
Processus 2
Quand j'ai demandé un arrêt pendant 2 secondes, c'était en fait 2.Arrêté pendant 003 secondes
Processus 3

En attente d'événement

Ensuite, attend l'événement, idéalement si le côté gen écrit comme suit.

def some_task(button):
    print('Processus 1')
    yield event(button, 'on_press')  #Attendez que le bouton soit enfoncé
    print('Processus 2')

Dans le cas d'un événement, c'est un peu compliqué car cela nécessite un travail de solution </ rb> </ rt> </ ruby> pour connecter la fonction de rappel, mais la procédure est la même. Il a été réalisé en passant une fonction qui redémarre gen comme fonction de rappel pour l'événement.

def start_gen(gen):
    def step_gen(*args, **kwargs):
        try:
            gen.send((args, kwargs, ))(step_gen)
        except StopIteration:
            pass
    try:
        gen.send(None)(step_gen)
    except StopIteration:
        pass

def event(ed, name):
    bind_id = None
    step_gen = None

    def bind(step_gen_):
        nonlocal bind_id, step_gen
        bind_id = ed.fbind(name, callback)  #Associer la fonction de rappel
        assert bind_id > 0  #Vérifier si la liaison a réussi
        step_gen = step_gen_

    def callback(*args, **kwargs):
        ed.unbind_uid(name, bind_id)  #Résolvez la fonction de rappel
        step_gen(*args, **kwargs)  #Reprendre gen

    return bind

Code global

La grande différence avec l'arrêt temporel est que tout le traitement lié à l'événement peut être caché dans ʻevent () . Grâce à cela, start_gen () ne dépend pas du tout de kivy, et c'est aussi simple que de passer step_gen` à l'appelable envoyé depuis gen.

Généralisation

Je pense que la conception ci-dessus est très bonne, donc j'ai supprimé le traitement lié à kivy de start_gen () et je l'ai caché dans une autre fonction, après l'événement, attendez l'arrêt du temps.

def sleep(duration):
    return lambda step_gen: Clock.schedule_once(step_gen, duration)

Vous pouvez maintenant mélanger sleep () et ʻevent () `.

def some_task(button):
    yield event(button, 'on_press')  #Attendez que le bouton soit enfoncé
    button.text = 'Pressed'
    yield sleep(1)  #Attendez 1 seconde
    button.text = 'Bye'

Code global

La façon dont gen reprend dépend entièrement de l'appelable envoyé par gen, donc si vous envoyez quelque chose comme ce qui suit, par exemple

def sleep_forever():
    return lambda step_gen: None  

def some_task():
    yield sleep_forever()  #Attends pour toujours

Il est également possible de ne pas redémarrer.

Attendre le fil

Afin de confirmer sa polyvalence, j'ai également traité de choses qui n'ont rien à voir avec Kivy, le fil.

def thread(func, *args, **kwargs):
    from threading import Thread
    return_value = None
    is_finished = False
    def wrapper(*args, **kwargs):
        nonlocal return_value, is_finished
        return_value = func(*args, **kwargs)
        is_finished = True
    Thread(target=wrapper, args=args, kwargs=kwargs).start()
    while not is_finished:
        yield sleep(3)
    return return_value

C'est devenu une façon terne de regarder autour de vous pour voir si cela se termine régulièrement, mais maintenant le côté gen peut attendre la fin en exécutant la fonction passée sur un autre thread.

class SampleApp(App):
    def on_start(self):
        start_gen(self.some_task())
    def some_task(self):
        def heavy_task():
            import time
            for i in range(5):
                time.sleep(1)
                print(i)
        button = self.root
        button.text = 'start heavy task'
        yield event(button, 'on_press')  #Attendez que le bouton soit enfoncé
        button.text = 'running...'
        yield from thread(heavy_task)  #Lourd sur un autre fil_task()Et attendez sa fin
        button.text = 'done'

Code global

De quoi provient le rendement ou le rendement?

Cela semble bien se passer ici, mais certains problèmes sont apparus. La première est que vous devez utiliser correctement le rendement et le rendement en fonction de ce que vous attendez. (sleep () et ʻevent () sont yield, thread () est yield from). De plus, cela dépend de l'implémentation, et si threading.Thread avait un mécanisme pour notifier la fin du thread avec la fonction de rappel, thread () `pourrait également être implémenté afin qu'il puisse attendre avec yield. Ce n'est pas bon que l'utilisation soit différente comme ça, j'ai donc décidé de l'unifier à l'un ou l'autre.

Je pense que la seule option est «rendement de». Parce qu'il est facile de faire ce que vous pouvez attendre pour le rendement de l'attente du rendement, mais pas toujours l'inverse. Par exemple

def some_gen():
    yield 1

«1» est

def some_gen():
    yield from one()

def one():
    yield 1

En faisant cela, vous pouvez attendre à yield from

def some_gen():
    yield from another_gen()

def another_gen():
    yield 1
    yield 4

ʻAnother_gen () ne peut probablement pas attendre avec yield another_gen () `.

Unifié pour céder

J'ai donc réécrit sleep () et ʻevent () `pour attendre le rendement.

def sleep(duration):
    return (yield lambda step_coro: Clock.schedule_once(step_gen, duration))

def event(ed, name):
    #Abréviation
    return (yield bind)

Avec cela, l'utilisateur n'a pas à utiliser correctement le rendement et le rendement.

#Toujours céder de
def some_task():
    yield from sleep(2)
    yield from event(button, 'on_press')
    yield from thread(heavy_task)

Code global

Problèmes d'utilisation des arguments passés à la fonction de rappel

Un autre problème est que le côté gen utilisait à l'origine les arguments passés à la fonction de rappel comme suit.

def some_task():
    s = yield 1
    print(f"Quand j'ai demandé un arrêt pendant 1 seconde, il{s:.03f}Arrêté une seconde")

Ce qui se passe maintenant est en fait

def some_task():
    args, kwargs = yield from sleep(1)
    s = args[0]
    print(f"Quand j'ai demandé un arrêt pendant 1 seconde, il{s:.03f}Arrêté une seconde")

Il est difficile d'obtenir la valeur requise. C'est parce que l'argument formel est def step_gen (* args, ** kwargs): pour que step_gen () puisse recevoir n'importe quel argument. Heureusement, cependant, grâce à l'unification à partir de laquelle céder, un tel traitement peut être fait du côté sleep ().

def sleep(duration):
    args, kwargs = yield lambda step_coro: Clock.schedule_once(step_coro, duration)
    return args[0]

Avec cela, le côté utilisateur

def some_task():
    s = yield from sleep(1)
    print(f"Quand j'ai demandé un arrêt pendant 1 seconde, il{s:.03f}Arrêté une seconde")

Cela suffisait.

Introduction de la syntaxe async / await

Ensuite, j'ai décidé de convertir ce que j'ai fait jusqu'à présent afin qu'il puisse être géré avec la syntaxe async / await introduite dans Python 3.5. C'est parce qu'il a été écrit dans divers documents qu'il s'agit d'un remplacement de gen comme coro. Pour moi, j'ai essayé de faire «attendre» avec un mot plus court et plus facile à lire que «céder de» avec deux mots, mais il semble que cela présente en fait plus d'avantages, alors veuillez vous référer aux détails. Voir [Officiel] pep492.

Procédure de conversion

Premièrement, les fonctions gen qui incluent des rendements sans from, comme sleep () et ʻevent () , ont reçu @ types.coroutine. C'est parce que je ne savais pas comment réécrire la fonction gen contenant l'instruction return comme celle-ci en tant que fonction async. (Si la fonction async a une expression yield et renvoie une valeur dans l'instruction return, une erreur de syntaxe se produira: SyntaxError: 'return' avec valeur dans le générateur async`)

import types

@types.coroutine
def sleep(duration):
    #Abréviation

@types.coroutine
def event(ed, name):
    #Abréviation

D'un autre côté, thread () et some_task () pourraient être réécrits en tant que fonctions asynchrones pures. En particulier

Remplacé.

async def thread(func, *args, **kwargs):
    #Abréviation
    while not is_finished:
        await sleep(3)
    #Abréviation

class SampleApp(App):
    async def some_task(self):
        #Abréviation
        await event(button, 'on_press')
        button.text = 'running...'
        await thread(heavy_task)
        button.text = 'done'

Enfin, remplacez la chaîne de caractères «gen» incluse dans l'identificateur par «coro» pour terminer.

Code global

C'est la fin de l'édition Kivy. Comme j'ai oublié de le dire, coro peut être exécuté en même temps autant de fois que start_coro (another_task ()). ʻAttends another_task () si vous voulez attendre la fin, start_coro (another_task ()) `si vous voulez exécuter en parallèle sans attendre.

Éliminer la fonction de rappel (édition tkinter)

Ensuite, j'ai essayé la même chose avec tkinter, mais la procédure était exactement la même que kivy (en passant la fonction de redémarrage de gen / coro comme fonction de rappel), donc ça s'est bien passé.

Attendez le temps (dormir)

La différence avec Kivy est que Kivy utilise le seul objet kivy.clock.Clock, tandis que tkinter utilise la méthode .after () de chaque widget. Vous devez donc spécifier quel widget .after () appeler en plus de l'heure à laquelle vous souhaitez vous arrêter.

@types.coroutine
def sleep(widget, duration):
    yield lambda step_coro: widget.after(duration, step_coro)

Cela signifie que vous devez passer le widget à thread (), qui utilise sleep () en interne.

async def thread(func, *, watcher):
    #Abréviation
    while not is_finished:
        await sleep(watcher, 3000)  #3000ms d'arrêt
    return return_value

En attente d'événement

Vient ensuite l'événement. Avant l'implémentation, ʻunbind () `de tkinter semble avoir un [bogue] tkinter_issue, donc je l'ai modifié comme suit, en me basant sur les informations liées.

def _new_unbind(self, sequence, funcid=None):
    if not funcid:
        self.tk.call('bind', self._w, sequence, '')
        return
    func_callbacks = self.tk.call('bind', self._w, sequence, None).split('\n')
    new_callbacks = [l for l in func_callbacks if l[6:6 + len(funcid)] != funcid]
    self.tk.call('bind', self._w, sequence, '\n'.join(new_callbacks))
    self.deletecommand(funcid)

def patch_unbind():
    from tkinter import Misc
    Misc.unbind = _new_unbind

Il est remplacé par le ʻunbind () modifié en appelant patch_unbind () . Le problème est de savoir quand l'appeler, mais je pense qu'il vaut mieux ne pas le faire sans permission car il modifie tkinter lui-même. J'ai donc décidé que l'utilisateur m'appelle explicitement. Et l'implémentation de ʻevent ()

@types.coroutine
def event(widget, name):
    bind_id = None
    step_coro = None

    def bind(step_coro_):
        nonlocal bind_id, step_coro
        bind_id = widget.bind(name, callback, '+')
        step_coro = step_coro_

    def callback(*args, **kwargs):
        widget.unbind(name, bind_id)
        step_coro(*args, **kwargs)

    return (yield bind)[0][0]

C'est devenu.

Comment utiliser

#Installez asynctkinter à l'avance
# pip install git+https://github.com/gottadiveintopython/asynctkinter#egg=asynctkinter

from tkinter import Tk, Label
import asynctkinter as at
at.patch_unbind()  # unbind()Réparer le bug

def heavy_task():
    import time
    for i in range(5):
        time.sleep(1)
        print('heavy task:', i)

root = Tk()
label = Label(root, text='Hello', font=('', 60))
label.pack()

async def some_task(label):
    label['text'] = 'start heavy task'
    event = await at.event(label, '<Button>')  #Attendez que l'étiquette soit appuyée
    print(event.x, event.y)
    label['text'] = 'running...'
    await at.thread(heavy_task, watcher=label)  #Lourd sur un autre fil_task()Et attendez sa fin
    label['text'] = 'done'
    await at.sleep(label, 2000)  #Attendez 2 secondes
    label['text'] = 'close the window'

at.start(some_task(label))
root.mainloop()

en conclusion

L'idée d'utiliser gen pour le traitement parallèle semble avoir [déjà existé] video_curious_course il y a environ 10 ans, et beaucoup de gens l'ont peut-être su, mais pour moi, c'était une nouvelle connaissance il y a un an ou deux et c'était un choc. J'ai donc créé cet article. Probablement, si le côté bibliothèque implémente la boucle d'événements comme tkinter, utilisez cette méthode, et si le côté utilisateur confie l'implémentation de la boucle d'événements comme pygame, définissez la boucle d'événements sur ʻasyncioou Si vous l'implémentez comme une tâche surtrio`, vous pouvez fondamentalement introduire async / await à n'importe quoi. Adieu la fonction de rappel laide.

collection de liens

Recommended Posts

Async / await avec Kivy et tkinter
Programmation avec Python et Tkinter
[Python] Requête asynchrone avec async / await
MVC avec Tkinter
Créez une application graphique native avec Py2app et Tkinter
Devenez Père Noël avec Tkinter
Afficher et prendre des images de caméra Web avec Python Kivy [GUI]
Créons une application Mac avec Tkinter et py2app
Traitement asynchrone de Python ~ Comprenez parfaitement async et attendez ~
Avec et sans WSGI
Programmation GUI avec kivy ~ Partie 3 Vidéo et barre de recherche ~
J'ai essayé de créer une interface graphique à trois yeux côte à côte avec Python et Tkinter
Avec moi, cp et sous-processus
Scraping à l'aide de Python 3.5 async / await
Chiffrement et déchiffrement avec Python
Introduction à Tkinter 2: Button
Python et matériel - Utilisation de RS232C avec Python -
Créez des boutons de type SF avec Kivy
Changement d'écran / transition d'écran avec Tkinter
Créer une visionneuse d'images avec Tkinter
Exécuter Label avec tkinter [Python]
Super résolution avec SRGAN et ESRGAN
Group_by avec sqlalchemy et sum
python avec pyenv et venv
J'ai mesuré l'IMC avec tkinter
Avec moi, NER et Flair
Fonctionne avec Python et R
Créer des plug-ins asynchrones avec neovim
Implémentation du serveur de socket avec détection de déconnexion par gevent ou async / await