Devriez-vous utiliser le support asynchrone de Django ?

Nov 28 2022
Comme moi, vous auriez peut-être envisagé d'utiliser Django si vous vouliez un framework complet pour un développement Web rapide. Malheureusement, Django n'est pas célèbre pour ses performances - peut-être à cause de son service synchrone par défaut (Sync).
Un python manipulant plusieurs pêches en même temps ! (source : https://labs.openai.com/)

Comme moi, vous auriez peut-être envisagé d'utiliser Django si vous vouliez un framework complet pour un développement Web rapide.

Malheureusement, Django n'est pas célèbre pour ses performances - peut-être à cause de son service synchrone par défaut (Sync). C'est ce que parcourt l'introduction (https://docs.djangoproject.com/en/4.1/intro/tutorial01/).

La synchronisation fonctionne mal car un thread de serveur ne peut traiter qu'une seule demande à la fois. Il est important de noter que les requêtes d'E/S bloquent le thread. Le débit peut être augmenté en ajoutant des threads, mais les threads de serveur Python utilisent presque la même quantité de RAM qu'un processus de serveur Python. Cela rend impossible l'augmentation du débit en ajoutant des threads.

La conception asynchrone (Async) a été ajoutée dans Django 3.0 (https://docs.djangoproject.com/en/3.0/topics/async/), et n'est pas mentionné dans l'introduction. Il est mentionné comme dernier sujet dans l'utilisation de Django (https://docs.djangoproject.com/en/4.1/topics/).

Async peut surpasser Sync car un seul thread de serveur Async peut traiter plusieurs requêtes à la fois. Pendant les requêtes d'E/S en attente, l'attente rend le contrôle au thread et permet aux autres requêtes de se poursuivre. C'est similaire à la boucle d'événement de Javascript, mais pas tout à fait (https://stackoverflow.com/questions/68139555/difference-between-async-await-in-python-vs-javascript).

Alors ça a soulevé des questions :

  • Combien de gains de performances Django peut-il obtenir en utilisant une configuration asynchrone ?
  • Est-ce important ce que fait la route/le point de terminaison (lourd CPU vs lourd IO) ?
  • Qu'en est-il s'il y a une latence faible (le serveur est à proximité) ou une latence élevée (le serveur est éloigné) ?

J'ai donc effectué une expérience de test de charge. Vous pouvez trouver le code et les détails icihttps://github.com/nalkhish/Asynchronize-Django. Bref, voici ce que j'ai enquêté :

For each setup (sync, async limited, async unlimited):
    For each request route (high cpu, high io create, high io read):
        For each added latency (200, 100, 0):
            Emulate load and generate report

  • Pour Sync, Gunicorn a été utilisé pour faire tourner 1 travailleur Sync (https://docs.gunicorn.org/en/stable/design.html).
  • Pour Async Limited, Gunicorn a été utilisé pour faire tourner 1 travailleur Uvicorn (https://www.uvicorn.org/deployment/)
  • Pour Async Unlimited, Gunicorn a été utilisé pour faire tourner 1 travailleur Uvicorn (https://www.uvicorn.org/deployment/), mais postgres a été configuré pour autoriser 1 000 connexions simultanées (plus que les 100 par défaut).

3 itinéraires ont été utilisés :

  • cpu — indique que la route est gourmande en CPU
  • def cpu(request: HttpRequest):
        """High cpu route
        CPU: High
        I/O: low
        """
        ct = 0
        for i in range(10**7):
            ct += i * i
        return JsonResponse({"ct": ct})
    

    # In sync
    def io_create(request: HttpRequest):
        """Create 1 post
        CPU: low
        I/O: High
        """
        new_post = Post.objects.create(
            title=request.POST["title"],
            content=request.POST["content"],
        )
        return JsonResponse(new_post.to_dict())
    
    # In async
    async def io_create(request: HttpRequest):
        """Create 1 post
        CPU: low
        I/O: High
        """
        new_post = await Post.objects.acreate(
            title=request.POST["title"],
            content=request.POST["content"],
        )
        return JsonResponse(new_post.to_dict())
    

    # In sync
    def io_read(request: HttpRequest):
        """Get first 25 posts
        CPU: low
        I/O: High
        """
        posts = Post.objects.all()[:25]
        ct = Post.objects.count()
        return JsonResponse(
            {
                "ct": ct,
                "posts": [post.to_dict() for post in posts],
            }
        )
    
    # In Async
    async def io_read(request: HttpRequest):
        """Get first 25 posts
        CPU: low
        I/O: High
        """
        posts = []
        async for p in Post.objects.all()[:25]:
            posts.append(p)
        ct = await Post.objects.acount()
        return JsonResponse(
            {
                "ct": ct,
                "posts": [post.to_dict() for post in posts],
            }
        )
    

docker exec <container_name> tc qdisc add dev eth0 root netem delay 100ms

Où <container_name> est le nom du conteneur

Charger l'émulation

Load a été émulé à l'aide de l'outil open source Locust (https://locust.io/). Locust bombarde les ports de demandes. Dès qu'un 'utilisateur' termine sa tâche, celle-ci recommence.

La charge totale a duré 3 minutes :

  • Commencé avec 0 utilisateurs.
  • Pendant les 100 premières secondes, augmentation à un rythme de 5 utilisateurs par seconde pour plafonner à 500 utilisateurs simultanés.
  • Pendant les 80 dernières secondes, les utilisateurs ont été maintenus à 500.

Résultats:

Définitions :

  • Max sRPS : le nombre maximal de demandes réussies par seconde a été obtenu en prenant la différence maximale entre les demandes globales et les demandes ayant échoué pour l'ensemble des graphiques de rapport (voir les chiffres supplémentaires dans le repo ).
  • Succès totaux : les demandes réussies globales ont été obtenues en soustrayant le nombre de demandes échouées du nombre total de demandes (voir les chiffres supplémentaires dans le repo ).

Tableau 1 : lié au processeur. Max sRPS et réussites globales pour les combinaisons de configuration (Sync/Async Limited/Async illimité) et de latence (0 ms, 100 ms, 200 ms). Async fonctionne mal.

Async limité vs Async illimité :

  • Pourquoi Async Unlimited a-t-il fait mieux qu'Async Limited ? Async illimité autorise davantage de connexions à la base de données, mais la configuration de la route CPU et du middleware n'utilise pas la base de données. Cela doit être étudié plus avant.
  • Dans tous les cas, les deux configurations Async ont une dynamique imprévisible (voir les chiffres supplémentaires sur repohttps://github.com/nalkhish/Asynchronize-Django).
  • Sync avait un sRPS max inférieur à Async illimité. C'est probablement parce que les serveurs asynchrones sont capables de gérer plusieurs requêtes à la fois et que plusieurs requêtes se sont donc terminées en même temps. Ceci est surprenant car asyncio est censé ne pas changer de contexte à moins qu'il n'atteigne une instruction await, qui n'existe pas dans la route cpu. Cela doit être étudié plus avant.
  • Sync avait une dynamique prévisible et avait des succès globaux plus élevés qu'Async. Cela est suffisant pour justifier l'utilisation de Sync pour les services liés au processeur.

Tableau 2 : lecture de la base de données liée aux E/S. Max sRPS et réussites globales pour les combinaisons de serveur (Async/Sync) et de latence (0 ms, 100 ms, 200 ms). Il y a un débit plus élevé à des latences élevées lors de l'utilisation d'Async au lieu de Sync.

Tableau 3 : Création de base de données liée aux E/S. Max sRPS et réussites globales pour les combinaisons de serveur (Async/Sync) et de latence (0 ms, 100 ms, 200 ms). Il y a un débit plus élevé à des latences élevées lors de l'utilisation d'Async.

Async limité vs Async illimité : Async illimité avait un sRPS max et des succès globaux plus élevés qu'Async limité.

  • Pour la route de lecture liée aux E/S, cela peut probablement être attribué au fait que la base de données était un goulot d'étranglement car elle échouait.
  • Pour la route de création liée aux E/S, cela doit être étudié plus en détail car la base de données n'échouait pas pour Async limited (voir la figure supplémentaire)
  • Pour les routes io_read et io_create, Sync avait des performances bien inférieures à Async (pour une latence supplémentaire de 200 ms, la différence de débit global était de 40 fois pour io_read et de 230 fois pour io_create).
  • Cela est probablement dû au fait que le thread de travail du serveur attendait la fin des requêtes de base de données avant de pouvoir traiter la requête suivante. Cette théorie est étayée par la relation inverse entre la latence et le sRPS max et les succès globaux pour Sync.

Les limitations sont explorées plus dans le readme du dépôthttps://github.com/nalkhish/Asynchronize-Django(le peu d'informations discutées ici, l'absence de logique de nouvelle tentative et l'absence de latence entrante), mais cette étude explore suffisamment la capacité du service Django asynchrone et synchrone.

Si vous utilisez Django et avez des charges liées au processeur, utilisez Sync. Sinon, si les charges sont liées aux E/S, utilisez Async. Cela multipliera probablement par 10 le débit de vos travailleurs.