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.
Para acompanhar os exemplos desse artigo, você vai precisar instalar os seguintes softwares/componentes:
A solução do repositório contém 2 projetos que utilizaremos para analisar o comportamento de APIs síncronas e assíncronas.
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();
});
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 |
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:
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.
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.
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:
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:
MinThreads
, o threadpool cria uma thread para tratar a operação da fila.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.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.
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.
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.
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.