Você deve usar o suporte assíncrono do Django?

Nov 28 2022
Como eu, você deve ter considerado o uso do Django se desejasse uma estrutura completa para desenvolvimento rápido da Web. Infelizmente, o Django não é celebrado pelo desempenho — talvez por causa de seu serviço síncrono padrão (Sync).
Uma píton lidando com vários pêssegos ao mesmo tempo! (fonte: https://labs.openai.com/)

Como eu, você deve ter considerado o uso do Django se desejasse uma estrutura completa para desenvolvimento rápido da Web.

Infelizmente, o Django não é celebrado pelo desempenho — talvez por causa de seu serviço síncrono padrão (Sync). Isto é o que a introdução percorre (https://docs.djangoproject.com/en/4.1/intro/tutorial01/).

A sincronização tem um desempenho ruim porque um encadeamento do servidor só pode atender a uma solicitação por vez. É importante ressaltar que as solicitações de E/S bloqueiam o thread. A taxa de transferência pode ser aumentada adicionando threads, mas os threads do servidor Python ocupam quase a mesma quantidade de RAM que um processo do servidor Python. Isso torna inviável aumentar a taxa de transferência adicionando threads.

O design assíncrono (Async) foi adicionado no Django 3.0 (https://docs.djangoproject.com/en/3.0/topics/async/), e não é mencionado na introdução. É mencionado como o último tópico no uso do Django (https://docs.djangoproject.com/en/4.1/topics/).

O Async pode superar o Sync, pois um único thread do servidor Async pode atender a várias solicitações ao mesmo tempo. Durante as solicitações de E/S aguardáveis, o await retorna o controle ao thread e permite que outras solicitações continuem. É semelhante ao loop de eventos do Javascript, mas não exatamente (https://stackoverflow.com/questions/68139555/difference-between-async-await-in-python-vs-javascript).

Então isso levantou as questões:

  • Quanto ganho de desempenho o Django pode obter usando uma configuração assíncrona?
  • Importa o que a rota/endpoint está fazendo (CPU-heavy vs IO-heavy)?
  • E se houver uma latência baixa (o servidor está próximo) ou alta latência (o servidor está longe)?

Então, fiz um experimento de teste de carga. Você pode encontrar o código e detalhes aquihttps://github.com/nalkhish/Asynchronize-Django. Resumidamente, foi isso que investiguei:

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 foi usado para ativar 1 Sync worker (https://docs.gunicorn.org/en/stable/design.html).
  • Para Async Limited, Gunicorn foi usado para criar 1 trabalhador Uvicorn (https://www.uvicorn.org/deployment/)
  • Para Async Unlimited, Gunicorn foi usado para criar 1 trabalhador Uvicorn (https://www.uvicorn.org/deployment/), mas o postgres foi configurado para permitir 1000 conexões simultâneas (mais do que o padrão 100).

Foram utilizadas 3 rotas:

  • cpu — indicando que a rota é pesada em 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

Onde <container_name> é o nome do contêiner

Carregar emulação

A carga foi emulada usando a ferramenta de código aberto Locust (https://locust.io/). Locust bombardeia as portas com solicitações. Assim que um 'usuário' termina sua tarefa, ela começa novamente.

A carga total durou 3 minutos:

  • Começou com 0 usuários.
  • Nos primeiros 100 segundos, aumentando a uma taxa de 5 usuários por segundo para limitar a 500 usuários simultâneos.
  • Nos últimos 80 segundos, os usuários foram mantidos em 500.

Descobertas:

Definições:

  • Max sRPS: o máximo de solicitações bem-sucedidas por segundo foi obtido tomando a diferença máxima entre as solicitações gerais e as solicitações com falha para todos os gráficos do relatório (consulte as figuras complementares no repo ).
  • Total de sucessos: o total de solicitações bem-sucedidas foi obtido subtraindo #solicitações com falha de #total de solicitações (consulte os valores suplementares no repo ).

Tabela 1: Limite da CPU. Max sRPS e sucessos gerais para combinações de configuração (Sync/Async Limited/Async Unlimited) e Latência (0ms, 100ms, 200ms). Async realmente faz mal.

Async limitado vs Async ilimitado:

  • Por que o Async ilimitado se saiu melhor do que o Async limitado? Async ilimitado permite mais conexões de banco de dados, mas a rota da CPU e a configuração do middleware não usam o banco de dados. Isso precisa ser mais investigado.
  • Em qualquer caso, ambas as configurações Async têm dinâmicas imprevisíveis (consulte as figuras complementares no repohttps://github.com/nalkhish/Asynchronize-Django).
  • A sincronização tinha um sRPS máximo inferior ao Async ilimitado. Provavelmente, isso ocorre porque os servidores assíncronos são capazes de lidar com várias solicitações ao mesmo tempo e, portanto, várias solicitações foram concluídas ao mesmo tempo. Isso é surpreendente porque o asyncio supostamente não troca de contexto, a menos que atinja uma instrução await, que não existe na rota da cpu. Isso precisa ser mais investigado.
  • O Sync tinha uma dinâmica previsível e um sucesso geral mais alto do que o Async. Isso é suficiente para garantir o uso do Sync para serviços vinculados à CPU.

Tabela 2: Leitura de banco de dados limitada por E/S. Max sRPS e sucessos gerais para combinações de servidor (assíncrono/sincronizado) e latência (0ms, 100ms, 200ms). Há uma taxa de transferência mais alta em altas latências ao usar Async em vez de Sync.

Tabela 3: Criação de banco de dados com limite de E/S. Max sRPS e sucessos gerais para combinações de servidor (assíncrono/sincronizado) e latência (0ms, 100ms, 200ms). Há uma taxa de transferência mais alta em altas latências ao usar o Async.

Async Limited vs Async Unlimited: Async Unlimited teve maior sRPS máximo e sucessos gerais do que Async Limited.

  • Para a rota de leitura vinculada a IO, isso provavelmente pode ser atribuído ao banco de dados ser um gargalo, pois estava falhando.
  • Para a rota de criação vinculada a IO, isso precisa ser investigado mais a fundo, pois o banco de dados não estava falhando para Async Limited (consulte a figura complementar)
  • Para as rotas io_read e io_create, o Sync teve um desempenho muito inferior ao Async (para uma latência adicional de 200ms, a diferença na taxa de transferência geral foi de 40 vezes para io_read e 230 vezes para io_create).
  • Isso provavelmente ocorre porque o encadeamento de trabalho do servidor estava aguardando a conclusão das solicitações do banco de dados antes de poder manipular a próxima solicitação. Essa teoria é suportada pela relação inversa entre latência e sRPS máximo e sucessos gerais para sincronização.

As limitações são mais exploradas no repo readmehttps://github.com/nalkhish/Asynchronize-Django(a pouca informação discutida aqui, a ausência de lógica de repetição e a ausência de latência de entrada), mas este estudo explora suficientemente a capacidade de serviço Django assíncrono e síncrono.

Se você usa Django e tem cargas vinculadas à CPU, use Sync. Caso contrário, se as cargas forem limitadas por E/S, use Async. Provavelmente será mais de 10x a taxa de transferência do trabalhador.