Django Sync vs Async
Mieux comprendre pour faire le bon choix !
Table of Contents
Depuis la version 3.0, Django supporte les vues asynchrones lorsqu'il est déployé via un serveur ASGI comme Uvicorn. Officiellement, les deux modes peuvent cohabiter dans une même application, mais il y a quelques pièges à éviter.
Fonctionnement synchrone (WSGI)
Lorsque vous déployez Django via WSGI, probablement en utilisant Gunicorn, vous avez un certain nombre de workers (processus) qui traitent vos requêtes. Dans la configuration par défaut de Gunicorn (qui utilise des workers synchrones), le calcul est assez simple: à un instant donné, votre application pourra traiter au maximum N requêtes en parallèle, N étant le nombre de workers.
L'avantage principal étant que chaque requête est traitée dans un processus différent, il n'y a donc aucune contention au niveau du GIL de Python: c'est idéal pour les charge qui sont CPU-intensives, comme la génération de fichiers PDF par exemple.
L'inconvénient, c'est que dans le cas où vos vues mettent du temps à répondre, par exemple car vous faites des appels HTTP à une API externe, vous risquez de vous retrouver sans aucun worker disponible pour traiter les requêtes entrantes.
Il faut donc impérativement respecter les bonnes pratiques: toujours mettre un timeout (plutôt court) lorsque l'on fait des requêtes vers des APIs externes, afin d'éviter un effet boule de neige. Si un service tier se met à ralentir, et que les temps de réponse de son API passent à plusieurs secondes au lieu de quelques millisecondes, il y a un vrai risque de saturer vos workers, rendant votre service inutilisable à son tour.
On peut mitiger ce problème de plusieurs façons:
- En ajoutant des workers supplémentaires. On parle de scaling horizontal. C'est une solution simple mais qui peut vite devenir couteuse 🤑 en fonction de votre application. On parle d'auto-scaling si c'est fait automatiquement (en fonction de la charge, par exemple du nombre de requêtes/s reçues).
- En déportant les traitements lents en dehors des vues, par exemple avec Celery, Dramatiq, ou Django Q2. L'inconvénient étant que votre vue ne pourra plus retourner directement le résultat. Il faudra que votre application poll, ou qu'elle soit notifiée (WebSocket, SSE) pour récupérer le résultat du traitement ultérieurement. C'est une solution efficace mais plus complexe à mettre en place.
On privilégiera ce mode de déploiement pour des applications avec une charge plutôt CPU-intensive. Pour les nouveaux projets, il faut vraiment se poser la question, car si votre application fait majoritairement de l'I/O (Base de donnée, appels HTTP, etc) le mode asynchrone est probablement plus adapté.
Fonctionnement asynchrone (ASGI)
Le paradigme asynchrone (via asyncio) est arrivé récemment dans Python, mais existe depuis bien plus longtemps dans d'autres langages comme JavaScript. C'est d'ailleurs ce qui a fait (en partie) le succès de NodeJS.
Contrairement au mode synchrone, il n'y qu'un seul processus et un seul thread dans lequel s'exécute un event loop (boucle d'évènement). Le principe de base est que chaque fonction qui fait de l'I/O rend la main à l'event loop pour qu'il puisse traiter d'autres requêtes le temps que les opérations d'I/O soient terminées.
Pour profiter pleinement de ce paradigme, il faut lancer Django via un serveur ASGI, la référence étant Uvicorn.
Prenons un exemple concret :
import httpx
from django.http import JsonResponse
async def get_payments(request):
async with httpx.AsyncClient() as client:
# A partir du moment où la requête est en cours, l'event loop
# reprend la main et traite d'autres requêtes
response = await client.get('https://prestataire.fr/payments/')
# Une fois que la réponse est arrivée, l'event loop
# reprend l'exécution de cette fonction
return JsonResponse(response["payments"])Pour que ce mode fonctionne correctement, il ne faut jamais bloquer l'event loop.
C'est à dire qu'il ne faut jamais exécuter du code synchrone trop longtemps. Par exemple si on enrichi ce code et qu'on génère un PDF à partir des paiements pour l'envoyer à l'utilisateur :
import httpx
from django.http import FileResponse
async def get_payments_pdf(request):
async with httpx.AsyncClient() as client:
response = await client.get('https://prestataire.fr/payments/')
# La génération du PDF prend du temps et bloque l'event loop
pdf = generate_pdf(response["payments"])
return FileResponse(pdf.read(), as_attachment=True, filename="hello.pdf")Ne jamais faire ça dans la vrai vie.
Dans ce cas précis, pendant toute la durée de la génération du PDF, le serveur ne traite aucune autre requête, l'event loop ne pouvant pas s'exécuter (car le seul et unique thread est entrain de générer le PDF).
Lorsque l'on utilise le mode asynchrone correctement, on peut traiter des milliers de requêtes en parallèle sans aucun soucis, avec un seul processus, mais il suffit d'appeler une seule fois une fonction synchrone qui va bloquer l'event loop pour anéantir toutes les performances. C'est donc primordial de monitorer correctement votre production pour détecter ces problèmes le plus rapidement possible, par exemple avec aiodebug.
Mais du coup, comment faire pour générer ce PDF ?
La fonction sync_to_async
Django fourni deux fonctions pour "changer de mode". Dans notre cas c'est sync_to_async qui nous intéresse, puisqu'elle permet de rendre une fonction synchrone (comme celle qui génère notre PDF) asynchrone (c'est à dire awaitable).
Pour cela, Django exécute le code dans un thread dédié, qui est différent du thread de l'event loop. Pour illustrer ce mécanisme, j'ai écrit une petite vue et une fonction synchrone pour récupérer les identifiants des threads.
import threading
import time
from asgiref.sync import sync_to_async
from django.http import HttpRequest, JsonResponse
def _get_thread_id() -> int:
time.sleep(5)
return threading.current_thread().ident
async def get_thread_ids(request: HttpRequest):
return JsonResponse({
"view_thread_id": threading.current_thread().ident,
"first_sync_thread_id": await sync_to_async(_get_thread_id)(),
"second_sync_thread_id": await sync_to_async(_get_thread_id)()
})
J'ai lancé deux appels HTTP simultanés vers cette vue pour vérifier que l'event loop n'était pas bloqué, et voici les deux résultats :
APPEL #1:
{
"first_sync_to_async_thread_id": 127836502435392,
"second_sync_to_async_thread_id": 127836502435392,
"view_thread_id": 127836586202944
}
APPEL #2:
{
"first_sync_to_async_thread_id": 127836510828096,
"second_sync_to_async_thread_id": 127836510828096,
"view_thread_id": 127836586202944
}
On constate deux choses intéressantes :
- On voit bien que la vue est exécutée dans le même thread pour les deux requêtes reçues en même temps. C'est normal car c'est le thread de l'event loop.
- On voit aussi que les appels successifs à
sync_to_asyncréutilisent un thread spécifique à la requête en cours de traitement. Ce comportement peut être modifié en passantthread_sensitive=Falseen paramètre, ce qui aura pour effet d’exécuter la fonction dans un thread dédié.
Nous allons donc modifier notre exemple précédant pour utiliser sync_to_async:
import httpx
from django.http import FileResponse
async def get_payments_pdf(request):
async with httpx.AsyncClient() as client:
response = await client.get('https://prestataire.fr/payments/')
# La fonction generate_pdf() sera exécutée dans un thread séparé
pdf = await sync_to_async(generate_pdf)(response["payments"])
return FileResponse(pdf.read(), as_attachment=True, filename="hello.pdf")Cette solution fonctionne: l'event loop n'est plus bloqué. Cependant, si de prime abord ça semble résoudre notre problème, il n'en est rien en pratique.
Fausse bonne idée pour les fonctions CPU-intensives
Si on reçoit de nombreuses requêtes en même temps sur cette vue, il y aura alors de nombreux threads qui essayeront de générer des PDFs. A cause du GIL, les threads en Python ne sont pas réellement concurrents: l'interpréteur va simplement switcher d'un thread à l'autre à intervalle fixe (5ms par défaut, configurable via sys.setswitchinterval), mais rien n'est réellement exécuté en parralèle.
Cette solution ne va pas donc pas scale efficacement et risque de rendre l'ensemble de votre service indisponible. La seule solution pour éviter ce problème est de déporter l'exécution des fonctions CPU-intensives dans un processus externe, via Celery, un ProcessPoolExecutor ou n'importe quelle solution cloud.
Mais alors dans quel cas utiliser sync_to_async?
Et bien pour toutes les fonctions qui font de l'I/O, mais pas de manière asynchrone. Par exemple si une fonction utilise la bibliothèque requests (qui est synchrone) pour appeler une API, vous pouvez appeler cette fonction avec sync_to_async.
Il en va de même pour l'utilisation de l'ORM: Django utilise des connecteurs synchrones, et donc les appels à l'ORM sont bloquants car la connexion à la base de données est bloquante, mais comme c'est de l'I/O on peut utiliser sync_to_async.
Toutes les fonctions de Python qui font de l'I/O libèrent automatiquement le GIL, et il n'y a donc pas de problèmes de contention.
L'ORM de Django n'est pas (encore) asynchrone
On pourrait croire que l'ORM de Django est asynchrone mais ce n'est pas encore le cas, malgré le fait qu'il existe des versions asynchrones de la plupart des méthodes, par exemple aget() au lieu de get(), ou afirst() au lieu de first().
Si vous regardez l'implémentation de ces fonctions, il s'agit simplement d'appeler l'équivalent synchrone via sync_to_async(). Donc quand vous faites :
user = await User.objects.filter(username=my_input).afirst()En pratique User.objects.filter(username=my_input).first() sera exécuté dans un thread séparé de manière bloquante.
Pour que l'ORM devienne réellement asynchrone il faudra que Django supporte les drivers asynchrones comme asyncpg ou psycopg3, ce qui n'est pas encore le cas.
Autre détail important : les transactions ne sont pas supportées en mode asynchrone pour le moment (Django 5.0). Si vous utilisez les transactions (c'est probablement le cas) il faudra déporter tout le code dans une fonction décorée avec @sync_to_async.
Compatibilité
Les vues synchrones sur un serveur ASGI
Lorsque vous déployez Django via un serveur ASGI, Django fourni un mode de compatibilité qui vous permet de définir des vues synchrones.
Ces vues sont simplement exécutées via sync_to_async(), avec les limitations discutées précédemment. Il faudra donc éviter tout ce qui est consommateur de CPU.
Les vues asynchrones sur un serveur WSGI
A l'inverse, vous pouvez aussi déclarer des vues async quand vous déployez Django via WSGI. Dans ce cas, Django va utiliser async_to_sync et démarrer un event loop local à la vue. C'est comme utiliser asyncio.run() dans la vue.
Cette solution ne permet pas d'obtenir de meilleurs performances, mais peut avoir un intérêts pour paralléliser un grand nombre d'appels HTTP dans une vue (en utilisant asyncio.gather() par exemple).
Performances
D'une manière générale on souhaiter éviter ces couches de compatibilité qui ont un impact négatif sur les performances.
- Si vous déployez Django sur WSGI, privilégiez les vues synchrones.
- Si vous déployez Django sur ASGI, privilégiez les vues asynchrones.
On peut aussi limiter l'impact en essayant de regrouper le code synchrone dans une même fonction, qu'on appelle une fois avec sync_to_async plutôt que de découper en plusieurs fonctions. Par exemple pour effectuer des actions via l'ORM, ce qui aura le double avantage de nous permettre d'utiliser les transactions.
A noter aussi l'impact potentiel si vous utilisez des middlewares qui ne sont pas compatibles ASGI. Même si toutes vos vues sont asynchrones et que vous n'utilisez pas sync_to_async, Django devra quand même démarrer un thread par requête pour exécuter ce middleware (Voir la documentation de Django à ce sujet).
Conclusion
Django est un vieux Framework, qui est né avant l'arrivée d'asyncio. Alors forcément il est un peu en retard par rapport aux nouveaux arrivants comme FastAPI, Sanic ou Litestar qui sont 100% asynchrones, des vues jusqu'aux drivers de base de données.
Chaque nouvelle version améliore le support de l'async, et un jour on pourra bénéficier de tous les avantages de Django (notamment son ORM) en mode 100% asynchrone sans avoir recours à des artifices comme sync_to_async.
En attendant, la question se pose, et continuer à utiliser le WSGI n'est pas un problème, même si c'est moins à la mode. Il faut avoir conscience des limitations de chaque mode, et utiliser celui qui correspond le plus à votre application.
Mais dans un monde rempli d'APIs, il semble que tout de même que le paradigme asynchrone ait de beaux jours devant lui, avec ou sans Django.
