Como uma API .NET Processa suas Requisições
Estou escrevendo este artigo porque, apesar de parecer algo básico, muitas pessoas ainda não sabem como o .NET processa requisições. Eu mesmo já fui uma dessas pessoas 😅. Recentemente, porém, li um artigo que esclareceu bastante esse assunto. Hoje, quero compartilhar com vocês o conhecimento que adquiri.
Parte 1: O que é uma Thread?
Uma thread é uma unidade dentro de um processo que executa tarefas desse processo.
Ainda está confuso? Vamos simplificar.
Um processo é um programa em execução. Uma thread é como uma mesa dentro de um escritório, onde uma tarefa é executada. Imagine que você está no escritório da sua empresa programando uma funcionalidade. Você e seus colegas são as threads, e a empresa é o processo.
Assim como você e seus colegas podem se comunicar e trocar informações, as threads também podem interagir entre si, mas não podem interagir com threads de outros processos.
No entanto, assim como é caro contratar novos funcionários em uma empresa, é caro para o sistema operacional (em termos de recursos computacionais) criar novas threads. Portanto, não podemos simplesmente criar threads infinitamente para processar mais dados. Eventualmente, nossa aplicação pode falhar devido à falta de CPU ou memória.
Parte 2: Como Podemos Processar Requisições HTTP
Agora que já entendemos o que são threads, vamos pensar em como podemos processar requisições da nossa API.
Opção 1 - Uma Thread para Processar Todas as Requisições
Já que criar threads é caro, vamos deixar uma única thread para processar as requisições, certo?
Errado. Se nossa API for bem simples e receber pouquíssimas requisições, isso pode até funcionar, mas não é o comum. As APIs podem receber dezenas, centenas ou até milhares de requisições ao mesmo tempo. Se tivermos apenas uma thread para processar todas essas requisições, o tempo de resposta vai aumentar drasticamente 🚀, enquanto isso a nossa CPU estará subutilizada, talvez com apenas 1% de uso. Mas por quê?
Porque só temos uma thread processando nossas requisições, ou seja, nossa CPU está fazendo um trabalho muito abaixo da sua capacidade.
Opção 2 - Uma Thread para Cada Requisição
Ok, já que a CPU tem desempenho sobrando, vamos criar mais threads. Agora, para cada requisição, criamos uma thread para processá-la e, ao finalizar, deletamos essa thread.
Se 10 requisições chegarem ao mesmo tempo, podemos processá-las simultaneamente e retornar as respostas muito mais rápido para nossos clientes. Que maravilha!
Mas... agora nosso sistema cresceu e temos 10 mil requisições sendo feitas ao mesmo tempo. Isso significa que temos 10 mil threads, e nossa CPU e memória estão extremamente sobrecarregadas 😢. Se a nossa API não caiu, ela está bem perto disso. Obviamente, a quantidade de threads que podemos criar varia de acordo com nosso hardware, mas deu para entender que não podemos criar threads o tempo todo, certo?
Solução: Threadpools
Então, se não podemos criar threads indefinidamente, como vamos processar várias requisições? A resposta é simples: threadpools.
Parte 3: Threadpool
Voltando ao exemplo da empresa, como ela funciona? Existe um funcionário para fazer todo o trabalho? Não. Quando a empresa tem mais demanda, ela contrata um funcionário e depois o demite? Também não. A empresa mantém um número fixo de funcionários, por exemplo, 8. Pode ser que em alguns dias esses 8 funcionários estejam mais atarefados, e em outros dias menos, mas a empresa não fica contratando e demitindo funcionários a todo momento.
Assim também funciona a threadpool. Ela é basicamente um conjunto de threads que ficam esperando para receber tarefas. A threadpool possui parâmetros que definem o mínimo e o máximo de threads que o nosso processo pode ter.
A quantidade de threads não é fixa; ela varia conforme necessário. Se for preciso processar mais requisições, novas threads serão criadas, e se não for necessário ter tantas threads, algumas serão removidas. Esse processo ocorre em intervalos de tempo: a cada X tempo, o threadpool verifica se precisa de mais ou menos threads.
Todo esse processo de gerenciamento de threads é feito automaticamente pelo .NET.
Você pode pensar: "Ah, então eu li este artigo à toa? Se o .NET já faz isso sozinho, não preciso me preocupar com isso."
Não exatamente. Conhecendo esses conceitos, podemos criar nossas APIs de forma mais eficiente. Mas já que chegamos até aqui, vamos falar de mais uma coisinha: código síncrono e assíncrono.
Parte 4: Código Síncrono e Assíncrono
Imagine uma API que utiliza uma threadpool por baixo dos panos, com um mínimo de 1 thread e um máximo de 10 threads.
Momento 1: Recebendo 5 Requisições
Estamos recebendo 5 requisições simultâneas constantemente. Temos 5 threads criadas, então, por enquanto, está tudo tranquilo.
Momento 2: Recebendo 20 Requisições
Agora recebemos 20 requisições, nosso threadpool olha para a situação e pensa, "Tem 20 requisições vou criar 20 threads aqui, ahhh so posso criar 10, então vai isso mesmo, essas outras 10 requisições esperam os threads acabarem de processar pra esperar a vez deles.". Algumas requisições tem que esperar para serem processadas, mas tudo bem, não da pra processar tudo ao mesmo tempo.
Momento 3: Recebendo 100 Requisições
Agora temos um problema: recebemos 100 requisições. Muitas requisições estão aguardando, e o tempo de espera aumenta significativamente. Além disso, com tantas requisições na fila, há um risco de que a fila fique cheia e retornemos um erro 503 (Serviço Indisponível).
Código Síncrono
Quando escrevemos código síncrono, ele se comporta conforme descrito acima: a thread recebe a requisição e a processa completamente antes de ser liberada para processar outra requisição.
Código Assíncrono
O código assíncrono vem para nos ajudar de forma inteligente. Pense em uma requisição que cria um usuário. Recebemos os dados do usuário, tratamos esses dados e, em seguida, salvamos no banco de dados. A tarefa de salvar no banco pode demorar, fazendo com que a thread fique parada esperando a resposta do banco antes de terminar o processamento. No exemplo do escritório, é como se você tivesse feito um PR e tivesse que esperar alguém analisar para poder corrigir ou começar outra tarefa.
O código assíncrono resolve esse problema. Quando uma thread inicia uma tarefa que vai demorar, ela pode deixar essa tarefa de lado e processar outra requisição enquanto espera. Quando a tarefa longa termina, ela entra na fila e espera uma thread ficar livre para finalizá-la. Com isso, nossa API consegue processar muito mais requisições.
Conclusão
Por hoje é isso! Espero que minha explicação tenha sido clara e divertida. Se alguma parte não ficou clara, podem comentar que eu vou buscar responder vocês. E antes que eu me esqueça, abaixo tem um link com uma explicação mais técnica sobre o assunto:
Até a próxima!