.NET: Ferramentas para trabalhar com multithreading e assincronia. Parte 1

Publico o artigo original sobre Habr, cuja tradução está publicada no blog da Codingsight .
A segunda parte está disponível aqui.

A necessidade de fazer algo de forma assíncrona, sem esperar pelo resultado aqui e agora, ou de compartilhar muito trabalho entre várias unidades que o realizavam, era ainda antes do advento dos computadores. Com sua aparência, essa necessidade se tornou muito tangível. Agora, em 2019, digitando este artigo em um laptop com um processador Intel Core de 8 núcleos, no qual não cem processos funcionam ao mesmo tempo, mas ainda mais threads. Ao lado dele, encontra-se um telefone ligeiramente desgastado, comprado há alguns anos, com um processador de 8 núcleos a bordo. Os recursos temáticos estão cheios de artigos e vídeos em que seus autores admiram os principais smartphones deste ano, onde colocam processadores de 16 núcleos. Por menos de US $ 20 / hora, o MS Azure fornece uma máquina virtual com 128 processadores principais e 2 TB de RAM. Infelizmente, é impossível maximizar e restringir esse poder sem poder controlar a interação dos fluxos.

Terminologia


Processo - Um objeto do SO, um espaço de endereço isolado, contém threads.
Thread (Thread) - um objeto do SO, a menor unidade de execução, parte de um processo, threads compartilha memória e outros recursos entre si dentro do processo.
Multitarefa é um recurso do sistema operacional, a capacidade de executar vários processos ao mesmo tempo
Multicore - uma propriedade do processador, a capacidade de usar vários núcleos para processamento de dados
Multiprocessamento - uma propriedade de um computador, a capacidade de trabalhar simultaneamente com vários processadores fisicamente
Multithreading é uma propriedade de um processo, a capacidade de distribuir o processamento de dados entre vários threads.
Paralelismo - executando várias ações fisicamente ao mesmo tempo por unidade de tempo
Assincronia - execução de uma operação sem aguardar o final desse processamento, o resultado da execução pode ser processado posteriormente.

Metáfora


Como nem todas as definições são boas e algumas precisam de explicações adicionais, adicionarei uma metáfora para o café da manhã à terminologia formalmente introduzida. Cozinhar o café da manhã nessa metáfora é um processo.

Fazendo o café da manhã, eu ( CPU ) venho para a cozinha ( Computador ). Eu tenho 2 mãos ( Cores ). A cozinha possui vários dispositivos ( IO ): forno, chaleira, torradeira, geladeira. Eu ligo o acelerador, coloco uma frigideira sobre ele e despejo óleo lá, sem esperar até que aqueça ( assíncrona, Não obstruindo-IO-Espere ), tiro os ovos da geladeira e os quebro em um prato, depois bato com uma mão ( Tópico # 1 ) e o segundo ( Tópico # 2 ) seguro a placa (Recurso Compartilhado). Agora eu ainda ligava a chaleira, mas não há mãos suficientes ( Fome de Linha ). Durante esse período, a frigideira é aquecida (Processando o resultado), onde despejo o que bati. Pego a chaleira e a ligo e, estupidamente, observo como a água ferve ( Bloqueio-Espera-IO ), embora eu pudesse lavar o prato durante esse tempo, onde bato na omelete.

Eu cozinhei uma omelete usando apenas duas mãos, mas não tenho mais, mas ao mesmo tempo três operações foram realizadas no momento de chicotear uma omelete: chicoteando uma omelete, segurando uma placa, aquecendo uma frigideira. A CPU é a parte mais rápida do computador, o IO é mais frequente diminui a velocidade de tudo, com freqüência uma solução eficaz é pegar alguma CPU ao receber dados do IO.

Continuando a metáfora:

  • Se, no processo de preparar uma omelete, eu também tentasse trocar de roupa, isso seria um exemplo de multitarefa. Uma nuance importante: os computadores com isso são muito melhores que as pessoas.
  • Uma cozinha com vários chefs, por exemplo em um restaurante, é um computador com vários núcleos.
  • Muitos restaurantes de praça de alimentação em um shopping center - data center

Ferramentas .NET


Ao trabalhar com threads, como em muitas outras coisas, o .NET é bom. A cada nova versão, ele apresenta mais e mais novas ferramentas para trabalhar com elas, novas camadas de abstração nos encadeamentos do SO. Ao trabalhar com a construção de abstrações, os desenvolvedores da estrutura usam a abordagem que deixa a possibilidade ao usar a abstração de alto nível; ela descerá um ou vários níveis abaixo. Na maioria das vezes isso não é necessário; além disso, isso abre a possibilidade de uma espingarda ser baleada no pé, mas às vezes, em casos raros, pode ser a única maneira de resolver um problema que não resolve no nível atual de abstração.

Por ferramentas, refiro-me às interfaces de programa (APIs) fornecidas pela estrutura e aos pacotes de terceiros e a uma solução de software completa que simplifica a busca por problemas associados ao código multithread.

Início do stream


A classe Thread, a classe mais básica do .NET para trabalhar com threads. O construtor aceita um dos dois delegados:

  • ThreadStart - sem parâmetros
  • ParametrizedThreadStart - com um parâmetro do tipo objeto.

O delegado será executado no thread recém-criado depois de chamar o método Start, se um delegado do tipo ParametrizedThreadStart for passado para o construtor, um objeto deverá ser passado para o método Start. Esse mecanismo é necessário para transferir qualquer informação local para o fluxo. Vale ressaltar que a criação de um encadeamento é uma operação cara e o próprio encadeamento é um objeto pesado, pelo menos porque 1 MB de memória é alocado para a pilha e requer interação com a API do SO.

new Thread(...).Start(...); 

A classe ThreadPool representa o conceito de um pool. No .NET, o pool de threads é uma obra de arte e os desenvolvedores da Microsoft se esforçam muito para fazê-lo funcionar de maneira ideal em uma ampla variedade de cenários.

Conceito geral:

Desde o início, o aplicativo em segundo plano cria vários encadeamentos em reserva e oferece a oportunidade de usá-los. Se os encadeamentos forem usados ​​com frequência e em grandes números, o pool se expandirá para atender à necessidade do código de chamada. Quando não houver fluxos livres no pool no momento certo, ele aguardará o retorno de um dos fluxos ou criará um novo. Daqui resulta que o conjunto de encadeamentos é ótimo para algumas ações curtas e pouco adequado para operações que operam como um serviço em todo o aplicativo.

Para usar um encadeamento do pool, existe um método QueueUserWorkItem que aceita um delegado do tipo WaitCallback, que é a mesma assinatura que ParametrizedThreadStart e o parâmetro passado a ele executa a mesma função.

 ThreadPool.QueueUserWorkItem(...); 

O método de pool de encadeamentos menos conhecido, RegisterWaitForSingleObject, é usado para organizar operações de E / S sem bloqueio. O delegado passado para esse método será chamado quando o WaitHandle passado para o método for "Liberado".

 ThreadPool.RegisterWaitForSingleObject(...) 

O .NET possui um cronômetro de fluxo e difere dos cronômetros WinForms / WPF, pois seu manipulador será chamado em um fluxo retirado do pool.

 System.Threading.Timer 

Também há uma maneira bastante exótica de enviar um representante para o thread do pool - o método BeginInvoke.

 DelegateInstance.BeginInvoke 

Também quero insistir em transmitir uma função que chama muitos dos métodos acima - CreateThread da API Win32 do Kernel32.dll. Existe uma maneira, graças ao mecanismo de métodos externos para chamar essa função. Vi esse desafio apenas uma vez em um terrível exemplo de código legado, e a motivação do autor para fazer exatamente isso ainda é um mistério para mim.

 Kernel32.dll CreateThread 

Exibir e depurar threads


Os threads que você criou pessoalmente por todos os componentes de terceiros e o pool .NET podem ser exibidos na janela Threads Visual Studio. Essa janela exibirá informações sobre fluxos somente quando o aplicativo estiver em depuração e no modo de interrupção (modo de interrupção). Aqui você pode visualizar convenientemente os nomes e as prioridades da pilha de cada thread, alternar a depuração para um thread específico. A propriedade Priority da classe Thread permite definir a prioridade do thread, que o OC e o CLR perceberão como uma recomendação ao dividir o tempo da CPU entre os threads.



Biblioteca paralela de tarefas


A Biblioteca Paralela de Tarefas (TPL) apareceu no .NET 4.0. Agora é o padrão e a principal ferramenta para trabalhar com assincronia. Qualquer código que use uma abordagem mais antiga é considerado legado. A unidade básica do TPL é a classe Task do espaço para nome System.Threading.Tasks. Tarefa é uma abstração sobre um encadeamento. Com a nova versão do C #, temos uma maneira elegante de trabalhar com os operadores Task - async / wait. Esses conceitos tornaram possível escrever código assíncrono como se fosse simples e síncrono; isso possibilitou, mesmo para pessoas com pouco conhecimento da cozinha interna de encadeamentos, escrever aplicativos que os utilizavam, aplicativos que não congelam durante operações longas. O uso de async / waitit é um tópico para um ou vários artigos, mas tentarei entender o essencial de algumas frases:

  • async é um modificador do método que retorna Task ou void
  • e aguardar é a instrução de espera sem bloqueio de tarefas.

Mais uma vez: o operador wait, no caso geral (há exceções), lançará ainda mais o encadeamento atual de execução, e quando a Tarefa terminar sua execução, e o encadeamento (na verdade é mais correto dizer o contexto, mas mais sobre isso mais tarde) estará livre para continuar o método. No .NET, esse mecanismo é implementado da mesma maneira que o retorno de rendimento, quando um método escrito se transforma em uma classe inteira, que é uma máquina de estados e pode ser executada em partes separadas, dependendo desses estados. Qualquer pessoa interessada pode escrever qualquer código simples usando assíncrono / aguardar, compilar e visualizar a montagem usando o JetBrains dotPeek com o código gerado pelo compilador ativado.

Considere as opções para iniciar e usar a Tarefa. Usando o exemplo de código abaixo, criamos uma nova tarefa que não é útil ( Thread.Sleep (10000) ), mas na vida real deve ser algum tipo de trabalho complexo que envolve a CPU.

 using TCO = System.Threading.Tasks.TaskCreationOptions; public static async void VoidAsyncMethod() { var cancellationSource = new CancellationTokenSource(); await Task.Factory.StartNew( // Code of action will be executed on other context () => Thread.Sleep(10000), cancellationSource.Token, TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness, scheduler ); // Code after await will be executed on captured context } 

A tarefa é criada com várias opções:

  • LongRunning é uma dica de que a tarefa não será concluída rapidamente, o que significa que pode valer a pena considerar não retirar um thread do pool, mas criar um separado para esta Tarefa para não prejudicar os outros.
  • AttachedToParent - Tarefas podem ser organizadas em uma hierarquia. Se essa opção foi usada, a tarefa poderá estar em um estado em que tenha sido concluída e esteja aguardando a conclusão dos filhos.
  • PreferFairness - significa que seria bom executar as tarefas enviadas anteriormente para execução antes daquelas que foram enviadas posteriormente. Mas isso é apenas uma recomendação e o resultado não é garantido.

O segundo parâmetro para o método passou CancellationToken. Para processar corretamente o cancelamento de uma operação após seu lançamento, o código executado deve ser preenchido com as verificações de status de CancellationToken. Se não houver verificações, o método Cancel chamado no objeto CancellationTokenSource poderá parar a execução da Tarefa apenas antes de iniciar.

O último parâmetro passou o objeto do planejador do tipo TaskScheduler. Essa classe e seus descendentes foram projetados para controlar as estratégias de distribuição de Task'ov por encadeamento; por padrão, a Tarefa será executada em um encadeamento aleatório do pool.

O operador wait é aplicado à tarefa criada, o que significa que o código gravado depois dele, se houver, será executado no mesmo contexto (geralmente isso significa que está no mesmo encadeamento) que o código antes de aguardar.

O método é marcado como vazio assíncrono, o que significa que você pode usar o operador de espera nele, mas o código de chamada não pode esperar pela execução. Se esse recurso for necessário, o método deve retornar a tarefa. Os métodos marcados como async void são bastante comuns: como regra geral, trata-se de manipuladores de eventos ou outros métodos que trabalham com o princípio de disparar e esquecer. Se você precisar não apenas dar a oportunidade de esperar até a conclusão da execução, mas também retornar o resultado, deverá usar a Tarefa.

Na tarefa que o método StartNew retornou, no entanto, como em qualquer outro, você pode chamar o método ConfigureAwait com o parâmetro false; a execução após aguardar continuará não no contexto capturado, mas em um contexto arbitrário. Isso sempre deve ser feito quando o contexto de execução não for importante para o código após aguardar. Também é uma recomendação da MS ao escrever o código de que ele será fornecido em um formulário de biblioteca.

Vamos pensar um pouco mais em como você pode esperar até a conclusão da tarefa. Abaixo está um código de exemplo, com comentários, quando a espera é feita condicionalmente boa e quando condicionalmente ruim.

 public static async void AnotherMethod() { int result = await AsyncMethod(); // good result = AsyncMethod().Result; // bad AsyncMethod().Wait(); // bad IEnumerable<Task> tasks = new Task[] { AsyncMethod(), OtherAsyncMethod() }; await Task.WhenAll(tasks); // good await Task.WhenAny(tasks); // good Task.WaitAll(tasks.ToArray()); // bad } 

No primeiro exemplo, aguardamos a conclusão da tarefa e, sem bloquear o segmento de chamada, retornaremos ao processamento do resultado apenas quando ele já estiver lá, até que o segmento de chamada seja deixado sozinho.

Na segunda opção, bloqueamos o thread de chamada até que o resultado do método seja calculado. Isso é ruim não apenas porque pegamos o encadeamento, um recurso tão valioso do programa, por simples ociosidade, mas também porque, se o código do método que chamamos aguardar, e o contexto de sincronização envolver o retorno ao encadeamento de chamada depois de aguardar, teremos um conflito. : o encadeamento de chamada aguarda até que o resultado do método assíncrono seja calculado, o método assíncrono tenta em vão continuar sua execução no encadeamento de chamada.

Outra desvantagem dessa abordagem é o tratamento de erros complicado. O fato é que os erros no código assíncrono ao usar async / waitit são muito fáceis de manipular - eles se comportam como se o código fosse síncrono. Embora, se aplicarmos exorcismo, expectativa síncrona a Tarefa, a exceção original se transformar em uma AggregateException, ou seja, Para manipular uma exceção, você terá que examinar o tipo InnerException e escrever a cadeia if dentro de um bloco catch ou usar a catch quando construir em vez da cadeia de blocos catch mais familiar em C #.

O terceiro e o último exemplo também são marcados como ruins pelo mesmo motivo e contêm todos os mesmos problemas.

Quando os métodos AnyAny e WhenAll são extremamente convenientes na espera de um grupo de Task'ov, eles agrupam um grupo de Task'ov em um, que funcionará na primeira operação da Task'a do grupo ou quando todos terminarem sua execução.

Parada de fluxo


Por vários motivos, pode ser necessário interromper o fluxo após o início. Existem várias maneiras de fazer isso. A classe Thread possui dois métodos com nomes apropriados - Interromper e Interromper . O primeiro não é recomendado para uso, pois após ser chamado em qualquer momento aleatório, durante o processamento de qualquer instrução, uma ThreadAbortedException será lançada. Você não espera que essa exceção falhe ao incrementar uma variável inteira, certo? E ao usar esse método, essa é uma situação muito real. Se você deseja impedir que o CLR lance uma exceção em uma seção específica do código, é possível agrupá- lo nas chamadas para Thread.BeginCriticalRegion , Thread.EndCriticalRegion . Qualquer código escrito em um bloco final é envolvido com essas chamadas. Por esse motivo, nas entranhas do código da estrutura, você pode encontrar blocos com uma tentativa vazia, mas não uma vazia finalmente. A Microsoft não recomenda o uso desse método, que não o incluiu no núcleo .net.

O método Interrupção funciona de forma mais previsível. Ele pode interromper um encadeamento com exceção de ThreadInterruptedException somente quando o encadeamento estiver no estado ocioso. Nesse estado, ele fica em suspensão enquanto aguarda WaitHandle, lock ou após chamar Thread.Sleep.

Ambas as opções descritas acima são ruins por sua imprevisibilidade. A solução é usar a estrutura CancellationToken e a classe CancellationTokenSource . A linha inferior é: uma instância da classe CancellationTokenSource é criada e somente a pessoa que a possui pode parar a operação chamando o método Cancel . Somente o CancellationToken é passado para a própria operação. Os proprietários do CancellationToken não podem cancelar a operação, mas podem apenas verificar se a operação foi cancelada. Para fazer isso, há uma propriedade booleana IsCancellationRequested e o método ThrowIfCancelRequested . O último gerará uma TaskCancelledException se o método Cancel for chamado na instância CancellationToken cancelada do CancellationTokenSource. E é esse método que eu recomendo usar. Isso é melhor do que as opções anteriores, obtendo controle total sobre os pontos em que a operação de exceção pode ser interrompida.

A opção mais cruel para interromper o segmento é chamar a função TerminateThread da API do Win32. O comportamento do CLR após chamar esta função pode ser imprevisível. No MSDN, foi escrito o seguinte sobre esta função: “TerminateThread é uma função perigosa que só deve ser usada nos casos mais extremos. "

Converter API herdada em Baseado em Tarefas usando o método FromAsync


Se você teve a sorte de trabalhar em um projeto iniciado depois que as tarefas foram introduzidas e deixou de causar um horror silencioso para a maioria dos desenvolvedores, não precisará lidar com muitas APIs antigas, tanto de terceiros quanto de sua equipe torturadas no passado. Felizmente, a equipe de desenvolvimento do .NET Framework cuidou de nós, embora talvez o objetivo fosse cuidar de nós mesmos. Seja como for, o .NET possui várias ferramentas para a conversão simples de códigos escritos em abordagens de programação assíncrona antiga para uma nova. Um deles é o método FromAsync do TaskFactory. Usando o exemplo de código abaixo, envolvo os métodos assíncronos antigos da classe WebRequest na tarefa usando esse método.

 object state = null; WebRequest wr = WebRequest.CreateHttp("http://github.com"); await Task.Factory.FromAsync( wr.BeginGetResponse, we.EndGetResponse ); 

Este é apenas um exemplo, e é improvável que você faça isso com tipos internos, mas qualquer projeto antigo está simplesmente repleto de métodos BeginDoSomething que retornam os métodos IAsyncResult e EndDoSomething que o aceitam.

Converter API herdada em Baseado em Tarefas usando a classe TaskCompletionSource


Outra ferramenta importante a considerar é a classe TaskCompletionSource . Em termos de funções, objetivo e princípio de operação, ele pode de alguma forma lembrar o método RegisterWaitForSingleObject da classe ThreadPool sobre a qual escrevi acima. Usando essa classe, você pode agrupar APIs assíncronas antigas de maneira fácil e conveniente na tarefa.

Você dirá que eu já falei sobre o método FromAsync da classe TaskFactory destinado a esses fins. Aqui, teremos de recordar todo o histórico do desenvolvimento de modelos assíncronos em .net que a Microsoft oferece há 15 anos: antes do TAP, havia o padrão de programação assíncrona (APP), que tratava dos métodos Begin DoSomething que retornam os métodos IAsyncResult e End DoSomething que o aceitam. e o método FromAsync é adequado para o legado desses anos, mas, com o tempo, foi substituído pelo EAP (Event Based Asynchronous Pattern), que supunha que um evento seria acionado quando a operação assíncrona fosse concluída.

TaskCompletionSource é ótimo para agrupar na Task e na API herdada criada em torno do modelo de evento. A essência de seu trabalho é a seguinte: um objeto dessa classe possui uma propriedade pública do tipo Task, cujo estado pode ser controlado pelos métodos SetResult, SetException etc. da classe TaskCompletionSource. Nos locais em que o operador de espera foi aplicado a esta tarefa, ele será executado ou travado com uma exceção, dependendo do método aplicado ao TaskCompletionSource. Se tudo ainda não estiver claro, vejamos este exemplo de código, em que alguma API EAP antiga é agrupada na Tarefa usando TaskCompletionSource: quando o evento é disparado, a Tarefa será transferida para o estado Concluído e o método que aplicou o operador de espera a esta Tarefa continuará a execução obtendo o objeto de resultado .

 public static Task<Result> DoAsync(this SomeApiInstance someApiObj) { var completionSource = new TaskCompletionSource<Result>(); someApiObj.Done += result => completionSource.SetResult(result); someApiObj.Do(); result completionSource.Task; } 

Dicas e truques para fonte de tarefas


O agrupamento de APIs antigas não é tudo o que você pode fazer com TaskCompletionSource. O uso dessa classe abre uma possibilidade interessante de criar várias APIs em tarefas que não ocupam threads. E o fluxo, como lembramos, é um recurso caro e seu número é limitado (principalmente pela RAM). Essa limitação é facilmente alcançada através do desenvolvimento, por exemplo, de um aplicativo da web carregado com lógica de negócios complexa. Considere as possibilidades de que estou falando sobre a implementação de um truque como a pesquisa longa.

Em resumo, a essência do truque é esta: você precisa obter informações da API sobre alguns eventos que ocorrem do seu lado, enquanto a API, por algum motivo, não pode relatar o evento, mas pode retornar apenas o estado. Um exemplo disso são todas as APIs criadas sobre HTTP antes dos tempos do WebSocket ou quando, por algum motivo, não é possível usar essa tecnologia. O cliente pode perguntar ao servidor HTTP. Um servidor HTTP não pode, por si só, provocar comunicação com um cliente. Uma solução simples é interrogar o servidor por timer, mas isso cria uma carga adicional no servidor e um atraso adicional em média TimerInterval / 2. Para contornar isso, foi inventado um truque chamado Long Polling, que envolve atrasar a resposta do servidor até o tempo limite expirar ou um evento vai acontecer. Se um evento ocorreu, ele é processado; caso contrário, a solicitação é enviada novamente.

 while(!eventOccures && !timeoutExceeded) { CheckTimout(); CheckEvent(); Thread.Sleep(1); } 

Mas essa solução se mostrará terrivelmente assim que o número de clientes que aguardam o evento aumentar, porque Cada um desses clientes, em antecipação ao evento, ocupa um fluxo inteiro. Sim, e temos um atraso adicional de 1ms no acionamento do evento, na maioria das vezes não é significativo, mas por que tornar o software pior do que pode ser? Se você remover o Thread.Sleep (1), em vão carregaremos um núcleo do processador com 100% de ociosidade, girando em um ciclo inútil. Usando TaskCompletionSource, você pode facilmente refazer esse código e resolver todos os problemas identificados acima:

 class LongPollingApi { private Dictionary<int, TaskCompletionSource<Msg>> tasks; public async Task<Msg> AcceptMessageAsync(int userId, int duration) { var cs = new TaskCompletionSource<Msg>(); tasks[userId] = cs; await Task.WhenAny(Task.Delay(duration), cs.Task); return cs.Task.IsCompleted ? cs.Task.Result : null; } public void SendMessage(int userId, Msg m) { if (tasks.TryGetValue(userId, out var completionSource)) completionSource.SetResult(m); } } 

Este código não está pronto para produção, mas apenas uma demonstração. Para usá-lo em casos reais, você precisa pelo menos lidar com a situação quando uma mensagem chegar no momento em que ninguém a espera: nesse caso, o método AsseptMessageAsync deve retornar uma tarefa já concluída. Se esse caso for o mais frequente, você poderá pensar em usar o ValueTask.

Após o recebimento de uma solicitação de mensagem, criamos e colocamos TaskCompletionSource no dicionário e aguardamos o que acontece primeiro: o intervalo de tempo especificado expira ou uma mensagem é recebida.

ValueTask: por que e como


Operadores assíncronos / esperados, como o operador de retorno de rendimento, geram uma máquina de estado a partir do método, que está criando um novo objeto, que quase sempre não é importante, mas em casos raros, pode criar um problema. Este caso pode ser um método chamado com muita frequência, falando sobre dezenas e centenas de milhares de chamadas por segundo. Se esse método for escrito para que, na maioria dos casos, retorne um resultado ignorando todos os métodos de espera, o .NET fornecerá uma ferramenta para otimizar isso - a estrutura ValueTask. Para deixar claro, considere um exemplo de uso: existe um cache para o qual acessamos com muita frequência. Existem alguns valores nele e, em seguida, nós os devolvemos, se não, então vamos a um IO lento por trás deles. Eu quero fazer o último assincronamente, o que significa que todo o método é assíncrono. Assim, a maneira óbvia de escrever um método é a seguinte:

 public async Task<string> GetById(int id) { if (cache.TryGetValue(id, out string val)) return val; return await RequestById(id); } 

Devido ao desejo de otimizar um pouco e um leve medo do que Roslyn gerará ao compilar esse código, podemos reescrever este exemplo da seguinte maneira:

 public Task<string> GetById(int id) { if (cache.TryGetValue(id, out string val)) return Task.FromResult(val); return RequestById(id); } 

De fato, a solução ideal nesse caso é otimizar o caminho ativo, ou seja, obter o valor do dicionário sem alocações e carga extras no GC, enquanto nesses casos raros em que ainda precisamos ir para o IO, tudo permanecerá mais / menos antigo:

 public ValueTask<string> GetById(int id) { if (cache.TryGetValue(id, out string val)) return new ValueTask<string>(val); return new ValueTask<string>(RequestById(id)); } 

Vamos dar uma olhada mais de perto neste fragmento de código: se houver um valor no cache, criamos uma estrutura, caso contrário, a tarefa real será agrupada em uma significativa. O código de chamada não se importa de que maneira esse código foi executado: a ValueTask do ponto de vista da sintaxe C # se comportará exatamente como a Tarefa usual neste caso.

TaskSchedulers: Gerenciando estratégias de inicialização de tarefas


A próxima API que eu gostaria de considerar é a classe TaskScheduler e seus derivados. Eu já mencionei acima que no TPL existe a capacidade de controlar as estratégias de distribuição de Task'ov por thread. Essas estratégias são definidas nos descendentes da classe TaskScheduler. Quase toda estratégia necessária pode ser encontrada na biblioteca ParallelExtensionsExtras , desenvolvida pela microsoft, mas não parte do .NET, mas entregue como um pacote Nuget. Vamos considerar brevemente alguns deles:

  • CurrentThreadTaskScheduler - Executa Tarefa no thread atual
  • LimitedConcurrencyLevelTaskScheduler - limita o número de tarefas executadas simultaneamente ao parâmetro N, aceito no construtor
  • OrderedTaskScheduler — LimitedConcurrencyLevelTaskScheduler(1), .
  • WorkStealingTaskSchedulerwork-stealing . ThreadPool. , .NET ThreadPool , , . . .. WorkStealingTaskScheduler' , ThreadPool .
  • QueuedTaskScheduler - permite executar tarefas de acordo com as regras da fila com prioridades
  • ThreadPerTaskScheduler - cria um thread separado para cada tarefa executada nele. Pode ser útil para tarefas com duração imprevisível.

Há um bom artigo detalhado sobre TaskSchedulers no blog da microsoft.

Para uma depuração conveniente de tudo relacionado a Tarefas no Visual Studio, há uma janela Tarefas. Nesta janela, você pode ver o status atual da tarefa e ir para a linha de código atualmente em execução.



PLinq e a classe Parallel


Além do Task e tudo o que foi dito com eles no .NET, existem mais duas ferramentas interessantes: PLinq (Linq2Parallel) e a classe Parallel. O primeiro promete execução paralela de todas as operações do Linq em vários encadeamentos. O número de threads pode ser configurado com o método de extensão WithDegreeOfParallelism. Infelizmente, na maioria das vezes o PLinq no modo de execução, por padrão, não possui informações suficientes sobre a fonte de dados para fornecer um ganho de velocidade significativo, por outro lado, o preço da tentativa é muito baixo: basta chamar o método AsParallel na frente da cadeia de métodos do Linq e realizar testes de desempenho. Além disso, é possível transferir para o PLinq informações adicionais sobre a natureza da sua fonte de dados usando o mecanismo Partitions. Você pode ler mais aqui e aqui..

A classe estática Parallel fornece métodos para iterar sobre uma coleção Foreach em paralelo, executando um loop For e executando vários delegados em paralelo a Invoke. A execução do encadeamento atual será interrompida até o final dos cálculos. O número de threads pode ser configurado passando ParallelOptions como o último argumento. Usando as opções, você também pode especificar TaskScheduler e CancellationToken.

Conclusões


Quando comecei a escrever este artigo com base nos materiais do meu relatório e nas informações que coletei durante o trabalho depois dele, não esperava que o resultado fosse tanto. Agora, quando o editor de texto em que estou digitando este artigo reprovadoramente me disser que a 15ª página se foi, resumirei os resultados intermediários. Outros truques, APIs, ferramentas visuais e armadilhas serão discutidos em um artigo futuro.

Conclusões:

  • Você precisa conhecer as ferramentas para trabalhar com threads, assincronia e paralelismo para usar os recursos dos PCs modernos.
  • O .NET possui muitas ferramentas diferentes para esse fim.
  • Nem todos eles apareceram de uma só vez, porque o legado pode ser encontrado com frequência, no entanto, existem maneiras de converter APIs antigas sem muito esforço.
  • O trabalho com threads no .NET é representado pelas classes Thread e ThreadPool
  • Thread.Abort, Thread.Interrupt, Win32 API TerminateThread . CancellationToken'
  • — , . , . TaskCompletionSource
  • .NET Task'.
  • c# async/await
  • Task' TaskScheduler'
  • ValueTask hot-paths memory-traffic
  • Tasks Threads Visual Studio
  • PLinq , , partitioning
  • ...

Source: https://habr.com/ru/post/pt452094/


All Articles