Blog Post

Desenvolvedores BR
6 MIN READ

Como melhorar a performance de uma Web Api ASP.NET Core usando async/await

ClaudioGodoy's avatar
ClaudioGodoy
Icon for Microsoft rankMicrosoft
Mar 14, 2022

Neste artigo iremos entender a diferença entre as requisições síncronas e assíncronas, e qual o impacto na performance de uma aplicação ASP.NET Core.

 

Pré-requisitos

Para acompanhar os exemplos desse artigo, você vai precisar instalar os seguintes softwares/componentes:

 

Solução

A solução do repositório contém 2 projetos que utilizaremos para analisar o comportamento de APIs síncronas e assíncronas.

  • Web API: Aplicação que implementa a mesma API de forma síncrona e assíncrona.
  • Console: Programa para executar um teste de carga simples nas APIs.

 

Web API

O primeiro endpoint é síncrono e onde forçamos um atraso de 150ms através do método System.Thread.Sleep, que suspende (bloqueia) a thread que está processando a requisição.

app.MapGet("/weatherforecast-sync", () =>
{
    Thread.Sleep(150);
    return Forecast();
});

Já o segundo é assíncrono, com o mesmo comportamento que o primeiro, a única diferença é neste usamos o modificador async na expressão lambda, e dentro dela utilizamos o operador await para chamar método Task.Delay.

app.MapGet("/weatherforecast-async", async () =>
{
    await Task.Delay(150);
    return Forecast();
});

 

Teste de carga

Clone o projeto do git:

git clone https://github.com/claudiogodoy99/async-vs-sync-labs.git

 

Na pasta raiz da solução, rode o comando a seguir para subir a Web API:

dotnet run -p .\SyncVsAsyncAPI\SyncVsAsyncAPI.csproj -c Release

 

Ainda na pasta raiz rode o comando:

dotnet run -p .\SyncVsAsync\SyncVsAsync.csproj -c Release

O último comando vai executar o programa que irá realizar o teste de carga, que pode levar alguns minutos para terminar. No fim da execução você vai ver um resultado semelhante ao da seguinte tabela:

 

Method IterationCount Mean Error StdDev
GetAsyncEndpoint 200 184.6 ms 3.36 ms 3.86 ms
GetSyncEndpoint 200 672.8 ms 65.20 ms 180.67 ms

 

Interpretando o resultado do teste

  • Method: Contém um laço que executa a chamada ao endpoint.
  • IterationCount: Quantidade de requisições concorrentes executadas pelo método.
  • Mean: Tempo gasto médio do método que realizou as 200 requisições concorrentes.

Observação: Ambos endpoints tem um tempo de resposta médio de 170 milissegundos quando não expostos ao teste de carga.

Conseguimos concluir que:

  • O método GetAsyncEndpoint terminou a carga de 200 requisições em 182 milissegundos.
  • O método GetSyncEndpoint terminou a carga de 200 requisições em 672 milissegundos.
  • O método GetSyncEndpoint demorou um tempo ~369,23% superior em relação ao GetAsyncEndpoint.

 

Explicação conceitual dos resultados

Para analisar essa diferença gritante entre os tempos de execução durante o teste de carga, precisamos entender os conceitos descritos nessa seção.

 

Como o ASP.NET Core processa uma requisição HTTP

Toda aplicação Web em .NET foi desenhada para trabalhar com o conceito de multithreading.

Quando o servidor Web (seja HTTP.sys ou Kestrel) encaminha novas requisições para o código .NET, ela será processada por uma worker thread do threadpool. No caso do MVC, essa requisição passa pelos middlewares e pelo roteamento, que identificará qual é o controlador correspondente desta requisição. A partir deste ponto o container de injeção de dependências irá instanciar esse controlador e todas as suas dependências. Se o método do controlador for assíncrono, então ele o chama de forma assíncrona via async/await, caso contrário a requisição vai ser processada de forma síncrona.

 

Elasticidade do ThreadPool

Acredito que é muito interessante termos uma base de entendimento nesses itens, para ter maior clareza nos tópicos subsequentes, portanto esse parágrafo é inteiramente dedicado para explicar o funcionamento do threadpool.

Criar e destruir threads são tarefas relativamente caras em termos computacionais, e podem dar espaço para bugs e efeitos negativos na aplicação dependendo de sua estratégia de implementação. Manter muitas threads em memória também não é interessante porque:

  • Em muitos momentos ocorrerá um uso ineficiente da memória RAM consumindo mais espaço que o necessário.
  • Pode causar uma perda de desempenho, pois o S.O. terá mais threads para agendar e, consequentemente, aumenta a quantidade de chaveamento de contextos.

Para resolver esses e outros aspectos, o CLR (Common Language Runtime) conta com um recurso chamado ThreadPool, que ele mesmo gerencia. Esse ThreadPool fornece uma abstração para as aplicações usarem threads de uma forma mais eficiente e sem toda a complexidade para gerenciar esses threads. No .NET você pode manipular o ThreadPool através da classe System.Threading.ThreadPool.

O ThreadPool conta internamente com uma fila chamada GlobalThreadPoolQueue que é uma estrutura de dados linear FIFO (First In First Out).

Quando sua aplicação precisa realizar alguma operação assíncrona, basta adicionar um item nesta fila, e a operação será executada por alguma thread do ThreadPool. Assim você não precisa criar e gerenciar o ciclo de vida das suas threads. No .NET não acessamos essa GlobalThreadPoolQueue diretamente, porém podemos enfileirar itens nela através das várias abordagens, entre elas:

Se não tiver nenhuma thread disponível para executar a operação que sua aplicação enfileirou, o threadpool conta com algoritmo de criação de novas threads, cujo toma algumas decisões com base nas propriedades MinThreads e MaxTheads da classe System.Threading.Threadpool, sendo acessíveis através do respectivos métodos GetMinThreads e GetMaxThreads. Esse algoritmo em um alto nível toma as seguintes decisões:

  • Caso o número atual de threads seja menor que a propriedade MinThreads, o threadpool cria uma thread para tratar a operação da fila.
  • Caso o número atual de threads seja maior que a propriedade MinThreads e menor que a propriedade MaxThreads, o threadpool então executa um algoritmo de “espera”, ou seja, ele não cria nenhuma thread por um tempo determinado (provavelmente alguns milissegundos), se após esse tempo nenhuma thread ficou disponível para processar a operação da fila, o threadpool criará uma nova thread.
  • Caso o número atual de threads seja igual à propriedade MaxThreads, sua operação ficará aguardando na fila até que alguma thread seja liberada, e o threadpool não criará mais nenhuma thread.

 

Requisições síncronas

Quando um número de requisições concorrentes chega no servidor e ultrapassa o número de threads disponíveis no thread pool, essas requisições vão ter que esperar na fila até que alguma thread do thread pool consiga processa-la.

Vale lembrar que essa fila de espera tem um limite, ao ultrapassar esse limite as novas requisições respostas contendo erro de serviço indisponível. E se um item ficar muito tempo nessa fila, ela retorna um erro de timeout.

No modelo síncrono, se uma dessas threads estiver esperando por uma tarefa de longa duração como uma chamada ao banco de dados, ou uma requisição HTTP, essa thread vai ficar bloqueada até que essa tarefa seja completada.

Ou seja, nesse modelo, em um cenário de alta demanda de requisições, a performance da minha aplicação possivelmente vai ser seriamente afetada, mesmo que o consumo de CPU seja extremamente baixo, simplesmente porque minhas threads ficam bloqueadas sem realizar nenhum processamento, e as novas requisições subjacentes vão ficar esperando na fila até que alguma thread seja liberada ou criada pelo threadpool.

 

Requisições assíncronas

A thread responsável por processar a requisição não é bloqueada quando executa alguma tarefa de longa duração (I/O, banco de dados, HTTP, etc). No momento em que essa thread precisar executar alguma tarefa deste tipo, a thread NÃO fica bloqueada esperando o seu resultado, portanto a thread retorna ao thread pool e pode processar alguma outra requisição da fila. No momento em que a tarefa for concluída, essa requisição é reagendada na fila do thread pool e alguma outra thread irá continuar o processamento.

 

Conclusão

O uso dos endpoints assíncronos faz com que a nossa aplicação consiga lidar com um número demasiadamente superior de requisições concorrentes, sem precisar criar muitas threads para essa tarefa, pois com isso evitamos o bloqueamento de threads do thread pool.

Updated Mar 14, 2022
Version 3.0
  • raycarneiro's avatar
    raycarneiro
    Copper Contributor

    Muito bom o artigo! Concordo com o colega acima, se pudermos discutir sobre gargalos decorrentes dessa prática, seria interessante, também já vi muito banco de dados não aguentar o volume de requests assíncronas nas APIs.

  • Muito legal, ClaudioGodoy, tenho visto com certa frequência o uso dessa estratégia, principalmente em APIs HTTP, um efeito colateral muito comum depois do uso dessa abordagem é aumento de problemas em recursos como banco de dados relacionais. Talvez uma boa pedida para um futuro post seja discutirmos os gargalos mais comuns que encontramos nas aplicações.