Fonctions de modèle en Python pouvant s'exécuter de manière synchrone et asynchrone

image



Aujourd'hui, presque tous les développeurs connaissent le concept d '"asynchronie" en programmation. À une époque où les produits d'information sont tellement demandés qu'ils sont obligés de traiter simultanément un grand nombre de demandes et d'interagir en parallèle avec un grand nombre d'autres services - sans programmation asynchrone - nulle part. Le besoin s'est avéré si grand qu'un langage séparé a même été créé, dont la principale caractéristique (en plus d'être minimaliste) est un travail très optimisé et pratique avec du code parallèle / simultané, à savoir Golang . Malgré le fait que l'article ne parle pas du tout de lui, je vais souvent faire des comparaisons et y faire référence. Mais ici en Python, qui sera discuté dans cet article - il y a quelques problèmes que je vais décrire et proposer une solution à l'un d'entre eux. Si vous êtes intéressé par ce sujet - s'il vous plaît, sous cat.






Il se trouve que mon langage préféré, avec lequel je travaille, mets en œuvre des projets familiers et même me repose et me détends, est Python . Je suis sans cesse captivé par sa beauté et sa simplicité, son évidence, derrière laquelle, à l'aide de divers types de sucre syntaxique, il y a d'énormes opportunités pour une description laconique de presque toutes les logiques dont l'imagination humaine est capable. J'ai même lu quelque part que Python s'appelle un langage de très haut niveau, car il peut être utilisé pour décrire des abstractions qui seraient extrêmement problématiques à décrire dans d'autres langues.



Mais il y a une nuance sérieuse - Pythontrès difficile à intégrer dans les concepts modernes du langage avec la possibilité de mettre en œuvre une logique parallèle / simultanée. Le langage, dont l'idée est née dans les années 80 et qui a le même âge que Java, jusqu'à un certain temps n'impliquait l'exécution d'aucun code de manière compétitive. Si JavaScript nécessitait initialement la simultanéité pour un travail non bloquant dans le navigateur et que Golang est un langage complètement nouveau avec une réelle compréhension des besoins modernes, Python n'avait pas de telles tâches auparavant.



Ceci, bien sûr, est mon opinion personnelle, mais il me semble que Python est très en retard avec l'implémentation de l'asynchronie, depuis l'apparition de la bibliothèque asyncio intégréeétait plutôt une réaction à l'émergence d'autres implémentations d'exécution de code simultané pour Python. Fondamentalement, asyncio est conçu pour prendre en charge les implémentations existantes et contient non seulement sa propre implémentation de boucle d'événements, mais également un wrapper pour d'autres bibliothèques asynchrones, offrant ainsi une interface commune pour l'écriture de code asynchrone. Et Python , qui a été créé à l'origine comme le langage le plus laconique et le plus lisible en raison de tous les facteurs énumérés ci-dessus, lorsque l'écriture de code asynchrone devient un fouillis de décorateurs, de générateurs et de fonctions. La situation a été légèrement corrigée par l'ajout de directives spéciales async et wait (comme en JavaScript , ce qui est important) (corrigé, grâce à l'utilisateurtmnhy), mais des problèmes communs subsistent.



Je ne les énumérerai pas tous et me concentrerai sur celui que j'ai essayé de résoudre: il s'agit d'une description de la logique générale de l'exécution asynchrone et synchrone. Par exemple, si je veux exécuter une fonction en parallèle dans Golang , il me suffit d'appeler la fonction avec la directive go :



Exécution parallèle de la fonction dans Golang
package main

import "fmt"

func function(index int) {
    fmt.Println("function", index)
}

func main() {
    for i := 0; i < 10; i++ { 
        go function(i)
    }
    fmt.Println("end")
}




Cela étant dit, dans Golang, je peux exécuter cette même fonction de manière synchrone:



Exécution en série de la fonction dans Golang
package main

import "fmt"

func function(index int) {
    fmt.Println("function", index)
}

func main() {
    for i := 0; i < 10; i++ { 
        function(i)
    }
    fmt.Println("end")
}




En Python, toutes les coroutines (fonctions asynchrones) sont basées sur des générateurs et le basculement entre eux se produit lors de l'appel de fonctions de blocage, renvoyant le contrôle à la boucle d'événements à l'aide de la directive yield . Pour être honnête, je ne sais pas comment fonctionne la concurrence / concurrence dans Golang , mais je ne me trompe pas si je dis que cela ne fonctionne pas comme il le fait en Python . Malgré les différences existantes dans les éléments internes de l'implémentation du compilateur Golang et de l'interpréteur CPython et l'inadmissibilité de comparer le parallélisme / concurrence entre eux, je continuerai de le faire et de prêter attention non pas à l'exécution elle-même, mais à la syntaxe. En PythonJe ne peux pas prendre une fonction et l'exécuter en parallèle / simultanément avec un opérateur. Pour que ma fonction fonctionne de manière asynchrone, je dois explicitement l'écrire async avant sa déclaration, et après cela ce n'est plus seulement une fonction, c'est déjà une coroutine. Et je ne peux pas mélanger leurs appels dans un code sans actions supplémentaires, car une fonction et une coroutine en Python sont des choses complètement différentes, malgré la similitude dans la déclaration.



def func1(a, b):
    func2(a + b)
    await func3(a - b)  # ,   await     


Mon principal problème était la nécessité de développer une logique qui puisse fonctionner à la fois de manière synchrone et asynchrone. Un exemple simple est ma bibliothèque d'interaction avec Instagram , que j'ai abandonnée il y a longtemps, mais que j'ai maintenant reprise (ce qui m'a incité à rechercher une solution). Je voulais y mettre en œuvre la possibilité de travailler avec l'API non seulement de manière synchrone, mais aussi asynchrone, et ce n'était pas seulement un désir - lors de la collecte de données sur Internet, vous pouvez envoyer un grand nombre de demandes de manière asynchrone et obtenir une réponse à toutes plus rapidement, mais en même temps, la collecte massive de données n'est pas toujours nécessaire. Pour le moment, la bibliothèque met en œuvre ce qui suit: pour travailler avec Instagramil existe 2 classes, une pour le travail synchrone, l'autre pour le travail asynchrone. Chaque classe a le même ensemble de méthodes, seulement dans la première, les méthodes sont synchrones et dans la seconde, elles sont asynchrones. Chaque méthode fait la même chose - à l'exception de la façon dont les demandes sont envoyées à Internet. Et seulement à cause des différences dans une action de blocage, j'ai dû dupliquer presque complètement la logique dans chaque méthode. Cela ressemble à ceci:



class WebAgent:
    def update(self, obj=None, settings=None):
        ...
        response = self.get_request(path=path, **settings)
        ...

class AsyncWebAgent:
    async def update(self, obj=None, settings=None):
        ...
        response = await self.get_request(path=path, **settings)
        ...


Tout le reste dans la méthode de mise à jour et dans la coroutine de mise à jour est absolument identique. Et comme beaucoup de gens le savent, la duplication de code ajoute beaucoup de problèmes, en particulier lorsqu'il s'agit de corriger des bogues et de tester.



J'ai écrit ma propre bibliothèque pySyncAsync pour résoudre ce problème . L'idée est la suivante - au lieu de fonctions et de coroutines ordinaires, un générateur est implémenté, à l'avenir je l'appellerai un modèle. Pour exécuter un modèle, vous devez le générer en tant que fonction régulière ou en tant que coroutine. Le modèle, lorsqu'il est exécuté au moment où il doit exécuter du code asynchrone ou synchrone à l'intérieur de lui-même, retourne un objet Call spécial en utilisant yield, qui spécifie ce qu'il faut appeler et avec quels arguments. En fonction de la manière dont le modèle sera généré - en tant que fonction ou en tant que coroutine - c'est ainsi que les méthodes décrites dans l'objet Call seront exécutées .



Je vais montrer un petit exemple de modèle qui suppose la possibilité de faire des demandes à google :



Exemple de requêtes Google utilisant pySyncAsync
import aiohttp
import requests

import pysyncasync as psa

#       google
#          Call
@psa.register("google_request")
def sync_google_request(query, start):
    response = requests.get(
        url="https://google.com/search",
        params={"q": query, "start": start},
    )
    return response.status_code, dict(response.headers), response.text


#       google
#          Call
@psa.register("google_request")
async def async_google_request(query, start):
    params = {"q": query, "start": start}
    async with aiohttps.ClientSession() as session:
        async with session.get(url="https://google.com/search", params=params) as response:
            return response.status, dict(response.headers), await response.text()


#     100 
def google_search(query):
    start = 0
    while start < 100:
        #  Call     ,        google_request
        call = Call("google_request", query, start=start)
        yield call
        status, headers, text = call.result
        print(status)
        start += 10


if __name__ == "__main__":
    #   
    sync_google_search = psa.generate(google_search, psa.SYNC)
    sync_google_search("Python sync")

    #   
    async_google_search = psa.generate(google_search, psa.ASYNC)
    loop = asyncio.get_event_loop()
    loop.run_until_complete(async_google_search("Python async"))




Je vais vous parler un peu de la structure interne de la bibliothèque. Il existe une classe Manager , dans laquelle les fonctions et les coroutines sont enregistrées pour être appelées à l'aide de Call . Il est également possible d'enregistrer des modèles, mais cela est facultatif. La classe Manager a des méthodes register , generate et template . Les mêmes méthodes dans l'exemple ci-dessus ont été appelées directement à partir de pysyncasync , mais elles utilisaient une instance globale de la classe Manager , qui était déjà créée dans l'un des modules de la bibliothèque. En fait, vous pouvez créer votre propre instance et appeler le registre , générer et créer des modèles à partir de celle-ci.isolant ainsi les managers les uns des autres si, par exemple, un conflit de nom est possible.



La méthode register agit comme un décorateur et vous permet d'enregistrer une fonction ou une coroutine pour un appel ultérieur à partir du modèle. Le décorateur de registre accepte comme argument le nom sous lequel la fonction ou la coroutine est enregistrée dans le gestionnaire. Si le nom n'est pas spécifié, la fonction ou la coroutine est enregistrée sous son propre nom.



La méthode de modèle vous permet d'enregistrer le générateur en tant que modèle dans le gestionnaire. Cela est nécessaire pour pouvoir obtenir un modèle par nom. Générer une



méthodevous permet de générer une fonction ou une coroutine basée sur un modèle. Il prend deux arguments: le premier est le nom du template ou du template lui-même, le second est "sync" ou "async" - quel type de template générer - une fonction ou une coroutine. En sortie, la méthode generate donne une fonction ou coroutine prête à l'emploi.



Je vais donner un exemple de génération d'un template, par exemple, dans une coroutine:



def _async_generate(self, template):
    async def wrapper(*args, **kwargs):
        ...
        for call in template(*args, **kwargs):
            callback = self._callbacks.get(f"{call.name}:{ASYNC}")
            call.result = await callback(*call.args, **call.kwargs)
        ...
    return wrapper


À l'intérieur, une coroutine est générée, qui itère simplement sur le générateur et reçoit les objets de la classe Call , puis prend la coroutine précédemment enregistrée par son nom (le nom est tiré de l' appel ), l'appelle avec des arguments (qu'elle prend également de l' appel ) et le résultat de l'exécution de cette coroutine est également stocké dans call .



Les objets de la classe Call sont simplement des conteneurs pour stocker des informations sur quoi et comment appeler et vous permettent également de stocker le résultat en eux-mêmes. wrapper peut également retourner le résultat de l'exécution du modèle; pour cela, le modèle est enveloppé dans une classe Generator spéciale , qui n'est pas affichée ici.



J'ai omis certaines nuances, mais j'espère avoir transmis l'essence en général.



Pour être honnête, cet article a été écrit par moi plutôt pour partager mes réflexions sur la résolution de problèmes avec du code asynchrone en Python.et, surtout, écouter les opinions des habitants de Khabrav. Peut-être que je vais heurter quelqu'un dans une autre solution, peut-être que quelqu'un sera en désaccord avec cette implémentation particulière et vous dira comment vous pouvez l'améliorer, peut-être que quelqu'un vous dira pourquoi une telle solution n'est pas du tout nécessaire et vous ne devriez pas mélanger synchrone et code asynchrone, l'avis de chacun de vous est très important pour moi. De plus, je ne prétends pas être vrai de tout mon raisonnement au début de l'article. J'ai beaucoup réfléchi au sujet des autres YP et je pourrais me tromper, en plus il y a une possibilité que je puisse confondre les concepts, s'il vous plaît, si vous remarquez soudainement des incohérences - décrivez dans les commentaires. Je serai également heureux s'il y a des modifications à la syntaxe et à la ponctuation.



Et merci pour l'attention que vous portez à ce numéro et à cet article en particulier!



All Articles