Programação assíncrona em C #: como você está se saindo com o desempenho?

Mais recentemente, já falamos sobre substituir Equals e GetHashCode ao programar em C #. Hoje vamos lidar com os parâmetros de desempenho de métodos assíncronos. Inscreva-se agora!



Nos dois últimos artigos do blog msdn, examinamos a estrutura interna dos métodos assíncronos em C # e os pontos de extensão que o compilador C # fornece para controlar o comportamento dos métodos assíncronos.

Com base nas informações do primeiro artigo, o compilador realiza muitas transformações para tornar a programação assíncrona o mais semelhante possível à síncrona. Para fazer isso, ele cria uma instância da máquina de estado, a transmite ao construtor do método assíncrono, que chama o objeto de garçom para a tarefa etc. É claro que essa lógica tem um preço, mas quanto nos custa?

Até a biblioteca TPL aparecer, as operações assíncronas não eram usadas em uma quantidade tão grande; portanto, os custos não eram altos. Hoje, porém, mesmo um aplicativo relativamente simples pode executar centenas, se não milhares, de operações assíncronas por segundo. A biblioteca de tarefas paralelas TPL foi criada com essa carga de trabalho em mente, mas não há mágica aqui e você precisa pagar por tudo.

Para estimar os custos dos métodos assíncronos, usaremos um exemplo ligeiramente modificado do primeiro artigo.

public class StockPrices { private const int Count = 100; private List<(string name, decimal price)> _stockPricesCache; // Async version public async Task<decimal> GetStockPriceForAsync(string companyId) { await InitializeMapIfNeededAsync(); return DoGetPriceFromCache(companyId); } // Sync version that calls async init public decimal GetStockPriceFor(string companyId) { InitializeMapIfNeededAsync().GetAwaiter().GetResult(); return DoGetPriceFromCache(companyId); } // Purely sync version public decimal GetPriceFromCacheFor(string companyId) { InitializeMapIfNeeded(); return DoGetPriceFromCache(companyId); } private decimal DoGetPriceFromCache(string name) { foreach (var kvp in _stockPricesCache) { if (kvp.name == name) { return kvp.price; } } throw new InvalidOperationException($"Can't find price for '{name}'."); } [MethodImpl(MethodImplOptions.NoInlining)] private void InitializeMapIfNeeded() { // Similar initialization logic. } private async Task InitializeMapIfNeededAsync() { if (_stockPricesCache != null) { return; } await Task.Delay(42); // Getting the stock prices from the external source. // Generate 1000 items to make cache hit somewhat expensive _stockPricesCache = Enumerable.Range(1, Count) .Select(n => (name: n.ToString(), price: (decimal)n)) .ToList(); _stockPricesCache.Add((name: "MSFT", price: 42)); } } 

A classe StockPrices preços das ações de uma fonte externa e permite solicitá-los por meio da API. A principal diferença do exemplo no primeiro artigo é a transição de um dicionário para uma lista de preços. Para estimar os custos de vários métodos assíncronos em comparação com os métodos síncronos, a própria operação deve executar um determinado trabalho; no nosso caso, é uma busca linear pelos preços das ações.

O método GetPricesFromCache intencionalmente criado em torno de um loop simples para evitar a alocação de recursos.

Comparação de métodos síncronos e métodos assíncronos baseados em tarefas


No primeiro teste de desempenho, comparamos o método assíncrono que chama o método de inicialização assíncrona ( GetStockPriceForAsync ), o método síncrono que chama o método de inicialização assíncrona ( GetStockPriceFor ) e o método síncrono que chama o método de inicialização síncrona.

 private readonly StockPrices _stockPrices = new StockPrices(); public SyncVsAsyncBenchmark() { // Warming up the cache _stockPrices.GetStockPriceForAsync("MSFT").GetAwaiter().GetResult(); } [Benchmark] public decimal GetPricesDirectlyFromCache() { return _stockPrices.GetPriceFromCacheFor("MSFT"); } [Benchmark(Baseline = true)] public decimal GetStockPriceFor() { return _stockPrices.GetStockPriceFor("MSFT"); } [Benchmark] public decimal GetStockPriceForAsync() { return _stockPrices.GetStockPriceForAsync("MSFT").GetAwaiter().GetResult(); } 

Os resultados são mostrados abaixo:



Já nesta fase, recebemos dados bastante interessantes:

  • O método assíncrono é bastante rápido. GetPricesForAsync é executado de forma síncrona neste teste e é aproximadamente 15% (*) mais lento que o método puramente síncrono.
  • O método GetPricesFor síncrono, que chama o método InitializeMapIfNeededAsync assíncrono, tem custos ainda mais baixos, mas o mais surpreendente é que ele não aloca recursos (na coluna Alocado na tabela acima, custa 0 para GetPricesDirectlyFromCache e GetStockPriceFor ).

(*) Obviamente, não se pode dizer que os custos de execução síncrona do método assíncrono são de 15% para todos os casos possíveis. Esse valor depende diretamente da carga de trabalho executada pelo método. A diferença entre a sobrecarga de uma invocação pura de um método assíncrono (que não faz nada) e um método síncrono (que não faz nada) será enorme. A idéia desse teste comparativo é mostrar que os custos do método assíncrono, que executa uma quantidade relativamente pequena de trabalho, são relativamente baixos.

Como é que, quando você chama InitializeMapIfNeededAsync , os recursos não são alocados? No primeiro artigo desta série, mencionei que um método assíncrono deve alocar pelo menos um objeto no cabeçalho gerenciado - a própria instância da tarefa. Vamos discutir esse ponto em mais detalhes.

Otimização nº 1: armazenando em cache instâncias de tarefas quando possível


A resposta para a pergunta acima é muito simples: O AsyncMethodBuilder usa uma instância da tarefa para cada operação assíncrona concluída com êxito . O método assíncrono que Task retorna usa AsyncMethodBuilder com a seguinte lógica no método SetResult :

 // AsyncMethodBuilder.cs from mscorlib public void SetResult() { // Ie the resulting task for all successfully completed // methods is the same -- s_cachedCompleted. m_builder.SetResult(s_cachedCompleted); } 

O método SetResult chamado apenas para métodos assíncronos concluídos com êxito, e um resultado bem - sucedido para cada método baseado em Task pode ser usado livremente juntos . Podemos até rastrear esse comportamento com o seguinte teste:

 [Test] public void AsyncVoidBuilderCachesResultingTask() { var t1 = Foo(); var t2 = Foo(); Assert.AreSame(t1, t2); async Task Foo() { } } 

Mas essa não é a única otimização possível. AsyncTaskMethodBuilder<T> otimiza o trabalho de maneira semelhante: ele armazena em cache tarefas para a Task<bool> e alguns outros tipos simples. Por exemplo, ele armazena em cache todos os valores padrão para um grupo de tipos inteiros e usa um cache especial para a Task<int> , colocando valores do intervalo [-1; 9] (para obter mais detalhes, consulte AsyncTaskMethodBuilder<T>.GetTaskForResult() ).

Isso é confirmado pelo seguinte teste:

 [Test] public void AsyncTaskBuilderCachesResultingTask() { // These values are cached Assert.AreSame(Foo(-1), Foo(-1)); Assert.AreSame(Foo(8), Foo(8)); // But these are not Assert.AreNotSame(Foo(9), Foo(9)); Assert.AreNotSame(Foo(int.MaxValue), Foo(int.MaxValue)); async Task<int> Foo(int n) => n; } 

Não confie excessivamente nesse comportamento , mas é sempre bom perceber que os criadores da linguagem e da plataforma estão fazendo todo o possível para aumentar a produtividade de todas as formas disponíveis. O cache de tarefas é um método popular de otimização que também é usado em outras áreas. Por exemplo, uma nova implementação do Socket no repositório corefx repo faz uso extensivo desse método e aplica tarefas em cache sempre que possível.

Otimização # 2: Usando ValueTask


O método de otimização descrito acima funciona apenas em alguns casos. Portanto, em vez disso, podemos usar ValueTask<T> (**), um tipo especial de valor semelhante à tarefa; não alocará recursos se o método for executado de forma síncrona.

ValueTask<T> é uma combinação distinta de T e Task<T> : se a "tarefa-valor" for concluída, o valor base será usado. Se a alocação básica ainda não tiver sido esgotada, os recursos serão alocados para a tarefa.

Esse tipo especial ajuda a impedir o provisionamento excessivo de heap ao executar uma operação de forma síncrona. Para usar o ValueTask<T> , você precisa alterar o tipo de retorno para GetStockPriceForAsync : em vez de Task<decimal> deve especificar ValueTask<decimal> :

 public async ValueTask<decimal> GetStockPriceForAsync(string companyId) { await InitializeMapIfNeededAsync(); return DoGetPriceFromCache(companyId); } 

Agora podemos avaliar a diferença usando um teste comparativo adicional:

 [Benchmark] public decimal GetStockPriceWithValueTaskAsync_Await() { return _stockPricesThatYield.GetStockPriceValueTaskForAsync("MSFT").GetAwaiter().GetResult(); } 



Como você pode ver, a versão com ValueTask é apenas um pouco mais rápida que a versão com Task. A principal diferença é que a alocação de heap é impedida. Em um minuto, discutiremos a viabilidade dessa transição, mas antes disso eu gostaria de falar sobre uma otimização complicada.

Otimização nº 3: abandonando métodos assíncronos em um caminho comum


Se você costuma usar algum método assíncrono e deseja reduzir ainda mais os custos, sugiro a seguinte otimização: remova o modificador assíncrono e verifique o status da tarefa dentro do método e execute a operação inteira de forma síncrona, abandonando completamente as abordagens assíncronas.

Parece complicado? Considere um exemplo.

 public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized(string companyId) { var task = InitializeMapIfNeededAsync(); // Optimizing for acommon case: no async machinery involved. if (task.IsCompleted) { return new ValueTask<decimal>(DoGetPriceFromCache(companyId)); } return DoGetStockPricesForAsync(task, companyId); async ValueTask<decimal> DoGetStockPricesForAsync(Task initializeTask, string localCompanyId) { await initializeTask; return DoGetPriceFromCache(localCompanyId); } } 

Nesse caso, o modificador async não é usado no método GetStockPriceWithValueTaskAsync_Optimized ; portanto, ao receber uma tarefa do método InitializeMapIfNeededAsync , ele verifica seu status de execução. Se a tarefa for concluída, o método simplesmente usará DoGetPriceFromCache para obter o resultado imediatamente. Se a tarefa de inicialização ainda estiver em andamento, o método chama uma função local e aguarda resultados.

Usar uma função local não é a única, mas uma das maneiras mais fáceis. Mas há uma ressalva. Durante a implementação mais natural, a função local receberá um estado externo (variável e argumento local):

 public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized2(string companyId) { // Oops! This will lead to a closure allocation at the beginning of the method! var task = InitializeMapIfNeededAsync(); // Optimizing for acommon case: no async machinery involved. if (task.IsCompleted) { return new ValueTask<decimal>(DoGetPriceFromCache(companyId)); } return DoGetStockPricesForAsync(); async ValueTask<decimal> DoGetStockPricesForAsync() { await task; return DoGetPriceFromCache(companyId); } } 

Infelizmente, devido a um erro do compilador, esse código gerará um fechamento, mesmo se o método for executado no caminho comum. Veja como esse método se parece por dentro:

 public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized(string companyId) { var closure = new __DisplayClass0_0() { __this = this, companyId = companyId, task = InitializeMapIfNeededAsync() }; if (closure.task.IsCompleted) { return ... } // The rest of the code } 

Conforme discutido no artigo Dissecando as funções locais em C # , o compilador usa uma instância comum de fechamento para todas as variáveis ​​e argumentos locais em uma área específica. Consequentemente, há algum sentido nessa geração de código, mas torna inútil toda a luta para alocar pilhas.

DICA . Essa otimização é uma coisa muito insidiosa. Os benefícios são insignificantes e, mesmo se você gravar a função local original correta , poderá obter acidentalmente um estado externo que faz com que o heap seja alocado. Você ainda pode recorrer à otimização se trabalhar com uma biblioteca comumente usada (por exemplo, BCL) em um método que será definitivamente usado em uma seção carregada do código.

Custos associados à espera de uma tarefa


No momento, consideramos apenas um caso específico: a sobrecarga de um método assíncrono que é executado de forma síncrona. Isso é feito de propósito. Quanto menor o método assíncrono, mais perceptíveis são os custos em seu desempenho geral. Métodos assíncronos mais detalhados, como regra, são executados de forma síncrona e executam uma carga de trabalho menor. E costumamos chamá-los com mais frequência.

Mas devemos estar cientes dos custos do mecanismo assíncrono quando o método "espera" a conclusão de uma tarefa pendente. Para estimar esses custos, faremos alterações em InitializeMapIfNeededAsync e chamaremos Task.Yield() mesmo quando o cache for inicializado:

 private async Task InitializeMapIfNeededAsync() { if (_stockPricesCache != null) { await Task.Yield(); return; } // Old initialization logic } 

Adicionamos os seguintes métodos ao nosso pacote de benchmark para testes comparativos:

 [Benchmark] public decimal GetStockPriceFor_Await() { return _stockPricesThatYield.GetStockPriceFor("MSFT"); } [Benchmark] public decimal GetStockPriceForAsync_Await() { return _stockPricesThatYield.GetStockPriceForAsync("MSFT").GetAwaiter().GetResult(); } [Benchmark] public decimal GetStockPriceWithValueTaskAsync_Await() { return _stockPricesThatYield.GetStockPriceValueTaskForAsync("MSFT").GetAwaiter().GetResult(); } 



Como você pode ver, a diferença é palpável - tanto em termos de velocidade quanto em termos de uso de memória. Explique brevemente os resultados.

  • Cada operação de espera para uma tarefa inacabada leva aproximadamente 4 microssegundos e aloca quase 300 bytes (**) para cada chamada. É por isso que GetStockPriceFor é executado quase duas vezes mais rápido que GetStockPriceForAsync e aloca menos memória.
  • Um método assíncrono baseado no ValueTask leva um pouco mais do que a variante do Task, quando esse método não é executado de forma síncrona. Uma máquina de estado de um método baseado no ValueTask <T> deve armazenar mais dados do que uma máquina de estado de um método baseado na Tarefa <T>.

(**) Depende da plataforma (x64 ou x86) e de um número de variáveis ​​e argumentos locais do método assíncrono.

Desempenho do método assíncrono 101


  • Se o método assíncrono for executado de forma síncrona, a sobrecarga será bem pequena.
  • Se o método assíncrono for executado de forma síncrona, ocorrerá a sobrecarga a seguir no uso da memória: não haverá sobrecarga para os métodos de tarefa assíncrona e para os métodos de tarefa assíncrona <T>, a sobrecarga é de 88 bytes por operação (para plataformas x64).
  • ValueTask <T> elimina a sobrecarga mencionada acima para métodos assíncronos executados de forma síncrona.
  • Quando um método assíncrono baseado em ValueTask <T> é executado de forma síncrona, leva um pouco menos de tempo que o método com a Tarefa <T>, caso contrário, existem pequenas diferenças a favor da segunda opção.
  • A sobrecarga de desempenho dos métodos assíncronos que aguardam a conclusão de uma tarefa inacabada é significativamente maior (aproximadamente 300 bytes por operação para plataformas x64).

Obviamente, as medidas são o nosso tudo. Se você perceber que uma operação assíncrona está causando problemas de desempenho, poderá alternar da Task<T> para a ValueTask<T> , armazenar em cache a tarefa ou tornar o caminho geral da execução síncrono, se possível. Você também pode tentar agregar suas operações assíncronas. Isso ajudará a melhorar o desempenho, simplificar a depuração e a análise de código em geral. Nem todo pequeno pedaço de código deve ser assíncrono.

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


All Articles