Sincronicidade e paralelismo

Sincronicidade e paralelismo

Porque devemos evitar o uso de Task.Run? (estamos falando, obviamente, de um contexto de throughput alto - que é o caso de ASP.net, portanto, em outras situações, pode ser vantajoso utilizar este método).

Para entendermos melhor o caso, é preciso ter alguns conceitos em mente:

Tipos de Task

Existem dois tipos de Task, IO-bounded e CPU-bounded. São, respectivamente, tasks cujo gargalo ficam no IO (ler ou gravar em disco, ou em rede, por exemplo); ou na CPU (um cálculo muito pesado, que vai exigir muito da CPU). É importante saber o tipo de código que estamos escrevendo para saber dos benefícios (ou não) do que podemos utilizar. No caso em questão, aplicações web, normalmente vemos muitos pontos de IO e poucos de CPU.

Nem toda Task converte-se em uma thread - embora, obviamente, o trabalho realizado sempre o será em uma thread. E aqui reside um ponto que não é muito compreendido por uma grande parte dos desenvolvedores: task não significa necessariamente paralelismo, mas sim assincronicidade.

Paralelismo: duas coisas acontecendo concomitantemente. Exemplo: com duas threads, eu posso, ao mesmo tempo (porque estou usando dois cores do processador), fazer dois cálculos. O Node, por exemplo, não permite isso, porque seu modelo de concorrência é de thread única.

Assincronicidade: duas coisas acontecendo de forma não sequencial. Exemplo: faço uma chamada para o banco de dados. Enquanto a consulta está em andamento, eu leio um arquivo do disco, pouco a pouco (por exemplo, 64k por vez). Quando a resposta do banco está disponível, eu intercalo a leitura do disco com a leitura da rede. O Node permite tranquilamente isso: duas coisas não vão acontecer ao mesmo tempo, porém, eu faço um pouquinho de cada coisa.

Por isso o Node não é uma ferramenta muito adequada para coisas que demandam vários cálculos pesados, porém, se a aplicação for mais voltada a IO, pode ser a ferramenta ideal.

A primeira versão do ASP.net era bem limitada: quando uma requisição era feita, uma thread era utilizada do início até o fim: se você tivesse de fazer uma chamada ao banco de dados e essa chamada levasse 2 segundos, aquela thread ficaria bloqueada durante todo esse tempo, sem nada para fazer, aguardando o banco de dados responder. Na versão 2, foram introduzidas formas de se trabalhar com código assíncrono - em todo o framework pode-se encontrar métodos que seguem o padrão BeginX e EndX – são métodos desenhados para que um processo permitisse que determinada thread pudesse ser reutilizada por outro request enquanto uma operação de IO estivesse aguardando resposta, em um formato de callback, na qual o método EndX era chamado quando a ação assíncrona fosse concluída.

O modelo de callback é o padrão no nível mais baixo do IO. Quando aguardamos um pacote ser lido na interface de rede, ou no disco, o que acontece é que pedimos ao sistema operacional para registrar uma função de callback que deve ser chamada após a quantidade combinada de dados ser lida. Por exemplo, dizemos que o sistema operacional deve ler 64k, e então chamar a função Y. O sistema operacional vai armazenar em um buffer os dados recebidos até chegar aos 64k, e quando isso acontecer, ele vai chamar a função Y passando um ponteiro para a memória na posição 0 dos 64k que foram lidos. No caso do framework .net, esses métodos que são registrados como callback não são nossos métodos, mas sim do CLR, ou seja, do próprio framework. Este, por sua vez, vai tratar os dados, transformando-os em código gerenciado, e, só então, chamar nossos métodos (user code). Aqui já podemos adiantar algo que fica claro neste modelo: enquanto nosso código está aguardando ações de IO, nada é executado. Não há threads lendo algum tipo de resultado. Não há processamento acontecendo.

Na versão 4, recebemos o formato async/await, que nada mais é do que syntatic sugar para o modelo de assincronicidade já existente (mas que deu uma bela melhorada na produtividade, pois encapsulou várias preocupações que o desenvolvedor deveria considerar ao criar seu próprio código assíncrono) - na realidade, o async/await cria uma máquina de estado que armazena o conteúdo do contexto em que o método está sendo executado e controla os passos do processo. Um exemplo de mudanças de estado que uma task passa, quando executada com sucesso:

Created - A tarefa foi inicializada, mas ainda não foi agendada.

WaitingForActivation - A tarefa está aguardando para ser ativada e agendada internamente pela infraestrutura do .NET.

WaitingToRun - A tarefa foi agendada para execução, mas ainda não começou a ser executada.

Running - A tarefa está em execução, mas ainda não foi concluída.

RanToCompletion - A execução da tarefa foi concluída com êxito.

Baseado nesses estados, o .net vai decidir quando seu código vai ser retomado, e em que situação (ele pode ser retomado com um erro, por exemplo - estes não são os únicos estados possíveis).

Threadpool

Criar uma thread é um processo custoso, por isso, reaproveitar threads já criadas é um bom negócio (inclusive, pools são comuns em todo o framework .net. Um caso pouco conhecido é o ArrayPool, que foi criado para reaproveitar arrays grandes, que são alocados diretamente no Large Object Heap – e que causam uma pressão maior no Garbage Collector. Existem outros, como MemoryPool; internamente, o .net tem também o StringBuilderPool, etc.).

Nas primeiras versões do .net, o threadpool era, basicamente, uma fila na qual nosso código registrava ações que seriam processadas por alguma das threads do threadpool assim que ela estivesse livre. O problema deste modelo é que, para que a leitura na fila fosse segura, é preciso garantir que a leitura/retirada de um item da fila acontecesse de forma bloqueante. Em outras palavras: um lock era utilizado sempre que uma thread disponível fosse ler a fila. É um processo muito, muito rápido, porém, não deixa de ser um gargalo, com um agravante: quanto mais threads, maior tempo de bloqueio poderia haver nesse passo.

Nas versões mais recentes, o threadpool passou por uma atualização. Ao invés de apenas uma fila, temos N+1 filas, onde N é o número de threads. Cada thread tem sua própria fila de itens, e existe uma fila global. O algoritmo para a leitura das filas, grosso modo, funciona da seguinte forma: sempre que uma thread terminou alguma ação, ela vai olhar para sua própria fila, considerando os últimos itens inseridos (FILO). Se não houver mais itens em sua própria fila, ela vai olhar para a fila global. Se não houver itens na fila global, ela vai olhar para as filas das outras threads, porém, considerando os primeiros itens inseridos (FIFO). Assim, os casos em que é necessário lock reduzem-se, e muito.

Sempre que uma thread do threadpool solicita uma tarefa (cria uma Task), essa tarefa é colocada na fila da própria thread (há exceções, mas são casos em que o desenvolvedor precisa, explicitamente, solicitar a inclusão na fila global). Por outro lado, threads de fora do threadpool só podem incluir tarefas na fila global.

Adendo: a utilização de ConfigureAwait(false) em uma task tem relação com qual thread processará a continuação da execução do código, porém, não é uma relação direta. Isso porque as aplicações podem ter um sincronizador (ou um scheduler, nas aplicações mais novas). As razões de haver duas entidades para, em tese, fazer a mesma coisa são históricas, e vem da similaridade do modelo com o próprio Windows, nas primeiras versões do .net. Um sincronizador (ou scheduler – daqui pra frente, se você ler “sincronizador”, saiba que pode ser um scheduler também) é responsável por distribuir as tarefas para threads específicas. Porém, nem todos os ambientes possuem um sincronizador. Console applications, por exemplo, não possuem. ASP.net e Windows, possuem. No ASP.net, a responsabilidade do sincronizador é fazer com que uma requisição que estava sendo processada por uma thread X, após retornar de uma espera de um IO, caia na mesma thread X. Isso porque a thread X contém todos os dados da requisição, e as outras threads, não. Se, entretanto, você utiliza ConfigureAwait(false), você está dizendo para a task NÃO utilizar o sincronizador, ou seja, não há garantia nenhuma de que a continuação do código seguirá na mesma thread que iniciou o processamento, de modo que, nesse caso, você não pode confiar em contextos globais para aquela requisição (como o HttpContext, por exemplo). No ASP.net da versão core não há sincronizador, de modo que utilizar ou não ConfigureAwait não faz diferença.

Mais um adendo: se uma task já está resolvida quando seu código chega em um await, NÃO HÁ MUDANÇA DE THREAD. O código funciona de forma 100% síncrona, mesmo que seja uma operação de IO. Por exemplo, você chama uma leitura em disco sem o await, aí entra em um loop e vai fazer outra coisa, e depois desse loop faz o await daquela task. Caso a leitura já tenha sido realizada no momento em que o código chega no await, a execução do código é sequencial. Isso, obviamente, é ótimo para o seu código, já que o processo de colocar em espera, colocar a continuação em uma fila, e efetivamente continuar o processamento é custoso!

Último adendo: sempre que possível, utilize ValueTask ao invés de Task. O ValueTask vai economizar a alocação na heap, o que melhora bastante a execução do código em cenários de loops em que o processo pode armazenar os dados na própria stack. Menos alocações na heap = menos pressão no GC = mais performance.

Assincronicidade é paralelismo?

Já vimos que não. Evidentemente, no modelo de concorrência do .net, há uma forte relação entre assincronicidade e paralelismo. Isso leva a alguns mal-entendidos, que podem causar mais problemas do que ser uma solução. Especificamente, vamos focar no Task.Run.

Quando utilizamos o Task.Run em sua sobrecarga padrão, uma tarefa será registrada na fila da thread. É possível que outra thread, que esteja com a fila livre, vá executar a ação. Temos, então, dois cenários possíveis:

1 – a ação ser CPU-bounded. Nesse caso, o código em si que será executa é síncrono. Obviamente, do ponto de vista da primeira thread, a que disparou o Task.Run, é como se o código fosse assíncrono, mas apenas porque ela disparou a ação para ser realizada por outra thread enquanto ela própria pode seguir fazendo outra coisa. Mas, na prática, ALGUMA thread será bloqueada para o processamento.

2 – a ação ser IO-bounded. Nesse caso, a thread que for elencada para executar aquela tarefa será bloqueada durante a parte síncrona do código, mas quando o código chegar na parte assíncrona (no caso do .net, significa: quando encontrar um await), a execução do código será suspensa, e um callback será chamado quando aquela operação de IO for concluída, para, então, o código ser retomado. No caso do .net, isso significa que, quando a operação de IO for concluída, uma nova ação será registrada em uma fila das threads, e é possível que outra thread continue a execução do código. E, enquanto a operação de IO está em andamento, a thread que estava executando aquele código fica livre para fazer outras coisas. Percebam que, neste caso, enquanto a operação estava em andamento, NENHUMA thread está sendo bloqueada.

Ainda é sutil, mas já dá pra perceber uma coisa: se a ação for CPU-bounded, então qual o ganho em utilizar o Task.Run, já que eu posso liberar uma determinada thread, mas outra ficará ocupada? A resposta é: depende.

Em um cenário web, sim, estaremos apenas gastando uma troca de execução de uma thread para outra (considerando, por exemplo, passar um cálculo para outra thread, e ficar esperando o resultado – já chego no cenário de não esperar o resultado). Porém, em um cenário em que o sistema tem uma thread principal (janelas Windows, por exemplo, que funcionam de forma parecida com o Node, com um event loop), bloquear essa thread significa que a aplicação não ficará responsiva enquanto esse cálculo é feito. Nesse caso, utilizar o Task.Run faz com que o cálculo seja passado para outra thread, de modo que é possível para a aplicação continuar recebendo mensagens do sistema operacional (o que significa que ela continuará responsiva para o usuário).

Em uma aplicação web, o usuário só vê o resultado depois que recebe o string que será enviado para o navegador. Alguém poderia pensar: bom, se, em uma aplicação web, eu tenho dois cálculos independentes para fazer, eu posso disparar dois Task.Run, e esperar o resultado dos dois, assim minha aplicação responderá mais rápido. É verdade, até certo ponto: fazendo isso, bloquearemos duas threads ao invés de uma. Essa API, ou tela, vai ser acessada com frequência? Se sim, significa que cada chamada a ela estará ocupando duas threads (ou seja, você está penalizando outros requests para reduzir o tempo desse request específico). Talvez seja melhor que esse request específico espere mais (certamente um problema como esse tem mais a ver com arquitetura do que com código: provavelmente há formas melhores de não precisar realizar esse cálculo no momento do request).

E se a aplicação for IO-bounded? Nesse caso, temos uma excelente vantagem: enquanto o processo IO está aguardando, a thread (seja a principal, no caso de uma aplicação Windows, seja a thread que está processando o request) ficará livre para fazer outras coisas, como processar as mensagens do sistema operacional, ou responder a outros requests! Esse sim é um processo não-bloqueante. Nesse caso, se for o caso, há vantagens inclusive em paralelizar dois métodos assíncronos (utilizando Task.WhenAll, por exemplo), já que dois ou mais processos de IO serão feitos ao mesmo tempo sem que alguma thread seja consumida. Porém, aqui não faz sentido utilizar Task.Run. Fazer isso em um código IO-bounded vai fazer com que uma tarefa seja alocada para uma thread que vai, rapidamente, ser liberada novamente para a espera de IO. Ora, para quê passar por outra thread para isso? Utilize apenas o await da task, e economize na alocação da task na fila e na captura da task para execução!

E no caso do fire-and-forget?

Fire-and-forget é um termo aplicado a situações, geralmente cenários web, em que você quer disparar uma ação, mas não quer esperar a resposta. Por exemplo, eu quero realizar uma ação, porém, já quero enviar para o usuário o conteúdo da minha página ou API. Nesse caso, posso utilizar o Task.Run sem um await, certo? Em tese, sim. Na prática, não faça isso.

Um cenário em que essa ação seja CPU-bounded, você realmente vai responder de forma mais rápida para o cliente, porém, você terá outra thread sendo bloqueada, de modo que é uma thread a menos para responder a outras requisições. Aqui, aplica-se o que foi dito acima sobre tasks CPU-bounded em cenários web. Evite.

Em um cenário em que essa ação seja IO-bounded, aí, com o Task.Run, você vai alocar tarefa para uma outra thread, mas essa thread vai rapidamente ser liberada por causa da espera do IO. O ideal, em tasks de IO, é não utilizar outra thread, certo? Porém, não dá para fazer fire-and-forget sem utilizar outra thread, já que o request terá “morrido” quando a resposta do IO retornar para o código principal, e isso vai gerar um erro na aplicação. Então, nesse caso, o Task.Run funcionaria, em “caminhos felizes”.

Porém, há uma forma melhor de registrar tarefas para execução independente da continuidade do request no ASP.net: HostingEnvironment.QueueBackgroundWorkItem. Este método recebe uma tarefa na forma de um Action e esta tarefa será agendada na fila global. Importante: o registro da tarefa na fila de outra thread (ou, no caso, na fila global) significa que essas tarefas NÃO devem confiar ou esperar qualquer informação referente ao request. Quando isso for necessário, deve-se utilizar a técnica de closure (colocando os valores necessários em variáveis próximas da chamada ao QueueBackgroundWorkItem, de modo que a tarefa receba as referências a essas variáveis). Como o closure funciona não faz parte do escopo deste artigo, pois ele nada tem a ver com código assíncrono. Não vou me alongar nisso, mas é algo que vale muito a pena compreender!

Como mencionei anteriormente, no threadpool, as novas tasks sempre são alocadas nas filas de threads do threadpool, exceto em alguns casos em que explicitamente o desenvolvedor solicita a inclusão da task na fila global. Este é um dos casos: dentro do QueueBackgroundWorkItem, a solicitação da inclusão da task é para a fila global. Isso significa que os processamentos que estão em andamento não serão penalizados, já que as threads só vão olhar para a fila global quando suas próprias filas estiverem vazias. Obviamente, esse tipo de coisa deve ser exceção, pois novos requests entram na fila global, de modo que muitas alocações nela concorrem com os novos requests (idealmente, este é um problema arquitetural: algo que precise ser processado “por fora” das requests deveria ser enviado para uma fila que garanta persistência).

Um outro ponto positivo para a utilização do QueueBackgroundWorkItem ao invés de Task.Run é que essa é uma forma de informar ao ASP.net que há uma tarefa sendo executada (ou, ao menos, está agendada para tal). Isso faz com que, se o AppDomain precisar ser descarregado (o IIS faz isso ocasionalmente), saber quais são as tarefas em andamento vai permitir que essas tarefas tenham um tempo para finalizar corretamente (por meio do cancellation token, que é passado para a tarefa no momento da execução). Ao optar por chamar Task.Run, caso o AppDomain precise ser reciclado, a tarefa vai morrer sem mais nem menos, sem a possibilidade de gravar alguma informação, por exemplo.

Como mencionei, o ideal é não utilizar QueueBackgroundWorkItem, porque há situações em que você pode perder dados! OutOfMemoryException, ou StackOverflowException (entre outras) são exceções irrecuperáveis para o .net: elas simplesmente encerram o processo, eliminando o que estiver sendo processado da memória. O próprio processo do descarregamento do AppDomain tem um tempo limitado, de modo que se seu código não honrar o cancellation token, ele morre. Ou seja, registrar essas tarefas é colocar em risco o seu processamento. NUNCA coloque tarefas que geram dados importantes em BackgroundWorkItem (muito menos em Task.Run): utilize filas com persistência em disco, como AWS SQS, RabbitMQ (com persistência ativada), Azure Service Bus, etc.

Por último: não há bala de prata! Sempre TESTE! Verifique se seu código não está sendo penalizado pelo paralelismo! E, sempre que possível, prefira tasks que aguardem IO e não CPU (IO é da natureza da web).

Arthur Cardoso

Software Architect | Tech Lead @ Plan A Technologies

3 a

Excelente artigo como sempre, André.

José Ricardo Ventrilho

Tech Lead iOS | Swift | Kotlin Multiplatform | Appium @ Mercado Livre

3 a

Fantástico! Super esclarecedor esse artigo! Parabéns!!!!

Muito bom e didático o artigo. Tem um ponto que quase ninguém entende da primeira vez: o código na frente da palavra await NÃO vai rodar en outra thread. O que acontece é que esses códigos normalmente usam apis do Sistema operacional que recebem callbacks como argumentos. Dessa forma, quando se escolher gravar dados no disco ou enviar pacotes de rede, o que ocorre na prática é que o SO envia esses dados pra um outro processador (o chipset da placa de rede, o controlador do disco) e puede a ele que suba una interrupção no processador quando a tarefa concluir. Entre o clr e o SO existe um pool de callbacks que ao serem acionados, informam ao clr, que por sua vez, continua a execução do await, esse sim talvez em outra thread. Você colocou exatamente esse conteúdo no artigo, acima é só um detalhe mais técnico mesmo. O que sempre me fascina nesse modelo é que se fato temos sistemas multicore, multi cpu e multi chipset que é muito fácil de programar, mas que dentro da engine parece muito complexo (quem é mais velho lembra das tais IRQs que precisava configurar na bios? É exatamente dessas interrupções que o async/await se aproveita).

Que beleza de artigo! Parabéns! Claro e objetivo. Show de bola!

Entre para ver ou adicionar um comentário

Outros artigos de André Ricardo Pontes da Silva

  • Cuidado com o hype - parte 2

    Cuidado com o hype - parte 2

    IAs não são inteligentes. Repita quantas vezes for necessário até isso entrar na sua cabeça.

    1 comentário
  • Não existe almoço grátis

    Não existe almoço grátis

    Nem caminho fácil. Também não existem garantias.

    5 comentários
  • Home office: foi apenas um sonho

    Home office: foi apenas um sonho

    É, está claro que o home office está acabando, não é? Essa conversa de "híbrido" é só pra amortecer o golpe, porque não…

    23 comentários
  • API Gateway, mais uma bala de prata

    API Gateway, mais uma bala de prata

    Não sei se foi uma grande coincidência, ou se saiu alguma coisa sobre na imprensa ou entre influenciadores de…

    6 comentários
  • Links da apresentação do VS Summit 2021

    Links da apresentação do VS Summit 2021

    Este é um artigo especial, pois foi criado apenas como uma "bibliografia" da minha apresentação realizada no VS Summit…

    2 comentários
  • Trabalhando com transações

    Trabalhando com transações

    A utilização de transações é uma prática necessária para garantir a consistência dos dados que precisam ser salvos em…

    2 comentários
  • Route to Code (e outras histórias)

    Route to Code (e outras histórias)

    O .Net 5 e o C# 9 estão batendo à porta.

    7 comentários
  • Mais performance, e gratuitamente?

    Mais performance, e gratuitamente?

    Quer uma grande melhora de performance sem precisar mudar NADA (ou quase) no seu código? Prepare-se para o .net 5!…

    3 comentários
  • 1, 2, 3, Testando... 1, 2, 3 Testndo?

    1, 2, 3, Testando... 1, 2, 3 Testndo?

    Vocês têm dificuldade com testes unitários? Eu tinha, até desistir da busca pela cobertura. Existe um conceito, que…

  • O futuro do desenvolvimento é não ter desenvolvedores?

    O futuro do desenvolvimento é não ter desenvolvedores?

    Primeiro pensamento quando leio "low-code/no-code": de novo esse papinho furado pra enganar gestores esperançosos e…

    28 comentários

Outras pessoas também visualizaram

Conferir tópicos