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;
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() {
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
:
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() {
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();
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) {
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 ... }
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; }
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.