Publico no Habr o original do artigo cuja tradução está disponível na empresa .
A necessidade de fazer algo de forma assíncrona, sem esperar o resultado aqui e agora, ou dividir um grande trabalho entre várias unidades executoras existia antes mesmo do surgimento dos computadores. Com a chegada deles, essa necessidade se tornou bastante evidente. Agora, em 2019, ao digitar este artigo em um laptop com processador Intel Core de 8 núcleos, no qual várias centenas de processos estão sendo executados ao mesmo tempo, sendo que há ainda mais threads. Ao lado, está um telefone que já parece um pouco desgastado, comprado há alguns anos, que possui um processador de 8 núcleos. Existem muitos artigos e vídeos em recursos especializados onde seus autores exaltam os smartphones topo de linha deste ano que vêm com processadores de 16 núcleos. O MS Azure oferece, por menos de 20$/hora, uma máquina virtual com um processador de 128 núcleos e 2 TB de RAM. Infelizmente, é impossível extrair o máximo e domar essa potência sem saber gerenciar a interação entre threads.
Terminologia
Processo (Process) — objeto do SO, espaço de endereço isolado, contém threads.
Thread (Thread) — objeto do SO, a menor unidade de execução, parte de um processo, threads compartilham memória e outros recursos entre si dentro do processo.
Multitarefa — propriedade do SO, capacidade de executar vários processos simultaneamente
Multinucleado — propriedade do processador, capacidade de usar vários núcleos para processamento de dados
Multiprocessamento — propriedade do computador, capacidade de operar simultaneamente com vários processadores fisicamente
Multithreading — propriedade do processo, capacidade de distribuir o processamento de dados entre várias threads.
Paralelismo — executar várias ações fisicamente ao mesmo tempo em uma única unidade de tempo
Assincronismo — executar uma operação sem esperar a conclusão desse processamento, o resultado, no entanto, pode ser processado posteriormente.
Metáfora
Nem todas as definições são boas e algumas precisam de explicação adicional, por isso, para a terminologia formalmente introduzida, adicionarei uma metáfora sobre fazer café da manhã. Fazer o café da manhã nesta metáfora é um processo.
Fazendo o café da manhã pela manhã, eu (CPU) vou para a cozinha (Computador). Eu tenho 2 mãos (Núcleos). Na cozinha, há uma série de dispositivos (IO): forno, chaleira, torradeira, geladeira. Eu ligo o gás, coloco a frigideira e despejo óleo nela, sem esperar que ela esquente (assíncrono, Non-Blocking-IO-Wait), eu tiro os ovos da geladeira e quebro-os em um prato, em seguida bato com uma mão (Thread#1), enquanto com a outra (Thread#2) seguro o prato (Shared Resource). Agora só precisava ligar a chaleira, mas não dá para fazer tudo ao mesmo tempo (Thread Starvation) Enquanto isso, a frigideira esquenta (Processamento de Resultados) onde despejo o que bati. Eu me estico até a chaleira e a ligo, e apenas olho como a água ferve (Blocking-IO-Wait), embora eu pudesse ter lavado o prato em que bati o omelete nesse tempo.
Eu preparei o omelete usando apenas 2 mãos, pois não tenho mais, mas durante o processo de batida do omelete estava ocorrendo 3 operações simultaneamente: batendo o omelete, segurando o prato, aquecendo a frigideira. O CPU é a parte mais rápida do computador, já o IO é o que muitas vezes causa lentidão, portanto, uma solução eficaz é manter o CPU ocupado enquanto os dados estão sendo recebidos do IO.
Continuando a metáfora:
- Se durante o cozimento do omelete, eu estivesse tentando me trocar, isso seria um exemplo de multitarefa. Um ponto importante: os computadores lidam melhor com isso do que os seres humanos.
- Uma cozinha com vários chefs, por exemplo, em um restaurante — é um computador multiprocessador.
- Vários restaurantes em um food court em um shopping — é um data center
Ferramentas .NET
No trabalho com threads, assim como em muitas outras áreas, o .NET é bom. A cada nova versão, ele apresenta mais novas ferramentas para trabalhar com elas, novas camadas de abstração sobre as threads do sistema operacional. Ao construir abstrações, os desenvolvedores do framework utilizam uma abordagem que permite, ao usar uma abstração de alto nível, descer um ou mais níveis abaixo. Muitas vezes não há necessidade disso, e mais ainda, isso abre a possibilidade de um tiro no pé com um shotgun, mas às vezes, em casos raros, pode ser a única maneira de resolver um problema que não se resolve no nível atual de abstração.
Com ferramentas, eu me refiro tanto às interfaces de programação (API) fornecidas pelo framework e pacotes de terceiros, quanto a soluções de software inteiras que facilitam a busca de problemas relacionados ao código multithread.
Iniciar thread
A classe Thread, a mais básica no .NET para trabalhar com threads. O construtor aceita um dos dois delegados:
- ThreadStart — Sem parâmetros
- ParametrizedThreadStart — com um parâmetro do tipo object.
O delegado será executado em uma nova thread após a chamada ao método Start. Se um delegado do tipo ParametrizedThreadStart foi passado para o construtor, é necessário passar um objeto ao método Start. Esse mecanismo é necessário para transmitir qualquer informação local para a thread. Vale ressaltar que a criação de uma thread é uma operação custosa, e a própria thread é um objeto pesado, principalmente porque consome 1MB de memória na pilha e requer interação com a API do sistema operacional.
new Thread(...).Start(...);
A classe ThreadPool representa o conceito de pool. No .NET, o pool de threads é uma verdadeira obra de engenharia, e os desenvolvedores da Microsoft dedicaram muitos esforços para que ele funcionasse de forma otimizada em diversos cenários.
Conceito geral:
Desde o início, a aplicação em segundo plano cria várias threads por precaução e fornece a possibilidade de utilizá-las. Se as threads forem usadas com frequência e em grande quantidade, o pool é expandido para atender à demanda do código solicitante. Quando não há threads livres no pool, ele aguardará a liberação de uma das threads ou criará uma nova. Isso significa que o pool de threads é ideal para ações curtas e inadequado para operações que funcionam como serviços durante todo o ciclo de vida da aplicação.
Para utilizar uma thread do pool, existe o método QueueUserWorkItem, que recebe um delegado do tipo WaitCallback, que tem a mesma assinatura do ParametrizedThreadStart, e o parâmetro passado para ele desempenha a mesma função.
ThreadPool.QueueUserWorkItem(...);
Um método menos conhecido do pool de threads, RegisterWaitForSingleObject, é usado para organizar operações de IO não bloqueantes. O delegado passado a esse método será chamado quando o WaitHandle fornecido ao método for "solto" (Released).
ThreadPool.RegisterWaitForSingleObject(...);
No .NET, existe um timer de thread, que se diferencia dos timers do WinForms/WPF pelo fato de que seu manipulador será chamado em uma thread retirada do pool.
System.Threading.Timer
Também existe uma maneira bastante exótica de enviar um delegado para execução em uma thread do pool — o método BeginInvoke.
DelegateInstance.BeginInvoke
Gostaria de mencionar brevemente uma função à qual muitos dos métodos acima se referem — CreateThread da Kernel32.dll do Win32 API. Existe uma maneira, através do mecanismo de métodos extern, de chamar esta função. Eu vi esse tipo de chamada apenas uma vez em um exemplo horrível de código legado, e a motivação do autor que fez isso ainda me permanece um mistério.
Kernel32.dll CreateThread
Visualização e depuração de threads
As threads que você criou pessoalmente, juntamente com todas as threads de componentes de terceiros e do pool .NET, podem ser visualizadas na janela Threads do Visual Studio. Esta janela exibirá informações sobre as threads apenas quando o aplicativo estiver em modo de depuração e em pausa (Break mode). Aqui, é possível visualizar convenientemente os nomes e prioridades do stack de cada thread, além de alternar a depuração para uma thread específica. A propriedade Priority da classe Thread pode ser usada para definir a prioridade da thread, que o OC e o CLR considerarão como uma recomendação ao dividir o tempo de CPU entre as threads.

Biblioteca de Tarefas Paralelas
A Biblioteca de Tarefas Paralelas (TPL) surgiu no .NET 4.0. Agora, é o padrão e a principal ferramenta para trabalhar com assíncrono. Qualquer código que utilize abordagens mais antigas é considerado legado. A unidade básica da TPL é a classe Task do namespace System.Threading.Tasks. Task representa uma abstração sobre uma thread. Com a nova versão da linguagem C#, recebemos uma maneira elegante de trabalhar com tarefas — os operadores async/await. Esses conceitos permitiram escrever código assíncrono como se fosse simples e síncrono, possibilitando que até mesmo pessoas com pouca compreensão dos detalhes de como as threads funcionam, criem aplicativos que as utilizem, sem travar durante operações longas. O uso de async/await é um tema para um ou até mesmo vários artigos, mas tentarei resumir o essencial em algumas frases:
- async é um modificador de método que retorna Task ou void
- e await é o operador de espera não bloqueante de um Task.
Mais uma vez: o operador await, em termos gerais (existem exceções), permitirá que o fluxo de execução atual continue, e quando a Task terminar sua execução e o fluxo (na verdade, é mais correto dizer o contexto, mas isso será discutido mais tarde) estiver livre, o método continuará sua execução. Dentro do .NET, esse mecanismo é implementado da mesma forma que o yield return, quando o método escrito se transforma em uma classe inteira que é uma máquina de estados e pode ser executada em pedaços dependendo desses estados. Quem estiver interessado pode escrever qualquer código simples usando async/await, compilar e visualizar o assembly com o JetBrains dotPeek com o Código Gerado pelo Compilador habilitado.
Vamos considerar as opções para iniciar e usar uma Task. No exemplo de código abaixo, criamos uma nova task que não faz nada útil (Thread.Sleep(10000)), mas na vida real isso deve ser algum trabalho complexo que envolve CPU.
using TCO = System.Threading.Tasks.TaskCreationOptions;
public static async void VoidAsyncMethod() {
var cancellationSource = new CancellationTokenSource();
await Task.Factory.StartNew(
// O código de ação será executado em outro contexto
() => Thread.Sleep(10000),
cancellationSource.Token,
TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
scheduler
);
// O código após o await será executado no contexto capturado
}
A Task é criada com uma série de opções:
- LongRunning — uma dica de que a tarefa não será concluída rapidamente, e, portanto, talvez seja melhor não retirar uma thread do pool, mas criar uma separada para essa Task para não prejudicar as demais.
- AttachedToParent — as Tasks podem ser organizadas em hierarquias. Se essa opção foi usada, a Task pode estar em um estado em que ela foi concluída e está aguardando a conclusão das filhas.
- PreferFairness — significa que é preferível executar Tasks enviadas para execução antes daquelas que foram enviadas depois. Mas isso é apenas uma recomendação e o resultado não é garantido.
O segundo parâmetro do método é o CancellationToken. Para que a operação seja corretamente cancelada após seu início, o código a ser executado deve conter verificações do estado do CancellationToken. Se não houver verificações, o método Cancel chamado no objeto CancellationTokenSource só poderá interromper a execução da Task antes de seu início.
O último parâmetro é um objeto scheduler do tipo TaskScheduler. Esta classe e seus herdeiros são projetados para gerenciar estratégias de distribuição de Tasks entre threads; por padrão, a Task será executada em uma thread aleatória do pool.
A Task criada é aplicada com o operador await, o que significa que o código escrito após ele, se houver, será executado no mesmo contexto (normalmente isso significa que será na mesma thread) que o código antes do await.
O método é marcado como async void, o que significa que é permitido o uso do operador await, mas o código chamador não poderá aguardar sua conclusão. Se essa possibilidade for necessária, o método deve retornar um Task. Métodos marcados como async void são bastante comuns: geralmente, são manipuladores de eventos ou outros métodos que funcionam com o princípio de executar e esquecer (fire and forget). Se for necessário não apenas permitir esperar a conclusão da execução, mas também retornar um resultado, deve-se usar Task.
Em uma Task retornada pelo método StartNew, assim como em qualquer outra, pode-se chamar o método ConfigureAwait com o parâmetro false; assim, a execução após o await continuará não no contexto capturado, mas em um qualquer. Isso deve ser feito sempre que o contexto de execução não for crucial para o código após o await. Isso também é uma recomendação da MS ao escrever código que será distribuído em forma de biblioteca.
Vamos parar um pouco para discutir como podemos aguardar a conclusão de uma Task. Veja abaixo um exemplo de código, com comentários sobre quando a espera é feita de forma condicionalmente boa e quando de forma condicionalmente ruim.
public static async void AnotherMethod() {
int result = await AsyncMethod(); // bom
result = AsyncMethod().Result; // ruim
AsyncMethod().Wait(); // ruim
IEnumerable tasks = new Task[] {
AsyncMethod(), OtherAsyncMethod()
};
await Task.WhenAll(tasks); // bom
await Task.WhenAny(tasks); // bom
Task.WaitAll(tasks.ToArray()); // ruim
}
No primeiro exemplo, aguardamos a conclusão da Task sem bloquear a thread chamadora; o processamento do resultado será retomado apenas quando ele já estiver disponível, até então a thread chamadora fica livre.
No segundo caso, bloqueamos o thread chamador até que o resultado do método seja calculado. Isso é ruim não apenas porque ocupamos um thread, um recurso tão valioso para o programa, com uma simples inatividade, mas também porque, se houver um await no código do método que estamos chamando e o contexto de sincronização pressupõe o retorno ao thread chamador após o await, teremos um deadlock: o thread chamador espera que o resultado do método assíncrono seja calculado, enquanto o método assíncrono tenta inutilmente continuar sua execução no thread chamador.
Outro inconveniente dessa abordagem é o tratamento de erros mais complicado. A questão é que os erros no código assíncrono ao usar async/await são muito fáceis de serem tratados — eles se comportam como se o código fosse síncrono. Enquanto, se aplicarmos uma espera síncrona ao Task, a exceção original é envolta em AggregateException. Portanto, para tratar a exceção, será necessário investigar o tipo de InnerException e escrever uma cadeia de if dentro de um único bloco catch ou usar a construção catch when, em vez da mais familiar cadeia de blocos catch no mundo C#.
O terceiro e último exemplo também é marcado por problemas semelhantes e contém todos os mesmos problemas.
Os métodos WhenAny e WhenAll são extremamente úteis para aguardar um grupo de Tasks, eles envolvem um grupo de Tasks em um único, que será acionado assim que o primeiro Task do grupo for completado ou quando todos terminarem sua execução.
Parada de threads
Por várias razões, pode haver a necessidade de parar um thread após seu início. Para isso, existem várias maneiras. A classe Thread possui dois métodos com nomes apropriados — são Abort e Interrupt. O primeiro é extremamente desaconselhável, pois após sua chamada, em qualquer momento aleatório, durante o processamento de qualquer instrução, uma exceção será lançada. ThreadAbortedException. Você certamente não espera que tal exceção ocorra ao incrementar uma variável inteira, espera? Mas ao usar esse método, essa é uma situação bem real. Caso seja necessário proibir o CLR de gerar essa exceção em uma determinada parte do código, pode-se envolvê-la nas chamadas Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Esses desafios são comuns a qualquer código escrito em um bloco finally. Por esse motivo, no código do framework, você pode encontrar blocos com try vazio, mas não finally vazio. A Microsoft desaconselha tanto o uso desse método que não o incluiu no .net core.
O método Interrupt funciona de forma mais previsível. Ele pode interromper a thread com uma exceção. ThreadInterruptedException apenas nos momentos em que a thread está em estado de espera. Ela entra nesse estado ao aguardar um WaitHandle, lock ou após a chamada de Thread.Sleep.
Ambas as opções descritas acima são problemáticas por sua imprevisibilidade. A solução é utilizar a estrutura CancellationToken e a classe CancellationTokenSource. A ideia é a seguinte: uma instância da classe CancellationTokenSource é criada e somente quem a possui pode interromper a operação chamando o método Cancel. Na operação, é passado apenas o CancellationToken. Os proprietários do CancellationToken não podem cancelar a operação por conta própria, apenas verificar se a operação foi cancelada. Para isso, existe a propriedade boolean IsCancellationRequested e o método ThrowIfCancelRequested. O último gerará uma exceção TaskCancelledException se o método Cancel for chamado na instância de CancellationTokenSource que estava associada ao CancellationToken. E é esse método que eu recomendo usar. Ele proporciona um controle total sobre em quais momentos a operação pode ser interrompida com uma exceção.
A maneira mais drástica de parar uma thread é chamando a função da API Win32 TerminateThread. O comportamento do CLR após a chamada a essa função pode ser imprevisível. No MSDN, sobre essa função, está escrito o seguinte: “TerminateThread é uma função perigosa que deve ser usada apenas em casos extremos.”
Transformando legacy-API em Task Based com o método FromAsync.
Se você teve a sorte de trabalhar em um projeto que começou após a introdução de Tasks, minimizando o terror silencioso da maioria dos desenvolvedores, então você não precisará lidar com uma grande quantidade de APIs antigas, tanto de terceiros quanto aquelas complicadas pela sua equipe no passado. Felizmente, a equipe de desenvolvimento do .NET Framework pensou em nós, embora o objetivo possa ter sido cuidar de si mesma. De qualquer forma, no .NET há uma série de ferramentas para a conversão sem dores de código escrito em abordagens antigas de programação assíncrona para novas. Uma delas é o método FromAsync da TaskFactory. No exemplo de código abaixo, estou encapsulando antigos métodos assíncronos da classe WebRequest em um Task usando este método.
object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
wr.BeginGetResponse,
wr.EndGetResponse
);
Este é apenas um exemplo, e é improvável que você tenha que fazer isso com tipos embutidos, mas qualquer projeto antigo está repleto de métodos BeginDoSomething que retornam IAsyncResult e métodos EndDoSomething que os aceitam.
Conversão de APIs legadas em Tasks com a classe TaskCompletionSource
Outra ferramenta importante a ser considerada é a classe TaskCompletionSource. Em termos de funções, propósitos e princípios de operação, ela pode lembrar o método RegisterWaitForSingleObject da classe ThreadPool, que mencionei anteriormente. Com essa classe, você pode facilmente e de forma conveniente encapsular APIs assíncronas antigas em Tasks.
Você pode dizer que já falei sobre o método FromAsync da classe TaskFactory, que é destinado a esses fins. Aqui, é preciso lembrar toda a história do desenvolvimento de modelos assíncronos no .NET que a Microsoft propôs nos últimos 15 anos: antes do Task-Based Asynchronous Pattern (TAP), existia o Asynchronous Programming Pattern (APP), que tratava dos métodos BeginDoSomething que retornam IAsyncResult e métodos EndDoSomething que os aceitam, e para legados daquela época, o método FromAsync se encaixa perfeitamente, mas com o tempo, ele foi substituído pelo Event Based Asynchronous Pattern (EAP), que supunha que após a conclusão da operação assíncrona, um evento seria acionado.
O TaskCompletionSource é ideal para encapsular Task de APIs legadas construídas em torno de um modelo orientado a eventos. Seu funcionamento é o seguinte: um objeto dessa classe possui uma propriedade pública do tipo Task, cujo estado pode ser controlado através dos métodos SetResult, SetException, e outros. Nos locais onde o operador await foi aplicado a essa Task, ele será concluído ou porá em erro com uma exceção, dependendo do método aplicado ao TaskCompletionSource. Se ainda não estiver claro, vejamos este exemplo de código, onde uma antiga API dos tempos do EAP é encapsulada em uma Task usando o TaskCompletionSource: quando o evento ocorre, a Task será colocada no estado Completed, e o método que aplicou o operador await a essa Task retomará sua execução recebendo um objeto. result.
public static Task<Result> DoAsync(this SomeApiInstance someApiObj) {
var completionSource = new TaskCompletionSource<Result>();
someApiObj.Done +=
result => completionSource.SetResult(result);
someApiObj.Do();
return completionSource.Task;
}
Dicas & Truques do TaskCompletionSource
Encapsular APIs antigas não é tudo o que se pode fazer com o TaskCompletionSource. O uso dessa classe abre uma possibilidade interessante de projetar diferentes APIs baseadas em Task que não ocupam threads. E, como sabemos, uma thread é um recurso caro e sua quantidade é limitada (principalmente pela memória RAM). Essa limitação pode ser facilmente alcançada ao desenvolver, por exemplo, um aplicativo web exigente com lógica de negócios complexa. Vamos considerar as possibilidades que menciono na implementação desse truque chamado Long-Polling.
Resumidamente, a essência do truque é a seguinte: você precisa obter informações da API sobre certos eventos que ocorrem do lado dela, mas a API, por algum motivo, não pode notificar sobre o evento, podendo apenas retornar o estado. Exemplos disso são todas as APIs construídas sobre HTTP antes da era do WebSocket ou quando não é possível, por algum motivo, utilizar essa tecnologia. O cliente pode consultar o servidor HTTP. O servidor HTTP não pode iniciar a comunicação com o cliente. Uma solução simples é a sondagem do servidor por um cronômetro, mas isso cria uma carga adicional no servidor e um atraso adicional em média de TimerInterval / 2. Para contornar isso, foi inventado um truque chamado Long Polling, que implica um atraso na resposta do servidor até que o Timeout expire ou que um evento ocorra. Se o evento ocorreu, ele é processado; se não, a solicitação é enviada novamente.
while(!eventOccures && !timeoutExceeded) {
CheckTimout();
CheckEvent();
Thread.Sleep(1);
}
Mas essa solução se mostrará horrível assim que o número de clientes aguardando um evento aumentar, pois cada cliente nesse estado ocupa um thread inteiro. Além disso, obtemos um atraso adicional de 1ms na ativação do evento; geralmente isso não é significativo, mas por que fazer o software pior do que pode ser? Se removermos o Thread.Sleep(1), sobrecarregaremos um núcleo da CPU em 100% girando em um ciclo inútil. Com o TaskCompletionSource, é fácil reescrever esse código e resolver todos os problemas mencionados acima:
class LongPollingApi {
private Dictionary<int, TaskCompletionSource> tasks;
public async Task AcceptMessageAsync(int userId, int duration) {
var cs = new TaskCompletionSource();
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);
}
}
Esse código não é adequado para produção, sendo apenas uma demonstração. Para uso em casos reais, é necessário, no mínimo, lidar com a situação em que a mensagem chega no momento em que ninguém a aguarda: nesse caso, o método AcceptMessageAsync deve retornar um Task já completo. Se esse caso for o mais frequente, pode-se considerar também o uso do ValueTask.
Ao receber uma solicitação de mensagem, criamos e inserimos em um dicionário TaskCompletionSource, e então esperamos o que acontecerá primeiro: o intervalo de tempo especificado expirar ou uma mensagem ser recebida.
ValueTask: por que e como
Os operadores async/await, assim como o operador yield return, geram uma máquina de estados a partir do método, o que resulta na criação de um novo objeto. Isso quase sempre não é importante, mas em casos raros pode causar problemas. Esse caso pode ser um método que é realmente chamado com frequência, envolvendo dezenas e centenas de milhares de chamadas por segundo. Se tal método for escrito de forma que na maioria das vezes ele retorne um resultado sem passar por todos os métodos await, o .NET fornece uma ferramenta para otimizar isso — a estrutura ValueTask. Para entender melhor, vejamos um exemplo de seu uso: temos um cache que acessamos com muita frequência. Alguns valores já podem estar presentes, e nesse caso, os retornamos imediatamente; se não estiverem, vamos atrás deles em algum IO lento. O último é desejável que seja feito de forma assíncrona, tornando todo o método assíncrono. Assim, uma maneira óbvia de escrever o método é a seguinte:
public async Task<string> GetById(int id) {
if (cache.TryGetValue(id, out string val))
return val;
return await RequestById(id);
}
Por causa do desejo de otimizar um pouco e do leve receio sobre o que o Roslyn gerará ao compilar este código, podemos reescrever esse exemplo da seguinte forma:
public Task<string> GetById(int id) {
if (cache.TryGetValue(id, out string val))
return Task.FromResult(val);
return RequestById(id);
}
Na verdade, a solução mais otimizada neste caso será otimizar o caminho crítico, ou seja, obter o valor do dicionário sem alocações desnecessárias e carga no GC, enquanto nos casos raros em que realmente precisamos ir ao IO em busca de dados, tudo permanecerá mais ou menos como antes:
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 analisar esse fragmento de código em detalhes: na presença de um valor no cache, criamos uma estrutura; caso contrário, a tarefa real será envolvida em um valor significativo. O código que chama não se importa com o caminho que o código percorreu: o ValueTask, do ponto de vista da sintaxe C#, se comportará da mesma forma que um Task comum nesse caso.
TaskSchedulers: gerenciamento de estratégias de execução de Tasks
A próxima API que gostaríamos de considerar é a classe TaskScheduler e suas derivadas. Já mencionei anteriormente que o TPL permite gerenciar as estratégias de distribuição de Tasks entre threads. Essas estratégias são definidas nos subclasses da classe TaskScheduler. Praticamente qualquer estratégia que você precisar será encontrada na biblioteca ParallelExtensionsExtras, desenvolvida pela Microsoft, mas que não é parte do .NET, e é fornecida na forma de pacote Nuget. Vejamos brevemente algumas delas:
- CurrentThreadTaskScheduler — executa Tasks na thread atual
- LimitedConcurrencyLevelTaskScheduler — limita o número de Tasks executadas simultaneamente com o parâmetro N, que é recebido no construtor
- OrderedTaskScheduler — é definido como LimitedConcurrencyLevelTaskScheduler(1), portanto, as tarefas serão executadas sequencialmente.
- WorkStealingTaskScheduler — implementa a abordagem de distribuição de tarefas. Na essência, é um ThreadPool separado. Resolve o problema de que no .NET, o ThreadPool é uma classe estática, única para todas as aplicações, o que significa que sua sobrecarga ou seu uso inadequado em uma parte do programa pode causar efeitos colaterais em outra. Além disso, entender a causa de tais defeitos é extremamente difícil. Assim, pode haver necessidade de utilizar WorkStealingTaskSchedulers separados em partes do programa onde o uso do ThreadPool pode ser agressivo e imprevisível.
- QueuedTaskScheduler — permite executar tarefas com regras de fila com prioridades
- ThreadPerTaskScheduler — cria uma thread separada para cada Task que está sendo executada. Pode ser útil para tarefas que levam um tempo imprevisível para se completar.
Há um bom e detalhado sobre TaskSchedulers no blog da Microsoft.
Para facilitar a depuração de tudo relacionado a Tasks no Visual Studio, há a janela Tasks. Nessa janela, você pode ver o estado atual da task e ir para a linha de código que está sendo executada no momento.

PLinq e a classe Parallel
Além das Tasks e de tudo o que foi mencionado, o .NET possui duas ferramentas interessantes: PLinq (Linq2Parallel) e a classe Parallel. A primeira promete a execução paralela de todas as operações Linq em vários threads. O número de threads pode ser configurado pelo método de extensão WithDegreeOfParallelism. Infelizmente, muitas vezes o PLinq, em modo padrão, não tem informações suficientes sobre a estrutura do seu fonte de dados para garantir um ganho significativo em velocidade. Por outro lado, o custo da tentativa é muito baixo: é necessário apenas chamar o método AsParallel antes da cadeia de métodos Linq e realizar testes de desempenho. Além disso, existe a possibilidade de fornecer ao PLinq informações adicionais sobre a natureza do seu fonte de dados através do mecanismo Partitions. Para mais detalhes, você pode ler e .
A classe estática Parallel oferece métodos para a iteração paralela de coleções com Foreach, execução de loops For e execução de vários delegados em paralelo com Invoke. A execução da thread atual será interrompida até que os cálculos sejam concluídos. O número de threads pode ser configurado passando ParallelOptions como o último argumento. Com as opções, também é possível especificar TaskScheduler e CancellationToken.
Como resultado, obtemos um serviço de "monitoramento como serviço", automatizado na linha de produção de integração contínua. O desenvolvedor ou testador precisa apenas definir a lista de métricas em um arquivo JSON, e tudo o mais acontece automaticamente. Obtemos um controle de qualidade transparente dos lançamentos: todas as notificações sobre desempenho, consumo de recursos ou regressões arquitetônicas.
Quando comecei a escrever este artigo com base no meu relatório e nas informações que coletei ao longo do tempo após ele, não esperava que ele se tornasse tão extenso. Agora, quando o editor de texto no qual estou digitando me informa com reprovação que já estou na 15ª página, farei um resumo intermediário. Outros truques, APIs, ferramentas visuais e armadilhas serão abordados no próximo artigo.
Conclusões:
- É necessário conhecer as ferramentas de trabalho com threads, assíncrono e paralelismo para utilizar os recursos dos PCs modernos.
- O .NET possui várias ferramentas diferentes para esses fins.
- Nem todas elas surgiram de imediato, pois muitas vezes é possível encontrar legacy, embora existam maneiras de transformar APIs antigas sem muito esforço.
- O trabalho com threads no .NET é representado pelas classes Thread e ThreadPool.
- Os métodos Thread.Abort, Thread.Interrupt e a função Win32 API TerminateThread são perigosos e não são recomendados para uso. Em vez disso, é melhor utilizar o mecanismo de CancellationTokens.
- O fluxo é um recurso valioso, e sua quantidade é limitada. É preciso evitar situações em que os fluxos ficam ocupados aguardando eventos. Para isso, é conveniente utilizar a classe TaskCompletionSource.
- A ferramenta mais poderosa e avançada do .NET para trabalhar com paralelismo e assíncronismo são os Tasks.
- Os operadores c# async/await implementam o conceito de espera não bloqueante.
- É possível gerenciar a distribuição de Tasks entre threads usando classes derivadas do TaskScheduler.
- A estrutura ValueTask pode ser útil na otimização de hot-paths e tráfego de memória.
- As janelas de Tasks e Threads do Visual Studio oferecem muitas informações úteis para depuração de código multithreaded ou assíncrono.
- O PLinq é uma ferramenta incrível, mas pode não ter informações suficientes sobre sua fonte de dados; no entanto, isso pode ser corrigido com o mecanismo de partição.
- Continua…
Fonte: habr.com
