¿Deberías usar el soporte asíncrono de Django?

Nov 28 2022
Al igual que yo, podría haber considerado usar Django si deseaba un marco con todas las funciones para un desarrollo web rápido. Desafortunadamente, Django no es famoso por su rendimiento, tal vez debido a su servicio síncrono predeterminado (Sync).
¡Una pitón que maneja varios melocotones al mismo tiempo! (fuente: https://labs.openai.com/)

Al igual que yo, podría haber considerado usar Django si deseaba un marco con todas las funciones para un desarrollo web rápido.

Desafortunadamente, Django no es famoso por su rendimiento, tal vez debido a su servicio síncrono predeterminado (Sync). Esto es lo que recorre la introducción (https://docs.djangoproject.com/en/4.1/intro/tutorial01/).

La sincronización funciona mal porque un subproceso del servidor solo puede atender una solicitud a la vez. Es importante destacar que las solicitudes de E/S bloquean el subproceso. El rendimiento se puede aumentar agregando subprocesos, pero los subprocesos del servidor de Python requieren casi la misma cantidad de RAM que un proceso del servidor de Python. Esto hace que no sea factible aumentar el rendimiento mediante la adición de subprocesos.

El diseño asíncrono (Async) se agregó en Django 3.0 (https://docs.djangoproject.com/en/3.0/topics/async/), y no se menciona en la introducción. Se menciona como el último tema en el uso de Django (https://docs.djangoproject.com/en/4.1/topics/).

Async puede superar a Sync ya que un solo subproceso de servidor Async puede atender varias solicitudes a la vez. Durante las solicitudes de E/S en espera, await devuelve el control al subproceso y permite que continúen otras solicitudes. Es similar al bucle de eventos de Javascript, pero no del todo (https://stackoverflow.com/questions/68139555/difference-between-async-await-in-python-vs-javascript).

Entonces eso planteó las preguntas:

  • ¿Cuánta ganancia de rendimiento puede obtener Django usando una configuración asíncrona?
  • ¿Importa lo que esté haciendo la ruta o el punto final (con mucha CPU o con mucha E/S)?
  • ¿Qué sucede si hay una latencia baja (el servidor está cerca) o una latencia alta (el servidor está lejos)?

Así que realicé un experimento de prueba de carga. Puedes encontrar el código y los detalles aquíhttps://github.com/nalkhish/Asynchronize-Django. Brevemente, esto es lo que investigué:

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

  • Para Sync, Gunicorn se utilizó para activar 1 trabajador de sincronización (https://docs.gunicorn.org/en/stable/design.html).
  • Para Async Limited, Gunicorn se utilizó para activar 1 trabajador de Uvicorn (https://www.uvicorn.org/deployment/)
  • Para Async Unlimited, Gunicorn se usó para activar 1 trabajador de Uvicorn (https://www.uvicorn.org/deployment/), pero postgres se configuró para permitir 1000 conexiones simultáneas (más de las 100 predeterminadas).

Se utilizaron 3 rutas:

  • cpu: indica que la ruta tiene mucha 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

Donde <container_name> es el nombre del contenedor

Emulación de carga

La carga se emuló utilizando la herramienta de código abierto Locust (https://locust.io/). Locust bombardea los puertos con solicitudes. Tan pronto como un 'usuario' termina su tarea, comienza de nuevo.

La carga total duró 3 minutos:

  • Comenzó con 0 usuarios.
  • Durante los primeros 100 segundos, aumentando a una tasa de 5 usuarios por segundo hasta un límite de 500 usuarios simultáneos.
  • Durante los últimos 80 segundos, los usuarios se mantuvieron en 500.

Recomendaciones:

Definiciones:

  • Max sRPS: el máximo de solicitudes exitosas por segundo se obtuvo tomando la diferencia máxima entre las solicitudes generales y las solicitudes fallidas para los gráficos completos del informe (consulte las cifras complementarias en el informe ) .
  • Éxitos totales: las solicitudes exitosas generales se obtuvieron restando las solicitudes fallidas del total de solicitudes (consulte las cifras complementarias en el informe ).

Tabla 1: vinculado a la CPU. Máximo de sRPS y éxitos generales para combinaciones de configuración (sincrónica/asincrónica limitada/asincrónica ilimitada) y latencia (0ms, 100ms, 200ms). Async en realidad lo hace mal.

Asíncrono limitado vs Asíncrono ilimitado:

  • ¿Por qué Async Unlimited funcionó mejor que Async Limited? Async Unlimited permite más conexiones a la base de datos, pero la ruta de la CPU y la configuración del middleware no utilizan la base de datos. Esto necesita ser investigado más a fondo.
  • En cualquier caso, ambas configuraciones asíncronas tienen una dinámica impredecible (consulte las cifras complementarias en el repositoriohttps://github.com/nalkhish/Asynchronize-Django).
  • Sync tenía un sRPS máximo más bajo que Async Unlimited. Esto probablemente se deba a que los servidores asíncronos pueden manejar varias solicitudes a la vez y, por lo tanto, varias solicitudes terminaron al mismo tiempo. Esto es sorprendente porque supuestamente asyncio no cambia de contexto a menos que llegue a una declaración de espera, que no existe en la ruta de la CPU. Esto necesita ser investigado más a fondo.
  • Sync tenía una dinámica predecible y tuvo mayores éxitos generales que Async. Esto es suficiente para garantizar el uso de Sync para servicios vinculados a la CPU.

Tabla 2: lectura de base de datos vinculada a E/S. Máximo de sRPS y éxitos generales para combinaciones de servidor (Async/Sync) y latencia (0ms, 100ms, 200ms). Hay un mayor rendimiento en latencias altas cuando se usa Async en lugar de Sync.

Tabla 3: creación de base de datos vinculada a E/S. Máximo de sRPS y éxitos generales para combinaciones de servidor (Async/Sync) y latencia (0ms, 100ms, 200ms). Hay un mayor rendimiento en latencias altas cuando se usa Async.

Async limitada frente a Async ilimitada: Async ilimitada tuvo un sRPS máximo más alto y éxitos generales que Async limitada.

  • Para la ruta de lectura enlazada a E/S, esto probablemente se puede atribuir a que la base de datos es un cuello de botella porque estaba fallando.
  • Para la ruta de creación vinculada a IO, esto debe investigarse más a fondo ya que la base de datos no estaba fallando para Async limitada (consulte la figura complementaria)
  • Tanto para las rutas io_read como para las rutas io_create, Sync tuvo un rendimiento mucho más bajo que Async (para una latencia adicional de 200 ms, la diferencia en el rendimiento general fue 40 veces mayor para io_read y 230 veces mayor para io_create).
  • Es probable que esto se deba a que el subproceso de trabajo del servidor estaba esperando que finalizaran las solicitudes de la base de datos antes de poder manejar la siguiente solicitud. Esta teoría está respaldada por la relación inversa entre la latencia y el sRPS máximo y los éxitos generales de Sync.

Las limitaciones se exploran más en el archivo Léame del repositorio.https://github.com/nalkhish/Asynchronize-Django(la poca información discutida aquí, la ausencia de lógica de reintento y la ausencia de latencia de entrada), pero este estudio explora suficientemente la capacidad de servir Django asíncrono y síncrono.

Si usa Django y tiene cargas vinculadas a la CPU, use Sync. De lo contrario, si las cargas están vinculadas a E/S, use Async. Es probable que aumente más de 10 veces el rendimiento de sus trabajadores.