IO Bound — There is no thread <0>

Thiago Borba
CWI Software
Published in
5 min readAug 11, 2019

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.

O problema

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, porque 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 sequência 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, é utilizada 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!

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, fica claro por que 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, nesse 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.");
};y

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

  • Chamada seja “async all the way”
  • 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 TimerApi é uma API Rest em .NET Core que possui um método GET que retorna um DateTime atual. Antes de retornar o DateTime, ele usa 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 demostraçã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.

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

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 à 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 que:

  • 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.

--

--