Un monde sans coroutines. Itérateurs de générateur

1. Introduction



Pour brouiller le plus possible le problème, confiez la solution aux programmeurs;). Mais sérieusement, à mon avis, quelque chose de similaire arrive aux coroutines, car, volontairement ou non, elles sont utilisées pour brouiller la situation. Cette dernière se caractérise par le fait qu'il existe encore des problèmes de programmation parallèle qui ne vont nulle part et, surtout, les coroutines ne contribuent pas à leur solution cardinale.



Commençons par la terminologie. "Combien de fois l'ont-ils dit au monde", mais jusqu'ici "le monde" se pose encore des questions sur la différence entre la programmation asynchrone et la programmation parallèle (voir la discussion sur le thème de l'asynchronie dans [1] ). Le nœud du problème de la compréhension de l'asynchronie par rapport au parallélisme commence par la définition du parallélisme lui-même. Cela n'existe tout simplement pas. Il existe une sorte de compréhension intuitive, qui est souvent interprétée de différentes manières, mais il n'y a pas de définition scientifique qui supprimerait toutes les questions de manière aussi constructive qu'une discussion sur le résultat de l'opération «deux et deux».



Et comme, encore une fois, tout cela n'est pas là, alors, confus en termes et en concepts, on distingue toujours la programmation parallèle et concurrente, asynchrone, réactive et une autre, etc. etc. Je pense qu'il sera peu probable qu'il y ait un problème à se rendre compte qu'une calculatrice mécanique comme Felix fonctionne différemment d'une calculatrice logicielle. Mais d'un point de vue formel, i.e. un ensemble d'opérations et le résultat final, il n'y a pas de différence entre eux. Ce principe doit être pris en compte dans la définition de la programmation parallèle.



Nous devons avoir une définition stricte et des moyens transparents pour décrire le parallélisme, conduisant à des résultats cohérents comme Felix "maladroit" et n'importe quel calculateur logiciel. Il est impossible que la notion de «parallélisme» soit associée aux moyens de sa mise en œuvre (au nombre des mêmes cœurs). Et qu'y a-t-il «sous le capot» - cela ne devrait intéresser principalement que ceux qui sont engagés dans la mise en œuvre de «machines», mais pas ceux qui utilisent un tel «calculateur parallèle» conditionnel.



Mais nous avons ce que nous avons. Et nous avons, sinon un engouement, alors une discussion active sur les coroutines et la programmation asynchrone. Et que faire d'autre si nous semblons en avoir assez du multithreading, mais que quelque chose d'autre n'est pas proposé? Ils parlent même d'une sorte de magie;) Mais tout devient évident si vous en comprenez les raisons. Et ils se trouvent exactement là - dans le plan du parallélisme. Sa définition et sa mise en œuvre.



Mais descendons des hauteurs globales et, dans une certaine mesure, philosophiques de la science de la programmation (depuis l'informatique) à notre «terre pécheresse». Ici, sans nuire aux mérites du langage Kotlin actuellement populaire, je voudrais avouer ma passion pour le langage Python. Peut-être qu'un jour et dans une autre situation, mes préférences changeront, mais en fait, jusqu'à présent, tout est ainsi.



Il y a plusieurs raisons à cela. Parmi eux, un accès gratuit à Python. Ce n'est pas l'argument le plus fort, car un exemple avec le même Qt dit que la situation peut changer à tout moment. Mais si Python, contrairement à Kotlin, est gratuit, au moins sous la forme du même environnement PyCharm de JetBrains (pour lequel un merci spécial à eux), alors mes sympathies sont de son côté. Il est également attrayant qu'il existe une masse de littérature en langue russe, des exemples en Python sur Internet, à la fois éducatifs et tout à fait réels. Sur Kotlin, ils ne sont pas dans ce nombre et leur variété n'est pas si grande.



Peut-être un peu en avance sur la courbe, j'ai décidé de présenter les résultats de la maîtrise de Python dans le contexte des problématiques de définition et d'implémentation du parallélisme logiciel et de l'asynchronie. Cela a été initié par l' article [2]... Aujourd'hui, nous aborderons le sujet des générateurs-coroutines. Mon intérêt pour eux est alimenté par la nécessité d'être conscient des possibilités spécifiques, intéressantes, mais pas très familières pour moi pour le moment, des langages modernes / langages de programmation.



Puisque je suis en fait un pur programmeur C ++, cela explique beaucoup de choses. Par exemple, si en Python les coroutines et les générateurs sont présents depuis longtemps, alors en C ++ ils n'ont pas encore gagné leur place. Mais le C ++ en a-t-il vraiment besoin? À mon avis, le langage de programmation doit être étendu de manière raisonnable. Il semble que C ++ a tiré le plus loin possible, et maintenant il essaie à la hâte de rattraper son retard. Mais des problèmes de concurrence similaires peuvent être implémentés en utilisant d'autres concepts et modèles plus fondamentaux que les coroutines / coroutines. Et le fait que derrière cette déclaration il n'y a pas que des mots sera démontré plus loin.



Si nous devons tout admettre, alors j'avoue aussi que je suis plutôt conservateur en ce qui concerne le C ++. Bien sûr, ses objets et ses capacités OOP sont pour moi «tout», mais je dirai que je critique les modèles. Eh bien, je n'ai jamais vraiment regardé leur "langage d'oiseau" particulier, qui, semble-t-il, complique grandement la perception du code et la compréhension de l'algorithme. Même si parfois j'ai même eu recours à leur aide, les doigts d'une main suffisent à tout cela. Je respecte la librairie STL et je ne peux pas m'en passer :) Par conséquent, même de ce fait, j'ai parfois des doutes sur les modèles. Donc je les évite toujours autant que je peux. Et maintenant j'attends avec un frisson les "gabarits de coroutines" en C ++;)



Python est une autre affaire. Je n'ai encore remarqué aucun motif et cela me calme. Mais, d'un autre côté, c'est, curieusement, alarmant. Cependant, quand je regarde le code Kotlin et, surtout, son code compartiment moteur, l'angoisse passe vite;) Cependant, je pense que c'est encore une question d'habitude et de mes préjugés. J'espère qu'avec le temps je me formerai à les percevoir adéquatement (modèles).



Mais ... revenons aux coroutines. Il s'avère que maintenant ils sont sous le nom de corutin. Quoi de neuf avec le changement de nom? Oui, en fait rien. Comme précédemment, l'ensemble est considéré à son tourfonctions exécutées. De la même manière qu'avant, avant de quitter la fonction, mais avant la fin de son travail, le point de retour est fixé, à partir duquel le travail est repris plus tard. La séquence de commutation n'étant pas stipulée, le programmeur contrôle lui-même ce processus en créant son propre ordonnanceur. Il ne s'agit souvent que d'une boucle de fonctions. Comme, par exemple, le cycle d'événement Round Robin dans la vidéo d' Oleg Molchanov [3] .



C'est ainsi qu'une introduction moderne aux coroutines coroutines et à la programmation asynchrone ressemble généralement à "sur les doigts". Il est clair qu'avec l'immersion dans ce sujet, de nouveaux termes et concepts surgissent. Les générateurs sont l'un d'entre eux. De plus, leur exemple servira de base à la démonstration de "préférences parallèles", mais déjà dans mon interprétation automatique.



2. Générateurs de listes de données



Donc - générateurs. La programmation asynchrone et les coroutines leur sont souvent associées. Une série de vidéos d'Oleg Molchanov raconte tout cela. Ainsi, à la caractéristique clé des générateurs, il se réfère à leur "possibilité de suspendre l'exécution d'une fonction afin de continuer son exécution à partir du même endroit où elle s'est arrêtée la dernière fois" (voir [3] pour plus de détails ). Et en cela, étant donné ce qui précède à propos de la définition déjà assez ancienne des coroutines, il n'y a rien de nouveau.



Mais il s'avère que les générateurs ont trouvé une utilisation assez spécifique pour créer des listes de données. Une introduction à ce sujet est déjà couverte dans une vidéo d' Egorov Artem [4]... Mais, comme il semble, par leur application, nous mélangeons des concepts qualitativement différents - opérations et processus. En élargissant les capacités descriptives de la langue, nous passons largement sous silence les problèmes qui peuvent survenir. Ici, comme on dit, pour ne pas trop jouer. Utiliser des générateurs-coroutines pour décrire des données contribue exactement à cela, me semble-t-il. Notez qu'Oleg Molchanov met également en garde contre l'association de générateurs avec des structures de données, soulignant que «les générateurs sont des fonctions» [3] .



Cependant, revenons à l'utilisation de générateurs pour définir des données. Il est difficile de cacher que nous avons créé un processus qui calcule les éléments de la liste. Par conséquent, des questions se posent immédiatement sur une telle liste en tant que processus. Par exemple, comment le réutiliser si les coroutines par définition ne fonctionnent que "à sens unique"? Comment en calculer un élément arbitraire si le processus ne peut pas être indexé? Etc. etc. Artem ne donne pas de réponses à ces questions, seulement avertissant que, disent-ils, l'accès répété aux éléments de la liste ne peut pas être organisé, et l'indexation est inacceptable. Une recherche sur Internet nous convainc que non seulement j'ai des questions similaires, mais que les solutions proposées ne sont pas si triviales et évidentes.



Un autre problème est la vitesse de génération de la liste. Nous formons maintenant un seul élément de la liste sur chaque commutateur coroutine, ce qui augmente le temps de génération des données. Le processus peut être grandement accéléré en générant des éléments en «lots». Mais, très probablement, il y aura des problèmes avec cela. Comment arrêter un processus déjà en cours? Ou autre chose. La liste peut être assez longue, n'utilisant que des éléments sélectionnés. Dans une telle situation, la mémorisation des données est souvent utilisée pour un accès efficace. Au fait, j'ai trouvé presque immédiatement un article sur ce sujet pour Python voir [5] (pour plus d'informations sur la mémorisation en termes d'automates, voir l' article [6] ). Mais qu'en est-il dans ce cas?



La fiabilité d'une telle syntaxe pour définir des listes peut également être discutable, car il est assez facile d'utiliser par erreur des crochets au lieu de parenthèses et vice versa. Il s'avère qu'une solution apparemment belle et élégante dans la pratique peut entraîner certains problèmes. Un langage de programmation doit être technologique, flexible et assurer contre les erreurs involontaires.



En passant, sur le thème des listes et des générateurs sur leurs avantages et inconvénients, en croisant les remarques ci-dessus, vous pouvez regarder une autre vidéo d' Oleg Molchanov [7] .



3. Générateurs-coroutines



La prochaine vidéo d' Oleg Molchanov [8] discute de l'utilisation de générateurs pour coordonner le travail des coroutines. En fait, ils sont destinés à cela. L'attention est attirée sur le choix des moments de commutation des coroutines. Leur disposition suit une règle simple - nous plaçons l'instruction yield devant les fonctions de blocage. Ces dernières sont comprises comme des fonctions dont le temps de retour est si long par rapport aux autres opérations que les calculs sont associés à leur arrêt. Pour cette raison, ils étaient appelés bloqueurs.



La commutation est effective lorsque le processus suspendu continue son travail exactement quand l'appel de blocage n'attendra pas, mais terminera rapidement son travail. Et, semble-t-il, pour le plaisir de tout cela, tout ce «tapage» a commencé autour du modèle coroutine / coroutine et, en conséquence, une impulsion a été donnée au développement de la programmation asynchrone. Bien que, notez, l'idée originale des coroutines était encore différente - créer un modèle virtuel de calcul parallèle.



Dans la vidéo considérée, comme dans le cas général des coroutines, la poursuite de l'opération coroutine est déterminée par l'environnement externe, qui est un planificateur d'événements. Dans ce cas, il est représenté par une fonction nommée event_loop. Et, semble-t-il, tout est logique: l'ordonnanceur effectuera l'analyse et poursuivra le travail de la coroutine en appelant l'opérateur next () exactement quand c'est nécessaire. Le problème réside dans l'attente là où on ne l'attendait pas: l'ordonnanceur peut être assez complexe. Dans la vidéo précédente de Molchanov ( voir [3] ), tout était simple, puisque un simple transfert de contrôle alterné a été effectué, dans lequel il n'y avait pas de verrouillage, car il n'y a pas eu d'appels correspondants. Néanmoins, nous soulignons que dans tous les cas au moins un simple ordonnanceur est nécessaire.



Problème 1. , next() (. event_loop). , , yield. - , , next(), .



2. , select, — . .



Mais le problème n'est même pas le besoin d'un planificateur, mais le fait qu'il assume des fonctions inhabituelles pour lui. La situation est encore compliquée par le fait qu'il est nécessaire de mettre en œuvre un algorithme pour le fonctionnement conjoint de nombreuses coroutines. La comparaison des ordonnanceurs discutés dans les deux vidéos mentionnées par Oleg Molchanov reflète assez clairement un problème similaire: l'algorithme d'ordonnancement de socket dans [8] est nettement plus compliqué que l'algorithme "carrousel" dans [3] .



3. Vers un monde sans coroutines



Puisque nous sommes sûrs qu'un monde sans coroutines est possible, en les opposant avec des automates, alors il est nécessaire de montrer comment des tâches similaires sont déjà résolues par elles. Démontrons cela en utilisant le même exemple d'utilisation des sockets. Notez que sa mise en œuvre initiale s'est avérée pas si triviale qu'elle pouvait être comprise tout de suite. Ceci est souligné à plusieurs reprises par l'auteur de la vidéo lui-même. D'autres sont confrontés à des problèmes similaires dans le cadre des coroutines. Ainsi, les inconvénients des coroutines liés à la complexité de leur perception, compréhension, débogage, etc. discuté en vidéo [10] .



Tout d'abord, quelques mots sur la complexité de l'algorithme considéré. Cela est dû à la nature dynamique et plurielle des processus de service client. Pour ce faire, un serveur est créé qui écoute un port donné et, à mesure que les demandes apparaissent, génère de nombreuses fonctions de service client qui l'atteignent. Comme il peut y avoir de nombreux clients, ils apparaissent de manière imprévisible, une liste dynamique est créée à partir des processus de maintenance des sockets et d'échange d'informations avec eux. Le code de la solution du générateur Python discuté dans la vidéo [8] est présenté dans le Listing 1.



Listing 1. Prises sur les générateurs
import socket
from select import select
tasks = []
to_read = {}
to_write = {}

def server():

    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server_socket.bind(('localhost', 5001))
    server_socket.listen()

    while True:
        yield ('read', server_socket)
        client_socket, addr = server_socket.accept()    
        print('Connection from', addr)
        tasks.append(client(client_socket, addr))       
    print('exit server')

def client(client_socket, addr):

    while True:
        yield ('read', client_socket)
        request = client_socket.recv(4096)              

        if not request:
            break
        else:
            response = 'Hello World\n'.encode()

            yield ('write', client_socket)

            client_socket.send(response)                
    client_socket.close()                               
    print('Stop client', addr)

def event_loop():
    while any([tasks, to_read, to_write]):

        while not tasks:

            ready_to_read, ready_to_write, _ = select(to_read, to_write, [])

            for sock in ready_to_read:
                tasks.append(to_read.pop(sock))

            for sock in ready_to_write:
                tasks.append(to_write.pop(sock))
        try:
            task = tasks.pop(0)

            reason, sock = next(task)   

            if reason == 'read':
                to_read[sock] = task
            if reason == 'write':
                to_write[sock] = task
        except StopIteration:
            print('Done!')
tasks.append(server())
event_loop()




Les algorithmes serveur et client sont assez basiques. Mais il devrait être alarmant que le serveur place la fonction client dans la liste des tâches. De plus - plus: il est difficile de comprendre l'algorithme de la boucle d'événement event_loop. Jusqu'à ce que la liste des tâches puisse être vide si au moins le processus serveur doit toujours y être présent? ..



Ensuite, les dictionnaires to_read et to_write sont introduits. Le travail même avec les dictionnaires nécessite une explication distincte, car c'est plus difficile que de travailler avec des listes régulières. Pour cette raison, les informations renvoyées par les instructions yield leur sont adaptées. Puis la "danse avec un tambourin" commence autour des dictionnaires et tout devient comme une sorte de "bouillonnement": quelque chose semble être placé dans les dictionnaires, d'où il entre dans la liste des tâches, etc. etc. Vous pouvez "vous casser la tête", en réglant tout cela.



Et à quoi ressemblera la solution de la tâche à accomplir? Il serait logique que les automates créent des modèles équivalents aux processus de travail avec des sockets déjà discutés dans la vidéo. Dans le modèle de serveur, il semble que rien ne doive être changé. Ce sera un automate fonctionnant comme la fonction server (). Son graphique est illustré à la Fig. 1a. L'action d'automate y1 () crée une socket serveur et la connecte au port spécifié. Le prédicat x1 () définit la connexion client, et si elle est présente, l'action y2 () crée un processus de service de socket client, en plaçant ce dernier dans la liste des processus de classes, qui inclut les classes d'objets actives.



En figue. 1b montre un graphique du modèle pour un client individuel. Etant dans l'état "0", l'automate détermine la disposition du client à transmettre des informations (prédicat x1 () - vrai) et reçoit une réponse dans l'action y1 () au passage à l'état "1". De plus, lorsque le client est prêt à recevoir des informations (déjà x2 () doit être vrai), l'action y2 () met en œuvre l'opération d'envoi d'un message au client sur le passage à l'état initial "0". Si le client rompt la connexion avec le serveur (dans ce cas, x3 () est faux), alors l'automate passe à l'état "4", fermant le socket client dans l'action y3 (). Le processus reste à l'état "4" jusqu'à ce qu'il soit exclu de la liste des classes actives (voir la description ci-dessus du modèle de serveur pour la formation de la liste).



En figue. 1c montre un automate qui implémente le lancement de processus similaires à la fonction event_loop () du Listing 1. Seulement dans ce cas, son algorithme de fonctionnement est beaucoup plus simple. Tout se résume au fait que la machine parcourt les éléments de la liste des classes actives et appelle la méthode loop () pour chacune d'elles. Cette action est implémentée par y2 (). L'action y4 () exclut de la liste les classes qui sont à l'état "4". Le reste des actions fonctionne avec l'index de la liste des objets: l'action y3 () augmente l'index, l'action y1 () le réinitialise.



Les capacités de programmation objet en Python sont différentes de la programmation objet en C ++. Par conséquent, l'implémentation la plus simple du modèle d'automate sera prise comme base (pour être précis, il s'agit d'une imitation d'un automate). Il est basé sur le principe d'objet de représentation des processus, dans lequel chaque processus correspond à une classe active distincte (ils sont souvent également appelés agents). La classe contient les propriétés et méthodes nécessaires (voir plus de détails sur les méthodes d'automates spécifiques - prédicats et actions dans [9] ), et la logique du fonctionnement de l'automate (ses fonctions de transition et de sortie) est concentrée dans le cadre de la méthode appelée loop (). Pour implémenter la logique du comportement de l'automate, nous utiliserons la construction if-elif-else.



Avec cette approche, la "boucle d'événements" n'a rien à voir avec l'analyse de la disponibilité des sockets. Ils sont vérifiés par les processus eux-mêmes, qui utilisent la même instruction de sélection dans les prédicats. Dans cette situation, ils fonctionnent avec un seul socket, et non une liste d'entre eux, le vérifiant pour l'opération qui est attendue pour ce socket particulier et précisément dans la situation qui est déterminée par l'algorithme d'opération. À propos, au cours du débogage d'une telle implémentation, une essence de blocage inattendue de l'instruction select est apparue.



Figure: 1. Graphiques des processus d'automates pour travailler avec des sockets
image



Le listing 2 montre un code objet automate en Python pour travailler avec des sockets. C'est notre genre de "monde sans coroutines". C'est un «monde» avec des principes différents pour concevoir des processus logiciels. Elle se caractérise par la présence d'un modèle algorithmique de calculs parallèles (pour plus de détails voir [9] , qui est la différence principale et qualitative entre la technologie de programmation d'automates (AP) et la «technologie coroutine».



La programmation d'automates implémente facilement les principes asynchrones de conception de programme, de parallélisme de processus et en même temps tout ce à quoi l'esprit d'un programmeur peut penser. Mes précédents articles en parlent plus en détail, en commençant par la description du modèle structurel du calcul automatique et sa définition formelle jusqu'aux exemples de son application. Le code ci-dessus en Python montre l'implémentation automatique des principes de coroutine des coroutines, les superposant complètement, les complétant et les étendant avec le modèle de machine à états.



Listing 2. Prises sur les machines
import socket
from select import select

timeout = 0.0; classes = []

class Server:
    def __init__(self): self.nState = 0;

    def x1(self):
        self.ready_client, _, _ = select([self.server_socket], [self.server_socket], [], timeout)
        return self.ready_client

    def y1(self):
        self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.server_socket.bind(('localhost', 5001))
        self.server_socket.listen()
    def y2(self):
        self.client_socket, self.addr = self.server_socket.accept()
        print('Connection from', self.addr)
        classes.append(Client(self.client_socket, self.addr))

    def loop(self):
        if (self.nState == 0):      self.y1();      self.nState = 1
        elif (self.nState == 1):
            if (self.x1()):         self.y2();      self.nState = 0

class Client:
    def __init__(self, soc, adr): self.client_socket = soc; self.addr = adr; self.nState = 0

    def x1(self):
        self.ready_client, _, _ = select([self.client_socket], [], [], timeout)
        return self.ready_client
    def x2(self):
        _, self.write_client, _ = select([], [self.client_socket], [], timeout)
        return self.write_client
    def x3(self): return self.request

    def y1(self): self.request = self.client_socket.recv(4096);
    def y2(self): self.response = 'Hello World\n'.encode(); self.client_socket.send(self.response)
    def y3(self): self.client_socket.close(); print('close Client', self.addr)

    def loop(self):
        if (self.nState == 0):
            if (self.x1()):                     self.y1(); self.nState = 1
        elif (self.nState == 1):
            if (not self.x3()):                 self.y3(); self.nState = 4
            elif (self.x2() and self.x3()):     self.y2(); self.nState = 0

class EventLoop:
    def __init__(self): self.nState = 0; self.i = 0

    def x1(self): return self.i < len(classes)

    def y1(self): self.i = 0
    def y2(self): classes[self.i].loop()
    def y3(self): self.i += 1
    def y4(self):
        if (classes[self.i].nState == 4):
            classes.pop(self.i)
            self.i -= self.i

    def loop(self):
        if (self.nState == 0):
            if (not self.x1()): self.y1();
            if (self.x1()):     self.y2(); self.y4(); self.y3();

namSrv = Server(); namEv = EventLoop()
while True:
    namSrv.loop(); namEv.loop()




Le code du Listing 2 est beaucoup plus avancé technologiquement que le code du Listing 1. Et c'est le mérite du modèle automatique de calculs. Ceci est facilité par l'intégration du comportement de l'automate dans le modèle objet de programmation. En conséquence, la logique de comportement des processus automates est concentrée exactement là où elle est générée, et non déléguée, comme cela est pratiqué dans les coroutines, dans la boucle événementielle du contrôle de processus. La nouvelle solution provoque la création d'une "boucle événementielle" universelle, dont le prototype peut être considéré comme le code de la classe EventLoop.



4. À propos des principes SRP et DRY



Les principes de «responsabilité unique» - SRP (le principe de responsabilité unique) et «ne vous répétez pas» - DRY (ne vous répétez pas) sont exprimés dans le contexte d'une autre vidéo d' Oleg Molchanov [11] . Selon eux, la fonction ne doit contenir que le code cible pour ne pas violer le principe SRY, et ne pas favoriser la répétition de "code supplémentaire" pour ne pas violer le principe DRY. A cet effet, il est proposé d'utiliser des décorateurs. Mais il existe une autre solution - une solution automatique.



Dans l' article précédent [2]ignorant l'existence de tels principes, un exemple a été donné à l'aide de décorateurs. Considéré comme un compteur qui, d'ailleurs, pourrait générer des listes si on le souhaite. L'objet chronomètre qui mesure le temps de fonctionnement du compteur est mentionné. Si les objets sont conformes aux principes SRP et DRY, leur fonctionnalité n'est pas aussi importante que le protocole de communication. Dans l'implémentation, le code du compteur n'a rien à voir avec le code du chronomètre et la modification de l'un des objets n'affectera pas l'autre. Ils ne sont liés que par le protocole, sur lequel les objets s'accordent «sur le rivage» et le suivent ensuite strictement.



Ainsi, un modèle d'automate parallèle remplace essentiellement les capacités des décorateurs. Il est plus flexible et plus facile de mettre en œuvre leurs capacités, car n'entoure pas (ne décore pas) le code de fonction. Aux fins d'une évaluation objective et de la comparaison de l'automate et de la technologie conventionnelle, le Listing 3 montre un objet analogue du compteur discuté dans l'article précédent [2] , où des versions simplifiées avec les heures de leur exécution et la version originale du compteur sont présentées après commentaires.



Listing 3. Implémentation de compteur automatique
import time
# 1) 110.66 sec
class PCount:
    def __init__(self, cnt ): self.n = cnt; self.nState = 0
    def x1(self): return self.n > 0
    def y1(self): self.n -=1
    def loop(self):
        if (self.nState == 0 and self.x1()):
            self.y1();
        elif (self.nState == 0 and not self.x1()):  self.nState = 4;

class PTimer:
    def __init__(self, p_count):
        self.st_time = time.time(); self.nState = 0; self.p_count = p_count
#    def x1(self): return self.p_count.nStat == 4 or self.p_count.nState == 4
    def x1(self): return self.p_count.nState == 4
    def y1(self):
        t = time.time() - self.st_time
        print ("speed CPU------%s---" % t)
    def loop(self):
       if (self.nState == 0 and self.x1()): self.y1(); self.nState = 1
       elif (self.nState == 1): pass

cnt1 = PCount(1000000)
cnt2 = PCount(10000)
tmr1 = PTimer(cnt1)
tmr2 = PTimer(cnt2)
# event loop
while True:
    cnt1.loop(); tmr1.loop()
    cnt2.loop(); tmr2.loop()

# # 2) 73.38 sec
# class PCount:
#     def __init__(self, cnt ): self.n = cnt; self.nState = 0
#     def loop(self):
#         if (self.nState == 0 and self.n > 0): self.n -= 1;
#         elif (self.nState == 0 and not self.n > 0):  self.nState = 4;
# 
# class PTimer:
#     def __init__(self): self.st_time = time.time(); self.nState = 0
#     def loop(self):
#        if (self.nState == 0 and cnt.nState == 4):
#            t = time.time() - self.st_time
#            print("speed CPU------%s---" % t)
#            self.nState = 1
#        elif (self.nState == 1): exit()
# 
# cnt = PCount(100000000)
# tmr = PTimer()
# while True:
#     cnt.loop();
#     tmr.loop()

# # 3) 35.14 sec
# class PCount:
#     def __init__(self, cnt ): self.n = cnt; self.nState = 0
#     def loop(self):
#         if (self.nState == 0 and self.n > 0):
#             self.n -= 1;
#             return True
#         elif (self.nState == 0 and not self.n > 0):  return False;
#
# cnt = PCount(100000000)
# st_time = time.time()
# while cnt.loop():
#     pass
# t = time.time() - st_time
# print("speed CPU------%s---" % t)

# # 4) 30.53 sec
# class PCount:
#     def __init__(self, cnt ): self.n = cnt; self.nState = 0
#     def loop(self):
#         while self.n > 0:
#             self.n -= 1;
#             return True
#         return False
#
# cnt = PCount(100000000)
# st_time = time.time()
# while cnt.loop():
#     pass
# t = time.time() - st_time
# print("speed CPU------%s---" % t)

# # 5) 18.27 sec
# class PCount:
#     def __init__(self, cnt ): self.n = cnt; self.nState = 0
#     def loop(self):
#         while self.n > 0:
#             self.n -= 1;
#         return False
# 
# cnt = PCount(100000000)
# st_time = time.time()
# while cnt.loop():
#     pass
# t = time.time() - st_time
# print("speed CPU------%s---" % t)

# # 6) 6.96 sec
# def count(n):
#   st_time = time.time()
#   while n > 0:
#     n -= 1
#   t = time.time() - st_time
#   print("speed CPU------%s---" % t)
#   return t
#
# def TestTime(fn, n):
#   def wrapper(*args):
#     tsum=0
#     st = time.time()
#     i=1
#     while (i<=n):
#       t = fn(*args)
#       tsum +=t
#       i +=1
#     return tsum
#   return wrapper
#
# test1 = TestTime(count, 2)
# tt = test1(100000000)
# print("Total ---%s seconds ---" % tt)




Résumons les temps de fonctionnement des différentes options dans un tableau et commentons les résultats des travaux.



  1. Implémentation d'automate classique - 110,66 sec
  2. Implémentation d'automates sans méthodes d'automates - 73,38 sec
  3. Sans chronomètre automatique - 35.14
  4. Compteur dans le formulaire avec sortie à chaque itération - 30,53
  5. Compteur avec cycle de blocage - 18,27
  6. Comptoir original avec décorateur - 6,96


La première option, représentant le modèle de compteur automatique dans son intégralité, c'est-à-dire le compteur et le chronomètre ont la durée de fonctionnement la plus longue. Le temps de fonctionnement peut être réduit en abandonnant, pour ainsi dire, les principes de la technologie automatique. Conformément à cela, dans l'option 2, les appels aux prédicats et aux actions sont remplacés par leur code. De cette façon, nous avons gagné du temps sur les opérateurs d'appel de méthode et c'est tout à fait visible, c'est-à-dire de plus de 30 secondes, réduit le temps de fonctionnement.



Nous avons économisé un peu plus dans la 3ème option, créant une implémentation plus simple du compteur, mais avec une sortie de celui-ci à chaque itération du cycle du compteur (imitation de l'opération coroutine). En supprimant la suspension du compteur (voir option 5), nous avons obtenu la plus forte réduction du travail du compteur. Mais en même temps, nous avons perdu les avantages du travail de coroutine. Option 6 - c'est le comptoir d'origine avec un décorateur déjà répété et il a le moins de temps de fonctionnement. Mais, comme l'option 5, il s'agit d'une implémentation bloquante, qui ne peut pas nous convenir dans le contexte de la discussion du fonctionnement coroutin des fonctions.



5. Conclusions



Que ce soit pour utiliser la technologie des automates ou faire confiance aux coroutines - la décision appartient entièrement au programmeur. Il est important pour nous ici qu'il sache qu'il existe une approche / technologie différente des coroutines pour la conception de programmes. Vous pouvez même imaginer l'option exotique suivante. Tout d'abord, au stade de la conception du modèle, un modèle de solution d'automate est créé. Il est rigoureusement scientifique, fondé sur des preuves et bien documenté. Ensuite, par exemple, pour améliorer les performances, il est «défiguré» en une version «normale» du code, comme le montre le Listing 3. Vous pouvez même imaginer un «reverse refactoring» du code, c'est-à-dire le passage de la 7ème option à la 1ère, mais ceci, bien que possible, mais le cours des événements le moins probable :)



Sur la fig. 2 montre des diapositives de la vidéo sur le thème "asynchrone" [10]... Et le «mauvais» semble l'emporter sur le «bien». Et si à mon avis les automates sont toujours bons, alors dans le cas de la programmation asynchrone, choisissez, comme on dit, à votre goût. Mais il semble que la «mauvaise» option sera la plus probable. Et le programmeur doit le savoir à l'avance lors de la conception d'un programme.



Figure: 2. Caractéristiques de la programmation asynchrone
image



Certes, le code de l'automate est quelque peu "non sans péché". Il aura une quantité de code légèrement plus grande. Mais d'abord, il est mieux structuré et donc plus facile à comprendre et à maintenir. Et, deuxièmement, il ne sera pas toujours plus grand, car avec une complexité croissante, il y aura probablement même un gain (dû, par exemple, à la réutilisation des méthodes d'automates). Il est plus facile et plus clair de déboguer. Oui, en fin de compte, c'est complètement SRP et DRY. Et cela, parfois, l'emporte sur beaucoup.



Il serait souhaitable, et peut-être même nécessaire, de prêter attention, disons, à la norme de conception des fonctions. Le programmeur doit, dans la mesure du possible, éviter de concevoir des fonctions de blocage. Pour ce faire, il doit soit démarrer uniquement le processus de calcul, dont l'exhaustivité est ensuite vérifiée, soit disposer de moyens pour vérifier la disponibilité à démarrer, comme la fonction de sélection considérée dans les exemples. Le code qui utilise des fonctions datant de l'époque DOS, montré dans le Listing 4, indique que de tels problèmes ont une longue histoire "pré-routine".



Listing 4. Lecture de caractères depuis le clavier
/*
int main(int argc, char *argv[])
{
    QCoreApplication a(argc, argv);

    int C=0;
    while (C != 'e')
    {
        C = getch();
        putchar (C);
    }
    return a.exec();
}
*/
//*
int main(int argc, char *argv[])
{
    QCoreApplication a(argc, argv);

    int C=0;
    while (C != 'e')
    {
        if (kbhit()) {
            C = getch();
            putch(C);
        }
    }

    return a.exec();
}
//*/




Voici deux options pour lire les caractères à partir du clavier. La première option est le blocage. Il bloquera le calcul et n'exécutera pas l'instruction pour sortir le caractère tant que la fonction getch () ne le recevra pas du clavier. Dans la deuxième variante, la même fonction ne sera lancée qu'au bon moment, lorsque la fonction appariée kbhit () confirme que le caractère est dans le tampon d'entrée. Ainsi, il n'y aura pas de blocage des calculs.



Si la fonction est "lourde" en elle-même, c'est-à-dire nécessite un temps de travail important, et une sortie périodique de celui-ci par le type de travail des coroutines (cela peut être fait sans utiliser le mécanisme des mêmes coroutines, afin de ne pas s'y lier) est difficile à faire ou n'a pas beaucoup de sens, il reste alors à placer ces fonctions dans un thread séparé puis contrôler l'achèvement de leur travail (voir l'implémentation de la classe QCount dans [2]).



Vous pouvez toujours trouver un moyen d'exclure le blocage des calculs. Ci-dessus, nous avons montré comment créer du code asynchrone dans le cadre des moyens habituels du langage, sans utiliser le mécanisme coroutine / coroutine et même aucun environnement spécialisé tel que l'environnement de programmation automatique VKP (a). Et quoi et comment utiliser, c'est au programmeur de décider.



Littérature



1. Podcast Python Junior. À propos de l'asynchronie en python. [Ressource électronique], mode d'accès: www.youtube.com/watch?v=Q2r76grtNeg , gratuit. Langue. russe (date de traitement 13/07/2020).

2. Concurrence et efficacité: Python vs FSM. [Ressource électronique], mode d'accès: habr.com/ru/post/506604 , gratuit. Langue. russe (date de traitement 13/07/2020).

3. Molchanov O. Fondamentaux de l'asynchronie en Python # 4: Générateurs et boucle d'événement Round Robin. [Ressource électronique], mode d'accès: www.youtube.com/watch?v=PjZUSSkGLE8 ], gratuit. Yaz. russe (date de traitement 13/07/2020).

4. 48 Générateurs et itérateurs. Expressions de générateur en Python. [Ressource électronique], mode d'accès: www.youtube.com/watch?v=vn6bV6BYm7w, gratuit. Langue. russe (date de traitement 13/07/2020).

5. Mémorisation et currying (Python). [Ressource électronique], mode d'accès: habr.com/ru/post/335866 , gratuit. Langue. russe (date de traitement 13/07/2020).

6. Lyubchenko V.S. A propos du traitement de la récursivité. "PC World", n ° 11/02. www.osp.ru/pcworld/2002/11/164417

7. Molchanov O. Python Cast # 10 - Qu'est-ce que le rendement. [Ressource électronique], mode d'accès: www.youtube.com/watch?v=ZjaVrzOkpZk , gratuit. Langue. russe (date de traitement 18/07/2020).

8. Molchanov O. Fondamentaux de l'async en Python # 5: Async sur les générateurs. [Ressource électronique], mode d'accès: www.youtube.com/watch?v=hOP9bKeDOHs , gratuit. Langue. russe (date de traitement 13/07/2020).

9. Modèle de calcul parallèle. [Ressource électronique], mode d'accès: habr.com/ru/post/486622 , gratuit. Yaz. russe (date de traitement 20/07/2020).

10. Polishchuk A. Asynchronisme en Python. [Ressource électronique], mode d'accès: www.youtube.com/watch?v=lIkA0TDX8tE , gratuit. Langue. russe (date de traitement 13/07/2020).

11. Molchanov O. Lessons Python cast # 6 - Décorateurs. [Ressource électronique], mode d'accès: www.youtube.com/watch?v=Ss1M32pp5Ew , gratuit. Yaz. russe (date de traitement 13/07/2020).



All Articles