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

Estou publicando o artigo original no Habr, cuja tradução está publicada no site corporativo блоге.

A necessidade de fazer algo de forma assíncrona, sem esperar o resultado aqui e agora, ou de dividir um grande trabalho entre várias unidades que o executam, existia antes do advento dos computadores. Com o seu advento, esta necessidade tornou-se muito tangível. Agora, em 2019, estou digitando este artigo em um laptop com processador Intel Core de 8 núcleos, no qual mais de cem processos estão rodando em paralelo e ainda mais threads. Perto está um telefone um pouco surrado, comprado há alguns anos, que tem um processador de 8 núcleos integrado. Os recursos temáticos estão repletos de artigos e vídeos onde seus autores admiram os principais smartphones deste ano que possuem processadores de 16 núcleos. O MS Azure fornece uma máquina virtual com processador de 20 núcleos e 128 TB de RAM por menos de US$ 2/hora. Infelizmente, é impossível extrair o máximo e aproveitar esse poder sem conseguir gerenciar a interação dos threads.

Vocabulário

Processo - Objeto do sistema operacional, espaço de endereço isolado, contém threads.
Fio - um objeto do sistema operacional, a menor unidade de execução, parte de um processo, threads compartilham memória e outros recursos entre si dentro de um processo.
Multitarefa - Propriedade do sistema operacional, capacidade de executar vários processos simultaneamente
Multi-núcleo - 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 - realizar várias ações fisicamente simultaneamente por unidade de tempo
Assincronia — execução de uma operação sem esperar pela conclusão deste processamento; o resultado da execução pode ser processado posteriormente.

Metáfora

Nem todas as definições são boas e algumas precisam de explicação adicional, por isso acrescentarei uma metáfora sobre preparar o café da manhã à terminologia formalmente introduzida. Preparar o café da manhã nesta metáfora é um processo.

Enquanto preparava o café da manhã eu (CPU) Eu venho para a cozinha (Computador). Eu tenho 2 mãos (Núcleos). Existem vários dispositivos na cozinha (IO): forno, chaleira, torradeira, frigorífico. Ligo o gás, coloco uma frigideira e despejo óleo sem esperar que aqueça (de forma assíncrona, Non-Blocking-IO-Wait), tiro os ovos da geladeira e quebro em um prato, depois bato com uma das mãos (Tópico nº 1), e em segundo lugar (Tópico nº 2) segurando a placa (Recurso Compartilhado). Agora gostaria de ligar a chaleira, mas não tenho mãos suficientes (Fome de linha) Durante esse tempo, aquece a frigideira (Processando o resultado) na qual despejo o que bati. Pego a chaleira, ligo-a e estupidamente observo a água ferver nela (Bloqueio-IO-Espere), embora durante esse tempo pudesse ter lavado o prato onde bateu a omelete.

Fiz uma omelete com apenas 2 mãos, e não tenho mais, mas ao mesmo tempo, na hora de bater a omelete, aconteceram 3 operações ao mesmo tempo: bater a omelete, segurar o prato, aquecer a frigideira A CPU é a parte mais rápida do computador, IO é o que mais frequentemente tudo fica lento, então muitas vezes uma solução eficaz é ocupar a CPU com algo enquanto recebe dados de IO.

Continuando a metáfora:

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

Ferramentas .NET

O .NET é bom para trabalhar com threads, assim como muitas outras coisas. A cada nova versão, ele introduz cada vez mais novas ferramentas para trabalhar com eles, novas camadas de abstração sobre threads do sistema operacional. Ao trabalhar com a construção de abstrações, os desenvolvedores de frameworks utilizam uma abordagem que deixa a oportunidade, ao utilizar uma abstração de alto nível, de descer um ou mais níveis abaixo. Na maioria das vezes isso não é necessário, na verdade abre a porta para atirar no próprio pé com uma espingarda, mas às vezes, em casos raros, pode ser a única maneira de resolver um problema que não é resolvido no atual nível de abstração .

Por ferramentas, quero dizer tanto interfaces de programação de aplicativos (APIs) fornecidas pela estrutura e pacotes de terceiros, quanto soluções de software completas que simplificam a busca por quaisquer problemas relacionados ao código multithread.

Iniciando um tópico

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

O delegado será executado no thread recém-criado após chamar o método Start. Se um delegado do tipo ParametrizedThreadStart foi passado para o construtor, então um objeto deve ser passado para o método Start. Este mecanismo é necessário para transferir qualquer informação local para o fluxo. É importante notar que criar um thread é uma operação cara, e o thread em si é um objeto pesado, pelo menos porque aloca 1 MB 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 peça de engenharia, e os desenvolvedores da Microsoft se esforçaram muito para garantir que ele funcionasse de maneira ideal em uma ampla variedade de cenários.

Conceito geral:

A partir do momento em que o aplicativo é iniciado, ele cria vários threads de reserva em segundo plano e oferece a capacidade de utilizá-los. Se os threads forem usados ​​com frequência e em grande número, o pool se expandirá para atender às necessidades do chamador. Quando não há threads livres no pool no momento certo, ele aguardará o retorno de um dos threads ou criará um novo. Conclui-se que o pool de threads é ótimo para algumas ações de curto prazo e pouco adequado para operações executadas como serviços durante toda a operação do aplicativo.

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

ThreadPool.QueueUserWorkItem(...);

O método de pool de threads menos conhecido RegisterWaitForSingleObject é usado para organizar operações de IO sem bloqueio. O delegado passado para este método será chamado quando o WaitHandle passado para o método for “Released”.

ThreadPool.RegisterWaitForSingleObject(...)

O .NET possui um temporizador de thread e difere dos temporizadores WinForms/WPF porque seu manipulador será chamado em um thread retirado do pool.

System.Threading.Timer

Há também uma maneira bastante exótica de enviar um delegado para execução a um thread do pool - o método BeginInvoke.

DelegateInstance.BeginInvoke

Gostaria de me debruçar brevemente sobre a função para a qual muitos dos métodos acima podem ser chamados - CreateThread da API Kernel32.dll Win32. Existe uma maneira, graças ao mecanismo de métodos externos, de chamar esta função. Vi tal chamada apenas uma vez em um exemplo terrível de código legado, e a motivação do autor que fez exatamente isso ainda permanece um mistério para mim.

Kernel32.dll CreateThread

Visualizando e depurando threads

Threads criados por você, todos os componentes de terceiros e o pool .NET podem ser visualizados na janela Threads do Visual Studio. Esta janela só exibirá informações do thread quando o aplicativo estiver em depuração e no modo Break. Aqui você pode visualizar convenientemente os nomes das pilhas e as prioridades de cada thread e alternar a depuração para um thread específico. Usando a propriedade Priority da classe Thread, você pode definir a prioridade de um thread, que o OC e o CLR perceberão como uma recomendação ao dividir o tempo do processador entre threads.

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

Biblioteca Paralela de Tarefas

A Biblioteca Paralela de Tarefas (TPL) foi introduzida 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 namespace System.Threading.Tasks. Uma tarefa é uma abstração sobre um thread. Com a nova versão da linguagem C#, obtivemos uma maneira elegante de trabalhar com Tarefas - operadores assíncronos/esperados. Esses conceitos possibilitaram escrever código assíncrono como se fosse simples e síncrono, isso possibilitou até mesmo pessoas com pouco conhecimento do funcionamento interno das threads escreverem aplicações que as utilizam, aplicações que não congelam ao realizar operações longas. Usar async/await é um tópico para um ou até vários artigos, mas tentarei entender a essência em algumas frases:

  • async é um modificador de um método que retorna Task ou void
  • e await é um operador de espera de tarefa sem bloqueio.

Mais uma vez: o operador await, no caso geral (há exceções), irá liberar ainda mais o thread atual de execução, e quando a Task terminar sua execução, e o thread (na verdade, seria mais correto dizer o contexto , mas falaremos mais sobre isso mais tarde) continuará executando o método. Dentro do .NET, esse mecanismo é implementado da mesma forma que o retorno de rendimento, quando o 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 asynс/await, compilar e visualizar o assembly usando JetBrains dotPeek com o código gerado pelo compilador habilitado.

Vejamos as opções para iniciar e usar o Task. No exemplo de código abaixo, criamos uma nova tarefa que não faz nada de útil (Thread.Sleep (10000)), mas na vida real isso deve ser um trabalho complexo que exige muita 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 diversas 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 outras pessoas.
  • AttachedToParent - As tarefas podem ser organizadas em uma hierarquia. Se esta opção for usada, então a Tarefa poderá estar em um estado em que ela mesma foi concluída e aguarda a execução de seus filhos.
  • PreferFairness - significa que seria melhor executar as tarefas enviadas para execução mais cedo do que aquelas enviadas posteriormente. Mas esta é apenas uma recomendação e os resultados não são garantidos.

O segundo parâmetro passado para o método é CancellationToken. Para lidar corretamente com o cancelamento de uma operação após seu início, o código que está sendo executado deve ser preenchido com verificações do estado CancellationToken. Se não houver verificações, o método Cancel chamado no objeto CancellationTokenSource poderá interromper a execução da tarefa somente antes de seu início.

O último parâmetro é um objeto agendador do tipo TaskScheduler. Esta classe e seus descendentes são projetados para controlar estratégias de distribuição de tarefas entre threads; por padrão, a tarefa será executada em um thread aleatório do pool.

O operador await é aplicado à tarefa criada, o que significa que o código escrito depois dela, se houver, será executado no mesmo contexto (geralmente isso significa no mesmo thread) que o código antes de await.

O método é marcado como async void, o que significa que pode usar o operador await, mas o código de chamada não poderá esperar pela execução. Se tal recurso for necessário, o método deverá retornar Task. Métodos marcados como async void são bastante comuns: via de regra, são manipuladores de eventos ou outros métodos que funcionam no princípio de disparar e esquecer. Se você precisa não apenas dar a oportunidade de esperar até o final da execução, mas também retornar o resultado, então você precisa usar Task.

Na tarefa que o método StartNew retornou, assim como em qualquer outra, você pode chamar o método ConfigureAwait com o parâmetro false, então a execução após await 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 a espera. Esta também é uma recomendação da MS ao escrever código que será entregue empacotado em uma biblioteca.

Vamos nos debruçar um pouco mais sobre como você pode aguardar a conclusão de uma Tarefa. Abaixo está um exemplo de código, com comentários sobre quando a expectativa é feita condicionalmente bem e quando é feita condicionalmente mal.

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, esperamos que a tarefa seja concluída sem bloquear o thread chamador; voltaremos a processar o resultado somente quando ele já estiver lá; até então, o thread chamador é deixado por conta própria.

Na segunda opção, bloqueamos o thread de chamada até que o resultado do método seja calculado. Isso é ruim não apenas porque ocupamos um thread, um recurso tão valioso do programa, com simples ociosidade, mas também porque se o código do método que chamamos contém await, e o contexto de sincronização exige o retorno ao thread de chamada após aguarde, então obteremos um impasse: o thread de chamada espera que o resultado do método assíncrono seja calculado, o método assíncrono tenta em vão continuar sua execução no thread de chamada.

Outra desvantagem desta abordagem é o tratamento complicado de erros. O fato é que erros em código assíncrono ao usar async/await são muito fáceis de tratar - eles se comportam da mesma forma como se o código fosse síncrono. Embora se aplicarmos o exorcismo de espera síncrona a uma tarefa, a exceção original se transforma em uma AggregateException, ou seja, Para lidar com a exceção, você terá que examinar o tipo InnerException e escrever uma cadeia if dentro de um bloco catch ou usar a construção catch when, em vez da cadeia de blocos catch que é mais familiar no mundo C#.

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

Os métodos WhenAny e WhenAll são extremamente convenientes para esperar por um grupo de tarefas; eles agrupam um grupo de tarefas em uma só, que será acionada quando uma tarefa do grupo for acionada pela primeira vez ou quando todas elas tiverem concluído sua execução.

Parando tópicos

Por vários motivos, pode ser necessário interromper o fluxo após seu início. Existem várias maneiras de fazer isso. A classe Thread possui dois métodos nomeados apropriadamente: Abortar и Interromper. O primeiro não é altamente recomendado para uso, porque após chamá-lo em qualquer momento aleatório, durante o processamento de qualquer instrução, uma exceção será lançada ThreadAbortedException. Você não espera que tal exceção seja lançada ao incrementar qualquer variável inteira, certo? E ao usar este método, esta é uma situação muito real. Se você precisar evitar que o CLR gere tal exceção em uma determinada seção do código, você pode envolvê-la em chamadas Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Qualquer código escrito em um bloco final é envolvido nessas chamadas. Por esse motivo, nas profundezas do código do framework você pode encontrar blocos com try vazio, mas não vazio finalmente. A Microsoft desencoraja tanto esse método que não o incluiu no núcleo do .net.

O método Interrupt funciona de maneira mais previsível. Pode interromper o thread com uma exceção ThreadInterruptedException somente durante os momentos em que o thread está em estado de espera. Ele entra nesse estado enquanto espera por WaitHandle, bloqueio ou após chamar Thread.Sleep.

Ambas as opções descritas acima são ruins devido à sua imprevisibilidade. A solução é usar uma estrutura CancelamentoToken e classe CancelamentoTokenSource. A questão é esta: uma instância da classe CancellationTokenSource é criada e somente quem a possui pode interromper a operação chamando o método Cancelar. Somente o CancellationToken é passado para a operação em si. Os proprietários do CancellationToken não podem cancelar a operação por conta própria, mas apenas verificar se a operação foi cancelada. Existe uma propriedade booleana para isso O cancelamento é solicitado e método ThrowIfCancelRequested. Este último lançará uma exceção TaskCanceledException se o método Cancel foi chamado na instância CancellationToken que está sendo reproduzida. E este é o método que recomendo usar. Isto é uma melhoria em relação às opções anteriores, pois obtém controle total sobre em que ponto uma operação de exceção pode ser abortada.

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

Convertendo API legada em tarefa baseada usando o método FromAsync

Se você teve a sorte de trabalhar em um projeto que foi iniciado após a introdução das Tarefas e deixou de causar horror silencioso para a maioria dos desenvolvedores, então você não terá que lidar com muitas APIs antigas, tanto de terceiros quanto de sua equipe. já torturou no passado. Felizmente, a equipe do .NET Framework cuidou de nós, embora talvez o objetivo fosse cuidar de nós mesmos. Seja como for, o .NET possui uma série de ferramentas para converter sem problemas o código escrito em antigas abordagens de programação assíncrona para o novo. Um deles é o método FromAsync do TaskFactory. No exemplo de código abaixo, envolvo os antigos métodos assíncronos da classe WebRequest em uma tarefa usando este 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ê precise fazer isso com tipos integrados, mas qualquer projeto antigo está simplesmente repleto de métodos BeginDoSomething que retornam métodos IAsyncResult e EndDoSomething que o recebem.

Converter API legada em tarefa baseada em classe TaskCompletionSource

Outra ferramenta importante a considerar é a classe Origem da conclusão da tarefa. Em termos de funções, finalidade e princípio de operação, pode lembrar um pouco o método RegisterWaitForSingleObject da classe ThreadPool, sobre o qual escrevi acima. Usando esta classe, você pode agrupar APIs assíncronas antigas de maneira fácil e conveniente em Tarefas.

Você dirá que já falei sobre o método FromAsync da classe TaskFactory destinado a esses fins. Aqui teremos que relembrar toda a história do desenvolvimento de modelos assíncronos em .net que a Microsoft ofereceu nos últimos 15 anos: antes do Padrão Assíncrono Baseado em Tarefas (TAP), existia o Padrão de Programação Assíncrona (APP), que era sobre métodos ComeçarFaça algo retornando IAsyncResult e métodos TerminarDoSomething que o aceita e para o legado desses anos o método FromAsync é simplesmente perfeito, mas com o tempo foi substituído pelo Event Based Asynchronous Pattern (EAP), que presumia que um evento seria gerado quando a operação assíncrona fosse concluída.

TaskCompletionSource é perfeito para agrupar tarefas e APIs legadas construídas em torno do modelo de evento. A essência de seu trabalho é a seguinte: um objeto desta classe possui uma propriedade pública do tipo Task, cujo estado pode ser controlado através dos métodos SetResult, SetException, etc. Nos locais onde o operador await foi aplicado a esta Task, ele será executado ou falhará com uma exceção dependendo do método aplicado ao TaskCompletionSource. Se ainda não estiver claro, vejamos este exemplo de código, onde alguma API EAP antiga é agrupada em uma tarefa usando um TaskCompletionSource: quando o evento for acionado, a tarefa será transferida para o estado Concluído e o método que aplicou o operador await para esta Tarefa retomará sua execução tendo recebido o objeto resultar.

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 do TaskCompletionSource

Agrupar APIs antigas não é tudo o que pode ser feito usando TaskCompletionSource. O uso desta classe abre uma possibilidade interessante de projetar 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 quantidade de RAM). Essa limitação pode ser facilmente alcançada desenvolvendo, por exemplo, uma aplicação web carregada com lógica de negócios complexa. Vamos considerar as possibilidades de que estou falando ao implementar um truque como o Long-Polling.

Resumindo, a essência do truque é esta: você precisa receber informações da API sobre alguns eventos que ocorrem em seu lado, enquanto a API, por algum motivo, não pode relatar o evento, mas apenas retornar o estado. Um exemplo disso são todas as APIs construídas sobre HTTP antes dos tempos do WebSocket ou quando era impossível por algum motivo usar essa tecnologia. O cliente pode perguntar ao servidor HTTP. O servidor HTTP não pode iniciar a comunicação com o cliente. Uma solução simples é pesquisar o servidor usando um 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 de o servidor até que o tempo limite expire ou um evento ocorra. Se o evento ocorreu, ele é processado; caso contrário, a solicitação é enviada novamente.

while(!eventOccures && !timeoutExceeded)  {

  CheckTimout();
  CheckEvent();
  Thread.Sleep(1);
}

Mas tal solução revelar-se-á péssima assim que aumentar o número de clientes à espera do evento, porque... Cada um desses clientes ocupa um thread inteiro aguardando um evento. Sim, e obtemos um atraso adicional de 1 ms quando o evento é acionado, na maioria das vezes isso não é significativo, mas por que tornar o software pior do que pode ser? Se removermos Thread.Sleep(1), em vão carregaremos um núcleo do processador 100% ocioso, girando em um ciclo inútil. Usando TaskCompletionSource você pode facilmente refazer este 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 utilizá-lo em casos reais, você também precisa, no mínimo, lidar com a situação em que uma mensagem chega em um momento em que ninguém a espera: neste caso, o método AsseptMessageAsync deve retornar uma Task já concluída. Se este for o caso mais comum, então você pode pensar em usar ValueTask.

Quando recebemos uma solicitação de mensagem, criamos e colocamos TaskCompletionSource no dicionário e, em seguida, esperamos o que acontece primeiro: o intervalo de tempo especificado expira ou uma mensagem é recebida.

ValueTask: por que e como

Os operadores async/await, assim como o operador yield return, geram uma máquina de estado a partir do método, e esta é a criação de um novo objeto, o 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, estamos falando de dezenas e centenas de milhares de chamadas por segundo. Se tal método for escrito de forma que na maioria dos casos retorne um resultado ignorando todos os métodos de espera, o .NET fornece uma ferramenta para otimizar isso - a estrutura ValueTask. Para que fique claro, vejamos um exemplo da sua utilização: existe uma cache que visitamos com muita frequência. Existem alguns valores nele e então simplesmente os retornamos; se não, então vamos para algum IO lento para obtê-los. Quero fazer o último de forma assíncrona, o que significa que todo o método acaba sendo assíncrono. Assim, a 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);
}

Devido ao desejo de otimizar um pouco, e um leve medo do que Roslyn irá gerar ao compilar este código, você pode reescrever este 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 ideal neste caso seria otimizar o hot-path, ou seja, obter um valor do dicionário sem quaisquer alocações e cargas desnecessárias no GC, enquanto nos raros casos em que ainda precisamos ir ao IO para obter dados , tudo permanecerá um sinal de mais/menos da maneira antiga:

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 neste trecho de código: se houver um valor no cache, criamos uma estrutura, caso contrário, a tarefa real será envolvida em uma tarefa significativa. O código de chamada não se importa em qual caminho esse código foi executado: ValueTask, do ponto de vista da sintaxe C#, se comportará da mesma forma que uma tarefa normal neste caso.

TaskSchedulers: gerenciando estratégias de lançamento de tarefas

A próxima API que gostaria de considerar é a classe Agendador de tarefas e seus derivados. Já mencionei acima que o TPL tem a capacidade de gerenciar estratégias para distribuição de tarefas entre threads. Tais estratégias são definidas nos descendentes da classe TaskScheduler. Quase todas as estratégias de que você precisa podem ser encontradas na biblioteca. Extensões ParalelasExtras, desenvolvido pela Microsoft, mas não faz parte do .NET, mas é fornecido como um pacote Nuget. Vejamos brevemente alguns deles:

  • CurrentThreadTaskScheduler — executa tarefas no thread atual
  • Agendador de tarefas de nível de simultaneidade limitado — limita o número de tarefas executadas simultaneamente pelo parâmetro N, que é aceito no construtor
  • OrderedTaskScheduler — é definido como LimitedConcurrencyLevelTaskScheduler(1), portanto as tarefas serão executadas sequencialmente.
  • WorkStealingTaskScheduler - implementos roubo de trabalho abordagem para distribuição de tarefas. Essencialmente, é um ThreadPool separado. Resolve o problema de que no .NET ThreadPool é uma classe estática, uma para todas as aplicações, o que significa que sua sobrecarga ou uso incorreto em uma parte do programa pode gerar efeitos colaterais em outra. Além disso, é extremamente difícil compreender a causa de tais defeitos. Que. Pode haver necessidade de usar WorkStealingTaskSchedulers separados em partes do programa onde o uso de ThreadPool pode ser agressivo e imprevisível.
  • QueuedTaskScheduler — permite que você execute tarefas de acordo com regras de fila de prioridade
  • ThreadPerTaskScheduler — cria um thread separado para cada tarefa executada nele. Pode ser útil para tarefas que levam um tempo imprevisivelmente longo para serem concluídas.

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

Para uma depuração conveniente de tudo relacionado a Tarefas, o Visual Studio possui uma janela Tarefas. Nesta janela você pode ver o estado atual da tarefa e pular para a linha de código em execução no momento.

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

PLinq e a classe Paralela

Além das Tarefas e de tudo o que é dito sobre elas, existem mais duas ferramentas interessantes no .NET: PLinq (Linq2Parallel) e a classe Parallel. A primeira promete execução paralela de todas as operações do Linq em múltiplos threads. O número de threads pode ser configurado usando o método de extensão WithDegreeOfParallelism. Infelizmente, na maioria das vezes o PLinq em seu modo padrão não possui informações suficientes sobre os internos de sua fonte de dados para fornecer um ganho significativo de velocidade, por outro lado, o custo de tentativa é muito baixo: basta chamar o método AsParallel antes a cadeia de métodos Linq e executar testes de desempenho. Além disso, é possível passar informações adicionais ao PLinq sobre a natureza da sua fonte de dados usando o mecanismo Partições. Você pode ler mais aqui и aqui.

A classe estática Parallel fornece métodos para iterar por meio de uma coleção Foreach em paralelo, executar um loop For e executar vários delegados em Invoke paralelo. A execução do thread atual será interrompida até que os cálculos sejam concluídos. O número de threads pode ser configurado passando ParallelOptions como último argumento. Você também pode especificar TaskScheduler e CancellationToken usando opções.

Descobertas

Quando comecei a escrever este artigo com base nos materiais do meu relatório e nas informações que coletei durante meu trabalho posterior, não esperava que houvesse tanto. Agora, quando o editor de texto no qual estou digitando este artigo me disser com reprovação que a página 15 desapareceu, resumirei os resultados provisórios. Outros truques, APIs, ferramentas visuais e armadilhas serão abordados no próximo artigo.

Conclusões:

  • Você precisa conhecer as ferramentas para trabalhar com threads, assincronia e paralelismo para utilizar os recursos dos PCs modernos.
  • O .NET possui muitas ferramentas diferentes para esses fins
  • Nem todos eles apareceram de uma vez, então muitas vezes você pode encontrar alguns legados, no entanto, existem maneiras de converter APIs antigas sem muito esforço.
  • Trabalhar com threads em .NET é representado pelas classes Thread e ThreadPool
  • Os métodos Thread.Abort, Thread.Interrupt e Win32 API TerminateThread são perigosos e não são recomendados para uso. Em vez disso, é melhor usar o mecanismo CancellationToken
  • O fluxo é um recurso valioso e seu fornecimento é limitado. Devem ser evitadas situações em que os threads estejam ocupados aguardando eventos. Para isso é conveniente utilizar a classe TaskCompletionSource
  • As ferramentas .NET mais poderosas e avançadas para trabalhar com paralelismo e assincronia são Tarefas.
  • Os operadores c# async/await implementam o conceito de espera sem bloqueio
  • Você pode controlar a distribuição de tarefas entre threads usando classes derivadas de TaskScheduler
  • A estrutura ValueTask pode ser útil na otimização de hot-paths e tráfego de memória
  • As janelas Tarefas e Threads do Visual Studio fornecem muitas informações úteis para depurar código multithread ou assíncrono
  • PLinq é uma ferramenta legal, mas pode não ter informações suficientes sobre sua fonte de dados, mas isso pode ser corrigido usando o mecanismo de particionamento
  • Para ser continuado ...

Fonte: habr.com

Adicionar um comentário