.NET: Ferramentas para trabalhar com multi-threading e assincronia - Parte 1

Originalmente, publiquei este artigo no blog CodingSight
A segunda parte do artigo está disponível aqui

A necessidade de fazer as coisas de maneira assíncrona - ou seja, dividir grandes tarefas entre várias unidades de trabalho - estava presente muito antes do surgimento dos computadores. No entanto, quando eles apareceram, essa necessidade se tornou ainda mais óbvia. Agora é 2019, e estou escrevendo este artigo em um laptop equipado com uma CPU Intel Core de 8 núcleos que, além disso, trabalha simultaneamente em centenas de processos, com o número de threads ainda maior. Ao meu lado, está um smartphone um pouco desatualizado que comprei há alguns anos - e também possui um processador de 8 núcleos. Os recursos especializados da web contêm uma grande variedade de artigos elogiando os principais smartphones deste ano, equipados com CPUs de 16 núcleos. Por menos de US $ 20 por hora, o MS Azure pode fornecer acesso a uma máquina virtual de 128 núcleos com 2 TB de RAM. Infelizmente, você não pode tirar o máximo proveito desse poder, a menos que saiba controlar a interação entre os threads.

Conteúdo




Terminologia


Processo - um objeto do SO que representa um espaço de endereço isolado contendo threads.

Thread - um objeto do SO que representa a menor unidade de execução. Os encadeamentos são partes constituintes dos processos, dividem a memória e outros recursos entre si no escopo de um processo.

Multitarefa - um recurso do SO que representa a capacidade de executar vários processos simultaneamente.

Multi-core - um recurso de CPU que representa a capacidade de usar vários núcleos para processamento de dados

Multiprocessamento - o recurso de um computador que representa a capacidade de trabalhar fisicamente com várias CPUs.

Multiencadeamento - o recurso de um processo que representa a capacidade de dividir e espalhar o processamento de dados entre vários encadeamentos.

Paralelismo - execução física simultânea de várias ações em uma unidade de tempo

Assincronia - executar uma operação sem esperar que ela seja totalmente processada, deixando o cálculo do resultado para mais tarde.


Uma metáfora


Nem todas as definições são eficazes e algumas delas requerem elaboração; portanto, deixe-me fornecer uma metáfora de cozimento para a terminologia que acabei de introduzir.

Fazer café da manhã representa um processo nessa metáfora.

Ao fazer o café da manhã, eu ( CPU ) vou para a cozinha ( Computador ). Eu tenho duas mãos ( Cores ). Na cozinha, há uma variedade de dispositivos ( IO ): fogão, chaleira, torradeira, geladeira. Ligo o fogão, coloco uma frigideira e despejo um pouco de óleo vegetal. Sem esperar que o óleo aqueça (de forma assíncrona, Não obstrua as entradas / saídas ), pego alguns ovos na geladeira, os quebro em uma tigela e os bato com uma mão ( Linha 1 ). Enquanto isso, o ponteiro dos segundos (Segmento # 2) mantém a tigela no lugar ( Recurso Compartilhado ). Gostaria de ligar a chaleira, mas não tenho mãos livres suficientes no momento ( Fome de Linha ). Enquanto batia os ovos, a frigideira ficou quente o suficiente (processamento de resultados), então despejei os ovos batidos nela. Estendo a mão na chaleira, ligo a panela e olho para a água que está sendo fervida ( Bloqueio-IO-Espere ) - mas eu poderia ter usado esse tempo para lavar a tigela.

Usei apenas duas mãos ao fazer a omelete (porque não tenho mais), mas foram executadas três operações simultâneas: bater os ovos, segurar a tigela, aquecer a frigideira. A CPU é a parte mais rápida do computador e a E / S é a parte que requer a espera com mais frequência; portanto, é bastante eficaz carregar a CPU com algum trabalho enquanto aguarda os dados da E / S.

Para estender a metáfora:

  • Se eu também estivesse tentando trocar de roupa enquanto preparava o café da manhã, teria sido multitarefa . Os computadores são muito melhores nisso do que os humanos.
  • Uma cozinha com vários cozinheiros - por exemplo, em um restaurante - é um computador com vários núcleos .
  • Uma praça de alimentação de shopping com muitos restaurantes representaria um data center .



Ferramentas .NET


O .NET é realmente bom quando se trata de trabalhar com threads - assim como em muitas outras coisas. A cada nova versão, ele fornece mais ferramentas para trabalhar com threads e novas camadas de abstração de threads do SO. Ao trabalhar com abstrações, os desenvolvedores que trabalham com a estrutura estão usando uma abordagem que lhes permite reduzir uma ou mais camadas enquanto usam abstrações de alto nível. Na maioria dos casos, não há necessidade real de fazer isso (e isso pode introduzir a possibilidade de dar um tiro no pé), mas às vezes essa é a única maneira de resolver um problema que não pode ser resolvido no nível de abstração atual.

Quando eu disse as ferramentas anteriormente, quis dizer interfaces de programa (API) fornecidas pela estrutura ou pacotes de terceiros e soluções de software completas que simplificam o processo de pesquisa de problemas relacionados ao código multiencadeado.


Iniciando um Thread


A classe Thread é a classe .NET mais básica para trabalhar com threads. Seu construtor aceita um desses dois delegados:

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


O delegado será executado em um thread recém-criado depois de chamar o método Start. Se o delegado ParametrizedThreadStart foi passado para o construtor, um objeto deve ser passado para o método Start. Esse processo é necessário para passar qualquer informação local para o encadeamento. Devo salientar que são necessários muitos recursos para criar um thread e o próprio thread é um objeto pesado - pelo menos porque requer interação com a API do SO e 1 MB de memória é alocado para a pilha.

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

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

O conceito geral:
Quando iniciado, o aplicativo cria alguns threads em segundo plano, permitindo acessá-los quando necessário. Se os threads forem usados ​​com frequência e em grandes números, o pool será expandido para satisfazer as necessidades do código de chamada. Se o pool não tiver threads livres suficientes no momento certo, ele aguardará um dos threads ativos ficar desocupado ou criará um novo. Com base nisso, o conjunto de encadeamentos é perfeito para ações curtas e não funciona muito bem para processos que funcionam como serviços por toda a duração da operação do aplicativo.

O método QueueUserWorkItem permite usar threads do pool. Este método utiliza o delegado WaitCallback -type. Sua assinatura coincide com a assinatura de ParametrizedThreadStart, e o parâmetro que é passado para ele tem a mesma função.

 ThreadPool.QueueUserWorkItem(...); 

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

 ThreadPool.RegisterWaitForSingleObject(...) 


Há um cronômetro de encadeamento no .NET e ele difere dos timers WinForms / WPF, pois seu manipulador é chamado no encadeamento retirado do pool.

 System.Threading.Timer 


Também há uma maneira bastante incomum de enviar o delegado para um thread do pool - o método BeginInvoke.

 DelegateInstance.BeginInvoke 


Eu também gostaria de dar uma olhada na função que muitos dos métodos que eu mencionei anteriormente se referem ao - CreateThread da API Win32 do Kernel32.dll. Existe uma maneira de chamar essa função com a ajuda do mecanismo externo dos métodos. Eu só vi isso sendo usado uma vez em um caso particularmente ruim de código legado - e ainda não entendo quais foram as razões de seu autor.
 Kernel32.dll CreateThread 



Visualizando e depurando threads


Todos os threads - criados por você, componentes de terceiros ou o pool .NET - podem ser exibidos na janela Threads do Visual Studio. Essa janela exibirá apenas as informações sobre threads quando o aplicativo estiver sendo depurado no modo Break. Aqui, você pode visualizar os nomes e prioridades de cada thread e focar o modo de depuração em threads específicos. A propriedade Priority da classe Thread permite definir a prioridade do thread. Essa prioridade será levada em consideração quando o SO e o CLR estiverem dividindo o tempo do processador entre os threads.




Biblioteca paralela de tarefas


A Biblioteca Paralela de Tarefas (TPL) apareceu pela primeira vez no .NET 4.0. Atualmente, é a principal ferramenta para trabalhar com assincronia. Qualquer código que utilize abordagens mais antigas será considerado código legado. A unidade principal do TPL é a classe Task do espaço para nome System.Threading.Tasks. As tarefas representam a abstração do encadeamento. Com a versão mais recente do C #, adquirimos uma nova maneira elegante de trabalhar com o Tasks - os operadores assíncronos / aguardados. Isso permite que o código assíncrono seja escrito como se fosse simples e síncrono, para que aqueles que não são bem versados ​​na teoria de threads agora possam escrever aplicativos que não terão dificuldades com operações longas. O uso de assíncrono / espera é realmente um tópico para um artigo separado (ou mesmo alguns artigos), mas tentarei descrever o básico em algumas frases:

  • async é um modificador de um método que retorna uma tarefa ou anula
  • waitit é um operador de uma tarefa de espera sem bloqueio.


Mais uma vez: o operador aguardar normalmente (há exceções) deixa o encadeamento atual e, quando a tarefa é executada e o encadeamento (na verdade, o contexto, mas voltaremos a ele mais tarde) estará livre como um Como resultado, ele continuará executando o método. No .NET, esse mecanismo é implementado da mesma maneira que o retorno de rendimento - um método é transformado em uma classe de máquina de estados finitos que pode ser executada em partes separadas com base em seu estado. Se isso parecer interessante, eu recomendaria escrever qualquer trecho de código simples baseado em assíncrono / aguardar, compilando-o e analisando sua compilação com a ajuda do JetBrains dotPeek com o código gerado pelo compilador ativado.

Vejamos as opções que temos quando se trata de iniciar e usar uma tarefa. No exemplo abaixo, criamos uma nova tarefa que na verdade não faz nada produtivo (Thread.Sleep (10000)). No entanto, em casos reais, devemos substituí-lo por algum trabalho complexo que utiliza recursos da 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 } 


Uma tarefa é criada com as seguintes opções:

  • LongRunning - essa opção sugere o fato de que a tarefa não pode ser executada rapidamente. Portanto, é possivelmente melhor criar um thread separado para esta tarefa, em vez de retirar um existente do pool para minimizar danos a outras tarefas.
  • AttachedToParent - As tarefas podem ser organizadas hierarquicamente. Se essa opção for usada, a tarefa estará aguardando a execução de suas tarefas filhas após a própria execução.
  • PreferFairness - esta opção especifica que a tarefa deve ser melhor executada antes das tarefas que foram criadas posteriormente. No entanto, é mais uma sugestão, portanto o resultado nem sempre é garantido.


O segundo parâmetro que foi passado para o método é CancellationToken. Para que a operação seja cancelada corretamente depois de já ter sido iniciada, o código executável deve conter as verificações de estado de CancellationToken. Se não houver essas verificações, o método Cancel chamado no objeto CancellationTokenSource só poderá parar a execução da tarefa antes que a tarefa seja realmente iniciada.

Para o último parâmetro, enviamos um objeto do tipo TaskScheduler chamado scheduler. Essa classe, juntamente com suas classes filho, é usada para controlar como as tarefas são distribuídas entre os threads. Por padrão, uma tarefa será executada em um encadeamento selecionado aleatoriamente do pool

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

Esse método é identificado como vazio assíncrono, o que significa que o operador de espera pode ser usado nele, mas o código de chamada não poderá aguardar a execução. Se tal possibilidade for necessária, o método deve retornar uma tarefa. Os métodos rotulados como vazio assíncrono podem ser vistos com bastante frequência: geralmente são manipuladores de eventos ou outros métodos que operam sob o princípio de ignorar e esquecer. Se for necessário aguardar a conclusão da execução e retornar o resultado, você deve usar a Tarefa.

Para tarefas que retornam o método StartNew, podemos chamar ConfigureAwait com o parâmetro false - então, a execução após aguardar continuará em um contexto aleatório em vez de capturado. Isso sempre deve ser feito se o código gravado após aguardar não exigir um contexto de execução específico. Essa também é uma recomendação da MS quando se trata de escrever código fornecido como uma biblioteca.

Vejamos como podemos esperar que uma tarefa seja concluída. Abaixo, você pode ver um exemplo de código com comentários indicando quando a espera é implementada de maneira relativamente boa ou 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, estamos aguardando a execução da tarefa sem bloquear o segmento de chamada, portanto, voltaremos a processar o resultado quando estiver pronto. Antes que isso aconteça, o encadeamento de chamada é deixado sozinho.

Na segunda tentativa, estamos bloqueando o encadeamento de chamada até que o resultado do método seja calculado. Essa é uma abordagem ruim por dois motivos. Antes de tudo, estamos desperdiçando um encadeamento - um recurso muito valioso - na simples espera. Além disso, se o método que estamos chamando contiver um aguardar enquanto um retorno para o segmento de chamada depois de aguardar for planejado pelo contexto de sincronização, obteremos um impasse. Isso acontece porque o encadeamento de chamada estará aguardando o resultado de um método assíncrono, e o próprio método assíncrono tentará infrutífera continuar sua execução no encadeamento de chamada.

Outra desvantagem dessa abordagem é o aumento da complexidade do tratamento de erros. Na verdade, os erros podem ser tratados com bastante facilidade no código assíncrono se for usado o assíncrono / espera - o processo nesse caso é idêntico ao do código síncrono. No entanto, quando uma espera síncrona é aplicada a uma tarefa, a exceção inicial é agrupada em AggregateException. Em outras palavras, para lidar com a exceção, precisaríamos explorar o tipo InnerException e escrever manualmente uma cadeia if em um bloco de captura ou, alternativamente, usar a captura quando a estrutura em vez da cadeia mais usual de blocos de captura.

Os dois últimos exemplos também são rotulados como relativamente ruins pelos mesmos motivos e ambos contêm os mesmos problemas.

Os métodos WhenAny e WhenAll são muito úteis quando se trata de aguardar um grupo de tarefas - eles agrupam essas tarefas em uma e serão executados quando uma tarefa do grupo for iniciada ou quando todas essas tarefas forem executadas com êxito.


Parando Encadeamentos


Por vários motivos, pode ser necessário interromper um encadeamento depois que ele foi iniciado. Existem algumas maneiras de fazer isso. A classe Thread possui dois métodos com nomes apropriados - Interromper e Interromper . Eu desencorajaria fortemente o uso do primeiro, pois, depois de chamado, haveria uma ThreadAbortedException lançada a qualquer momento aleatório ao processar qualquer instrução escolhida arbitrariamente. Você não espera que essa exceção seja encontrada quando uma variável inteira for incrementada, certo? Bem, ao usar o método Abort, isso se torna uma possibilidade real. No caso de você precisar negar a capacidade do CLR de criar essas exceções em uma parte específica do código, você pode agrupá- lo no Thread.InstititititRegion e Thread.EndCriticalRegion . Qualquer código escrito no bloco final é envolvido nessas chamadas. É por isso que você pode encontrar blocos com uma tentativa vazia e uma não vazia finalmente nas profundezas do código da estrutura. A Microsoft não gosta deste método na medida em que não o inclui no núcleo do .NET.

O método Interromper funciona de uma maneira muito mais previsível. Ele pode interromper um thread com uma ThreadInterruptedException apenas quando o thread estiver no modo de espera. Ele se move para esse estado quando suspenso enquanto aguarda WaitHandle, um bloqueio ou após o Thread.Sleep ser chamado.

Ambas as formas têm uma desvantagem de imprevisibilidade. Para escapar desse problema, devemos usar a estrutura CancellationToken e a classe CancellationTokenSource . A ideia geral é a seguinte: uma instância da classe CancellationTokenSource é criada e somente aqueles que a possuem podem parar a operação chamando o método Cancel . Somente CancellationToken é passado para a operação. Os proprietários do CancelamentoToken não podem cancelar a operação eles mesmos - eles podem apenas verificar se a operação foi cancelada. Isso pode ser alcançado usando a propriedade booleana IsCancellationRequested e o método ThrowIfCancelRequested . O último gerará uma TaskCancelledException se o método Cancel tiver sido chamado na instância CancellationTokenSource que criou o CancellationToken. Este é o método que eu recomendo usar. Sua vantagem sobre os métodos descritos anteriormente reside no fato de fornecer controle total sobre os casos de exceção exata nos quais uma operação pode ser cancelada.

A maneira mais brutal de interromper um thread seria chamar uma função da API do Win32 chamada TerminateThread. Depois que essa função é chamada, o comportamento do CLR pode ser bastante 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. "


Transformando uma API herdada em uma baseada em tarefas usando FromAsync


Se você teve a sorte de trabalhar em um projeto iniciado após a introdução das Tarefas (e quando não estiver mais provocando horror existencial na maioria dos desenvolvedores), não precisará lidar com APIs antigas - ambas de terceiros. aqueles e aqueles em que sua equipe trabalhou no passado. Felizmente, a equipe de desenvolvimento do .NET Framework tornou mais fácil para nós - mas isso poderia ter sido um autocuidado, pelo que sabemos. De qualquer forma, o .NET possui algumas ferramentas que ajudam a trazer o código escrito com as abordagens antigas para assincronia, para um formulário atualizado. Um deles é o método TaskFactory chamado FromAsync. No exemplo abaixo, estou agrupando os métodos assíncronos antigos da classe WebRequest em uma tarefa usando FromAsync.

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

É apenas um exemplo, e você provavelmente não fará algo desse tipo com os tipos internos. No entanto, projetos antigos estão repletos de métodos BeginDoSomething que retornam os métodos IAsyncResult e EndDoSomething que os recebem.


Transformando uma API herdada em uma baseada em tarefas usando TaskCompletionSource


Outra ferramenta que vale a pena explorar é a classe TaskCompletionSource . Em sua funcionalidade, objetivo e princípio de operação, ele se parece com o método RegisterWaitForSingleObject da classe ThreadPool que eu mencionei anteriormente. Essa classe nos permite envolver facilmente APIs assíncronas antigas em Tarefas.

Você pode dizer que eu já falei sobre o método FromAsync da classe TaskFactory, que serviu a esses propósitos. Aqui, precisamos lembrar o histórico completo dos modelos assíncronos que a Microsoft forneceu nos últimos 15 anos: antes dos padrões assíncronos baseados em tarefas (TAP), havia padrões de programação assíncrona (APP). Os aplicativos eram sobre Begin DoSomething retornando IAsyncResult e o método End DoSomething que o aceita - e o método FromAsync é perfeito para o legado desses anos. No entanto, com o passar do tempo, isso foi substituído pelo EAP (Event Based Asynchronous Patterns), que especificava que um evento é chamado quando uma operação assíncrona é executada com êxito.

TaskCompletionSource são perfeitos para agrupar APIs herdadas criadas em torno do modelo de evento nas Tarefas. É assim que funciona: os objetos dessa classe têm uma propriedade pública chamada Task, cujo estado pode ser controlado por vários métodos da classe TaskCompletionSource (SetResult, SetException etc.). 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. Para entender melhor, vejamos este exemplo de código. Aqui, alguma API antiga da era EAP é agrupada em uma tarefa com a ajuda de TaskCompletionSource: quando um evento é acionado, a tarefa será alternada para o estado Concluído enquanto o método que aplicou o operador de espera a esta tarefa continuará sua execução. depois de receber um 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


TaskCompletionSource pode fazer mais do que apenas agrupar APIs obsoletas. Essa classe abre uma possibilidade interessante de criar várias APIs baseadas em tarefas que não ocupam threads. Um thread, como lembramos, é um recurso caro, limitado principalmente pela RAM. Podemos facilmente atingir esse limite ao desenvolver um aplicativo Web robusto com lógica de negócios complexa. Vejamos os recursos que mencionei em ação, implementando um truque conhecido como Long Polling.

Em resumo, é assim que o Long Polling funciona:
Você precisa obter algumas informações de uma API sobre eventos que ocorrem do seu lado, mas a API, por algum motivo, pode retornar apenas um estado em vez de informar sobre o evento. Um exemplo disso seria qualquer API criada sobre HTTP antes do WebSocket aparecer ou em circunstâncias nas quais essa tecnologia não pode ser usada. O cliente pode perguntar ao servidor HTTP. O servidor HTTP, por outro lado, não pode iniciar o contato com o cliente sozinho. A solução mais simples seria solicitar ao servidor periodicamente usando um timer, mas isso criaria carga adicional para o servidor e um atraso geral que é aproximadamente igual a TimerInterval / 2. Para contornar isso, a Pesquisa Longa foi inventada. Isso implica em atrasar a resposta do servidor até o tempo limite expirar ou ocorrer um evento. Se um evento ocorrer, ele será tratado; caso contrário - o pedido será enviado novamente.

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

No entanto, a eficácia dessa solução diminuirá radicalmente se o número de clientes aguardando o evento aumentar - cada cliente em espera ocupa um encadeamento completo. Além disso, temos um atraso adicional de 1ms para o acionamento do evento. Frequentemente, não é realmente tão crucial, mas por que tornamos nosso software pior do que poderia ser? Por outro lado, se removermos o Thread.Sleep (1), um dos núcleos da CPU será carregado por 100%, sem fazer nada em um ciclo inútil. Com a ajuda do TaskCompletionSource, podemos transformar facilmente nosso código para resolver todos os problemas mencionados:

 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); } } 

Lembre-se de que esse trecho de código é apenas um exemplo e de forma alguma pronto para produção. Para usá-lo em casos reais, precisaríamos pelo menos adicionar uma maneira de lidar com situações nas quais uma mensagem é recebida quando nada estava esperando por ela: nesse caso, o método AcceptMessageAsync deve retornar uma tarefa já concluída. Se esse for o caso mais comum, podemos considerar o uso da ValueTask.

Ao receber uma solicitação de mensagem, criamos um TaskCompletionSource, colocamos em um dicionário e aguardamos um dos seguintes eventos: o intervalo de tempo especificado é gasto ou a mensagem é recebida.


ValueTask: Por que e como


Os operadores async / waitit, assim como o operador de retorno de rendimento, geram uma máquina de estados finitos a partir de um método, o que significa criar um novo objeto - isso não importa muito na maioria das vezes, mas ainda pode criar problemas em alguns casos raros. Um desses casos pode ocorrer com os métodos chamados com freqüência - estamos falando de dezenas e centenas de milhares de chamadas por segundo. Se esse método é escrito de uma maneira que faz com que ele retorne o resultado, ignorando todos os métodos de espera na maioria dos casos, o .NET fornece uma ferramenta de otimização para isso - a estrutura ValueTask. Para entender como funciona, vejamos um exemplo. Suponha que exista um cache que acessamos regularmente. Se houver algum valor, basta devolvê-lo; se não houver valores - tentamos obtê-los de algumas E / S lentas. Idealmente, o último deve ser feito de forma assíncrona, para que todo o método seja assíncrono. Portanto, a maneira mais óbvia de implementar esse método será a seguinte:

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

Com o desejo de otimizá-lo um pouco e uma preocupação com o que Roslyn gerará ao compilar esse código, poderíamos reescrever o método assim:

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

No entanto, a melhor solução nesse caso seria otimizar o atalho - especificamente, obtendo valores de dicionário sem alocações desnecessárias e sem carga no GC. Enquanto isso, nos casos pouco frequentes em que precisamos obter dados de E / S, as coisas permanecerão quase as mesmas:

 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 olhar mais de perto esse fragmento de código: se um valor estiver presente no cache, criaremos uma estrutura; caso contrário, a tarefa real será agrupada em uma ValueTask. O caminho pelo qual esse código é executado não é importante para o código de chamada: da perspectiva da sintaxe C #, uma ValueTask se comportará como uma Tarefa usual.


TaskScheduler: Controlando estratégias de execução de tarefas


A próxima API da qual gostaria de falar é a classe TaskScheduler e as derivadas dela. Eu já mencionei que o TPL fornece a capacidade de controlar como exatamente as tarefas estão sendo distribuídas entre os threads. Essas estratégias são definidas nas classes herdadas do TaskScheduler. Quase qualquer estratégia que possamos precisar pode ser encontrada na biblioteca ParallelExtensionsExtras . Esta biblioteca é desenvolvida pela Microsoft, mas não faz parte do .NET. Em vez disso, é distribuída como um pacote Nuget. Vamos dar uma olhada em algumas das estratégias:

  • CurrentThreadTaskScheduler - executa tarefas no thread atual
  • LimitedConcurrencyLevelTaskScheduler - limita o número de tarefas executadas simultaneamente usando o parâmetro N que ele aceita no construtor
  • OrderedTaskScheduler - é definido como LimitedConcurrencyLevelTaskScheduler (1), para que as tarefas sejam executadas seqüencialmente.
  • WorkStealingTaskScheduler - implementa a abordagem de roubo de trabalho para execução de tarefas. Essencialmente, ele pode ser visto como um ThreadPool separado. Isso ajuda a questão de o ThreadPool ser uma classe estática no .NET - se for sobrecarregado ou usado incorretamente em uma parte do aplicativo, efeitos colaterais desagradáveis ​​podem ocorrer em um local diferente. As causas reais desses defeitos podem ser difíceis de localizar, portanto, pode ser necessário usar WorkStealingTaskSchedulers separados nas partes do aplicativo em que o uso do ThreadPool pode ser agressivo e imprevisível.
  • QueuedTaskScheduler - permite executar tarefas com base em uma fila priorizada
  • ThreadPerTaskScheduler - cria um thread separado para cada tarefa executada nele. Isso pode ser útil para tarefas cujo tempo de execução não pode ser estimado.

Há um artigo muito bom sobre os TaskSchedulers no blog da Microsoft, portanto, fique à vontade para conferir.

No Visual Studio, há uma janela Tarefas que pode ajudar na depuração de tudo relacionado a Tarefas. Nesta janela, você pode ver o estado da tarefa e pular para a linha de código atualmente executada.



PLinq e a classe paralela


Além de Tarefas e tudo relacionado a elas, existem duas ferramentas adicionais no .NET que podemos achar interessantes - PLinq (Linq2Parallel) e a classe Parallel . O primeiro promete execução paralela de todas as operações do Linq em todos os threads. O número de threads pode ser configurado por um método de extensão WithDegreeOfParallelism. Infelizmente, na maioria dos casos, o PLinq no modo padrão não terá informações suficientes sobre a fonte de dados para fornecer um aumento significativo na velocidade. Por outro lado, o custo da tentativa é muito baixo: você só precisa chamar AsParallel antes da cadeia de métodos do Linq e executar testes de desempenho. Além disso, você pode passar informações adicionais sobre a natureza da sua fonte de dados para o PLinq usando o mecanismo Partitions. Você pode encontrar mais informações aqui e aqui .

A classe estática Parallel fornece métodos para enumerar coleções em paralelo via Foreach, executando o ciclo For e executando vários delegados em paralelo a Invoke. A execução do encadeamento atual será interrompida até que os resultados sejam calculados. Você pode configurar o número de threads passando ParallelOptions como o último argumento. TaskScheduler e CancellationToken também podem ser configurados com a ajuda de opções.


Sumário


Quando comecei a escrever este artigo com base na minha tese e no conhecimento que adquiri ao trabalhar depois, não achei que houvesse tanta informação. Agora, com o editor de texto me dizendo reprovadoramente que escrevi quase 15 páginas, gostaria de tirar uma conclusão intermediária. Veremos outras técnicas, APIs, ferramentas visuais e riscos ocultos no próximo artigo.

Conclusões:

  • Para usar efetivamente os recursos dos PCs modernos, você precisa conhecer ferramentas para trabalhar com threads, assincronia e paralelismo.
  • Existem muitas ferramentas como esta no .NET
  • Nem todos eles foram criados ao mesmo tempo; portanto, é possível que você encontre frequentemente algum código legado - mas há maneiras de transformar APIs antigas com pouco esforço.
  • No .NET, as classes Thread e ThreadPool são usadas para trabalhar com threads
  • O método Thread.Abort e Thread.Interrupt, juntamente com a função de API do Win32 TerminateThread, são perigosos e não são recomendados para uso. Em vez disso, é melhor usar CancellationTokens
  • Threads são um recurso valioso e seu número é limitado. Você deve evitar os casos em que os segmentos estão ocupados aguardando eventos. A classe TaskCompletionSource pode ajudar a conseguir isso.
  • As tarefas são a ferramenta mais poderosa e robusta que o .NET possui para trabalhar com paralelismo e assincronia.
  • Os operadores C # assíncronos / esperados implementam o conceito de uma espera sem bloqueio
  • Você pode controlar como as tarefas são distribuídas entre os threads com a ajuda de classes derivadas do TaskScheduler
  • A estrutura ValueTask pode ser usada para otimizar atalhos e tráfego de memória
  • As janelas Tarefas e Threads no Visual Studio fornecem muitas informações úteis para depurar código assíncrono ou multithread
  • O PLinq é uma ferramenta incrível, mas pode não ter todas as informações necessárias sobre sua fonte de dados - que ainda podem ser corrigidas com o mecanismo de particionamento

Para continuar ...

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


All Articles