Ir para o conteúdo

IO Bound: Saiba mais sobre o processo

4 min. de leitura

Avatar de Thiago Borba

Thiago Borba Autor


Desde a sua versão beta, o .NET suportou chamadas assíncronas, inclusive chamadas de IO Bound. Com a evolução do hardware e a chegada de processadores multicores, o .NET evoluiu e trouxe a TPL, que simplificou o uso de trabalhos de IO Bound.

Quem nunca ouviu alguma dessas frases?

“Sua aplicação deve ser assíncrona.”
“Se está lento, muda o método para async.”
“Todos os métodos da API devem ser assíncronos.”
“Usa async para tentar resolver o problema de lentidão.”
“Eu li num blog que aplicações modernas em C# devem ser async/await.”

No final de tudo, encontramos esse tipo de código na aplicação:

public static Task DoRequest(string url)
  => Task.Run(async () =>
     {
          await httpClient.GetStringAsync(requestUri: url);
          Console.WriteLine($"{DateTime.Now} - Task completed.");
     });

IO Bound & CPU Bound

Dentro da computação, IO Bound refere-se a cargas de trabalho de IO (disco, redes, dispositivos) e CPU Bound às cargas de trabalho de CPU (algoritmos, cálculos).

Microsoft Windows e IO Bound

Conceitualmente, toda a carga de trabalho de uma aplicação é executada por uma thread. Essa thread possui uma fatia de tempo (time slice) de uso do processador.

Agora, se aplicação faz o acesso a um IO, por que deve existir uma thread?

De fato, não faz sentido. Por esse motivo, o Windows, em sua natureza assíncrona, trata esse cenário de IO Bound. Toda chamada para o Windows, mesmo que síncrona, será tratada como assíncrona. Quando o .NET acessa um recurso de IO, ele cria uma IOCP (IO Completation Port) vinculada ao chamador.

O .NET utiliza as IOCP para gerenciar as solicitações de IO realizadas pela aplicação. Na sequencia, o .NET faz acesso a API Win32, cria um IRP (IO Request Packet) e adiciona-o na fila do dispositivo. O IRP faz parte da arquitetura do Windows e é utilizado para comunicação com dispositivos.

Nessa arquitetura, é utilizado uma fila IRP para o dispositivo, que retira o pacote da fila, processa o trabalho e devolve o resultado.

Observe que, nesse processo, não há thread envolvida. É puramente IO!

Imagem em preto e branco, demonstrando o processo de CLR via C#.
CLR via C# por Jeffrey Richter

Uma vez que o trabalho é concluído, o dispositivo faz uma chamada de interrupção de sistema e devolve o resultado.

Quando isso acontece, a API Win32 irá obter o resultado do trabalho e notificará o .NET através da IOCP criada na requisição. Nesse momento, o .NET irá obter uma thread do ThreadPool e devolverá o resultado para o chamador.

.NET e IO Bound

Uma vez que entendemos essa arquitetura, fica claro porque as chamadas assíncronas precisam ser “async all the way” e, principalmente, porque não há threads em IO Bound.

Quando olhamos novamente o código, entendemos porque ele não faz sentido.

public static Task DoRequest(string url)
  => Task.Run(async () =>
     {
          await httpClient.GetStringAsync(requestUri: url);
          Console.WriteLine($"{DateTime.Now} - Task completed.");
     });

Como essa chamada é síncrona, o httpclient manterá a thread bloqueada até finalizar o request, ou seja, no código estamos alocando uma Task e sua estrutura de máquina de estado para aguardar uma thread que sabemos que ficará bloqueada.

Neste caso, onde a chamada é síncrona, e há somente métodos assíncronos disponíveis na API, então usamos .Result.

public static void DoRequest(string url)
{
    httpClient.GetStringAsync(requestUri: url).Result;
    Console.WriteLine($"{DateTime.Now} - Task completed.");
};

Para utilizar chamadas assíncronas em .NET é necessário que:

  • A chamada seja “async all the way”;
  • Tenha lib de acesso ao recurso implemente “async all the way”

Se qualquer uma dessas condições não for atendida, o seu código estará bloqueando uma thread para cargas de trabalho IO Bound.

Demostração do Impacto do IO Bound

Para materializar a importância do IO Bound na aplicação e o seu impacto, vamos analisar a PoC abaixo.

  • TimeApi
    O TimeApi é uma API Rest em .NET Core que possui um método GET que retorna um DateTime atual. Antes de retornar o DateTime ele uso um delay aleatório de 10s até 20s a fim de simular uma API com problemas de performance.
  • Portal
    O Portal é uma aplicação ASPNET Core que possui 3 actions, sendo a Index (faz uma chamada síncrona para TimeApi), a IndexAsync (faz uma chamada assíncrona para TimeApi) e a Information (retorna a quantidade de threads disponíveis na aplicação). A aplicação Portal possui uma restrição de threads disponíveis, limitada ao número de cores do processador da máquina da demonstração (8 cores). Isso é utilizado para simular um servidor com alta carga de trabalho e poucas threads disponíveis.
  • Requester
    O Requester é uma aplicação console em .NET Core que faz chamadas síncronas e assíncronas para o Portal.
Gif mostrando uma tela preta com códigos brancos em movimento. Ilustração de IO Blocking Threads.
IO Blocking Threads

Na chamada síncrona, bloqueamos uma thread, que fica aguardando finalizar a carga de trabalho IO.

Gif mostrando uma tela preta com códigos brancos em movimento. Ilustração de IO No blocking Threads
I/O No blocking Threads

Já na chamada assíncrona, com o “async all the way”, as threads não são bloqueadas. Assim que invocamos o HttpClient, ele segue o fluxo de processamento assíncrono que vimos acima e libera a thread do request para retornar ao Threadpool.

Desta forma, mesmo que a TimeApi apresente algum problema de performance, a aplicação Portal fica disponível para receber novos requests.

Custo da programação Assíncrona

Quando uma aplicação apresenta lentidão, a resposta default, na maioria dos casos é “precisamos de mais hardware”. De fato, o aumento do hardware minimiza os problemas de performance, mas há um alto custo financeiro.

Na demonstração, onde simulamos um ambiente com hardware limitado e/ou carga alta de trabalho e com uma API problemática, observamos que utilizando as chamadas assíncronas corretamente, fomos capazes de atender 305 requisições!

Comparando com o total de requisições síncronas obtidas, que foram 14, significa que a aplicação assíncrona foi 22x mais eficiente no uso do hardware.

Se considerarmos que, nesse cenário hipotético, a aplicação deve atender até 305 requests, concluímos:

  • Na versão síncrona precisaríamos de 14 servidores;
  • Na versão assíncrona, os 14 servidores da versão síncrona poderiam ser reduzidos para 1 único servidor.

Considerando o custo de um servidor físico de BRL 12.000, concluímos que:

  • O custo de infraestrutura para versão síncrona seria de BRL 168.000;
  • O custo de infraestrutura para versão assíncrona seria de BRL 12.000.

Reflexão

Mais do que um software funcional, é necessário que ele seja eficiente e responsável. Devemos conhecer como o Framework que trabalhamos funciona. Devemos aprender a escrever código que aproveite o máximo possível o hardware disponível.

Gostou?