Programação assíncrona

Desempenho assíncrono: compreendendo os custos de async e await

Stephen Toub

 

A programação assíncrona vem, há muito tempo, sendo o território dos desenvolvedores mais qualificados e masoquistas — aqueles que têm tempo, tendência e capacidade mental de ponderar sobre um retorno de chamada após o outro do fluxo de controle não linear. Com o Microsoft .NET Framework 4.5, o C# e o Visual Basic entregam assincronicidade para o restante de nós, de forma que meros mortais possam escrever métodos assíncronos quase tão facilmente quanto escrevem métodos síncronos. Chega de retornos de chamada. Chega de marshaling explícito de código de um contexto de sincronização para o outro. Chega de se preocupar com o fluxo de resultados ou exceções. Chega de truques que distorcem recursos de linguagem existentes para facilitar o desenvolvimento assíncrono. Em resumo, chega de inconvenientes.

É claro que, embora agora seja fácil começar a escrever métodos assíncronos (consulte os artigos de Eric Lippert e Mads Torgersen nesta edição da MSDN Magazine), para fazer isso realmente bem, ainda é preciso de um entendimento do que acontece nos bastidores. Sempre que uma linguagem ou estrutura eleva o nível de abstração no qual um desenvolvedor pode programar, ela invariavelmente também encapsula custos de desempenho ocultos. Em muitos casos, esses custos são irrisórios, e podem e devem ser ignorados pelo grande número de desenvolvedores que implementam a grande quantidade de cenários. No entanto, ainda convém que desenvolvedores mais avançados realmente entendam quais custos existem, para que possam tomar as medidas necessárias para evitá-los se eles eventualmente se tornarem visíveis. Esse é o caso do recurso de métodos assíncronos no C# e no Visual Basic.

Neste artigo, explorarei os prós e contras dos métodos assíncronos, fornecendo um entendimento sólido de como eles são implementados nos bastidores e discutindo parte dos custos mais sutis envolvidos. Observe que essas informações não pretendem incentivá-lo a transformar um código legível em algo que não pode ser mantido, tudo em nome da micro-otimização e do desempenho. O objetivo é simplesmente fornecer informações que podem ajudá-lo a diagnosticar quaisquer problemas que você pode encontrar, bem como oferecer um conjunto de ferramentas para ajudá-lo a superar esses problemas potenciais. Observe também que este artigo é baseado em uma versão experimental do .NET Framework 4.5, e é provável que detalhes específicos de implementação mudem antes da versão final.

Obtendo o modelo mental correto

Há décadas, os desenvolvedores usam linguagens de alto nível como C#, Visual Basic, F# e C++ para desenvolver aplicativos eficientes. Essa experiência informou esses desenvolvedores sobre os custos relevantes de várias operações, e esse conhecimento resultou em práticas recomendadas de desenvolvimento. Por exemplo, na maioria dos casos de uso, chamar um método síncrono é relativamente barato, ainda mais quando o compilador é capaz de embutir o receptor no site de chamada. Dessa maneira, os desenvolvedores aprendem a refatorar o código em métodos pequenos e passíveis de manutenção, em geral sem precisar pensar em nenhuma ramificação negativa da contagem maior da invocação de método. Esses desenvolvedores têm um modelo mental para o que significa chamar um método.

Com a introdução dos métodos assíncronos, um novo modelo mental é necessário. Embora as linguagens C# e Visual Basic e os compiladores sejam capazes de fornecer a ilusão de que um método assíncrono é idêntico ao seu correspondente síncrono, as coisas são diferentes nos bastidores. O compilador acaba gerando muitos códigos em nome do desenvolvedor, códigos que são similares às quantidades de códigos de texto clichê que os desenvolvedores que implementavam assincronicidade antigamente teriam de escrever e manter manualmente. Além disso, o código gerado pelo compilador chama um código de biblioteca no .NET Framework, mais uma vez aumentando o trabalho realizado em nome do desenvolvedor. Para obter o modelo mental correto e depois usá-lo para tomar decisões de desenvolvimento apropriadas, é importante entender o que o compilador está gerando em seu nome.

Pense em partes, não em volume

Ao trabalhar com códigos síncronos, métodos com corpos vazios são praticamente gratuitos. Isso não se aplica aos métodos assíncronos. Considere o seguinte método assíncrono, que tem uma única instrução em seu corpo (e que devido à falta de awaits acabará em execução de forma síncrona):

public static async Task SimpleBodyAsync() {
  Console.WriteLine("Hello, Async World!");
}

Um descompilador de linguagem intermediária (IL) revelará a verdadeira natureza dessa função assim que for compilada, com resultado parecido com o mostrado na Figura 1. O que era uma simples linha única de código foi expandida em dois métodos, um dos quais existe em uma classe auxiliar de máquina de estado. Primeiro, existe um método de stub que tem a mesma assinatura básica escrita pelo desenvolvedor (o método tem o mesmo nome, a mesma visibilidade, aceita os mesmos parâmetros e mantém seu tipo de retorno), mas esse stub não contém nenhum dos códigos escritos pelo desenvolvedor. Em vez disso, contém um texto clichê de configuração. O código de configuração inicializa a máquina de estado usada para representar o método assíncrono e depois a inicia usando uma chamada para o método MoveNext secundário na máquina de estado. Esse tipo de máquina de estado armazena o estado para o método assíncrono, permitindo que esse estado seja mantido em pontos de espera assíncronos, se necessário. Ele também contém o corpo do método conforme foi escrito pelo usuário, mas distorcido de uma maneira que permite que os resultados e as exceções sejam elevados ao Task retornado; para que a posição atual no método seja mantida e a execução possa ser retomada naquele local após um await; e assim por diante.

Figura 1 Texto clichê de método assíncrono

[DebuggerStepThrough]     
public static Task SimpleBodyAsync() {
  <SimpleBodyAsync>d__0 d__ = new <SimpleBodyAsync>d__0();
  d__.<>t__builder = AsyncTaskMethodBuilder.Create();
  d__.MoveNext();
  return d__.<>t__builder.Task;
}
 
[CompilerGenerated]
[StructLayout(LayoutKind.Sequential)]
private struct <SimpleBodyAsync>d__0 : <>t__IStateMachine {
  private int <>1__state;
  public AsyncTaskMethodBuilder <>t__builder;
  public Action <>t__MoveNextDelegate;
 
  public void MoveNext() {
    try {
      if (this.<>1__state == -1) return;
      Console.WriteLine("Hello, Async World!");
    }
    catch (Exception e) {
      this.<>1__state = -1;
      this.<>t__builder.SetException(e);
      return;
    }
 
    this.<>1__state = -1;
    this.<>t__builder.SetResult();
  }
 
  ...
}

Ao pensar nos custos para invocar métodos assíncronos, lembre-se desse texto clichê. O bloco try/catch no método MoveNext provavelmente impedirá que ele seja embutido pelo compilador JIT (just-in-time), então, no mínimo, teremos o custo de uma invocação de método, sendo que no caso síncrono, não teríamos (com um corpo do método tão pequeno). Temos várias chamadas nas rotinas do Framework (como SetResult). E temos várias gravações em campos no tipo de máquina de estado. É claro, temos de pesar tudo isso com relação ao custo do Console.WriteLine, que provavelmente dominará todos os outros custos envolvidos (faz bloqueios, faz E/S e assim por diante). Além disso, observe que há otimizações que a infraestrutura faz para você. Por exemplo, o tipo de máquina de estado é um struct. Esse struct será demarcado no heap somente se esse método precisar suspender sua execução porque está aguardando uma instância que ainda não está concluída, e que neste método simples, nunca será concluída. Assim, o texto clichê desse método assíncrono não incorrerá em nenhuma alocação. O compilador e o tempo de execução trabalham muito para minimizar o número de alocações envolvidas na infraestrutura.

Saiba quando não utilizar o async

O .NET Framework tenta gerar implementações assíncronas eficientes para métodos assíncronos, aplicando várias otimizações. No entanto, os desenvolvedores geralmente têm conhecimento do domínio que pode render otimizações que seriam arriscadas e desaconselháveis para que o compilador e o tempo de execução aplicassem automaticamente, devido à generalidade para a qual se destinam. Com isso em mente, um desenvolvedor pode realmente ser beneficiado se evitar usar métodos async em um conjunto pequeno e específico de casos de uso, particularmente para métodos de biblioteca que serão acessados de uma maneira mais refinada. Isso normalmente ocorre quando se sabe que o método pode ser capaz de ser concluído de forma síncrona, porque os dados dos quais depende já estão disponíveis.

Ao criar métodos assíncronos, os desenvolvedores do Framework passam muito tempo otimizando as alocações de objetos. Isso porque as alocações representam um dos maiores custos de desempenho na infraestrutura de método assíncrono. O ato de alocar um objeto normalmente é muito barato. Alocar objetos é parecido com encher seu carrinho de compras com mercadorias, ou seja, não é preciso muito esforço para colocar os itens em seu carrinho, mas na hora de finalizar a compra, você precisa pegar sua carteira e investir uma quantidade significativa de recursos. Embora as alocações geralmente sejam baratas, a coleta de lixo resultante pode desviar a atenção quando se trata do desempenho do aplicativo. A coleta de lixo envolve fazer uma varredura em uma parte dos objetos alocados atualmente e encontrar os que não são mais referenciados. Quanto mais objetos alocados, mais demorada será essa marcação. Além disso, quanto maiores os objetos alocados e maior a quantidade desses objetos, mais frequentes serão as ocorrências de coleta de lixo. Dessa maneira, as alocações têm um efeito global no sistema: quanto mais lixo gerado pelos métodos assíncronos, mais devagar o programa geral será executado, mesmo que microbenchmarks dos próprios métodos assíncronos não revelem custos significativos.

Para métodos assíncronos que realmente proporcionam a execução (devido à espera por um objeto que ainda não está concluído), a infraestrutura de método assíncrono precisará alocar um objeto Task que retornará do método, já que esse Task serve como uma referência exclusiva para essa invocação específica. No entanto, muitas invocações de método assíncrono podem ser concluídas sem concessão. Nesses casos, a infraestrutura de método assíncrono pode retornar um Task já concluído e armazenado em cache, que pode ser usado várias vezes para evitar a alocação de Tasks desnecessários. No entanto, ela só é capaz de fazer isso em circunstâncias limitadas, como quando o método assíncrono é um Task não genérico, um Task<Boolean>, ou quando é um Task<TResult> em que TResult é um tipo de referência e o resultado do método assíncrono é nulo. Embora esse conjunto possa expandir no futuro, você geralmente pode fazer um trabalho melhor se tiver conhecimento do domínio da operação que está sendo implementada.

Considere a implementação de um tipo como MemoryStream. O MemoryStream é derivado do Stream, por isso, pode substituir os métodos ReadAsync, WriteAsync e FlushAsync do Stream do novo .NET 4.5 para fornecer implementações otimizadas para a natureza do MemoryStream. Como a operação de leitura é simplesmente realizada no buffer de memória e é, portanto, apenas uma cópia da memória, um desempenho melhor ocorrerá se o ReadAsync for executado de forma síncrona. A implementação disso com um método assíncrono seria semelhante ao seguinte:

public override async Task<int> ReadAsync(
  byte [] buffer, int offset, int count,
  CancellationToken cancellationToken)
{
  cancellationToken.ThrowIfCancellationRequested();
  return this.Read(buffer, offset, count);
}

Fácil o bastante. E como Read é uma chamada síncrona, e como não há awaits nesse método que proporcionarão controle, todas as invocações do ReadAsync serão realmente concluídas de forma síncrona. Agora, vamos considerar um padrão de uso comum de fluxos, como uma operação de cópia:

byte [] buffer = new byte[0x1000];
int numRead;
while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) {
  await source.WriteAsync(buffer, 0, numRead);
}

Observe aqui que o ReadAsync no fluxo de origem para essa série específica de chamadas é sempre invocado com o mesmo parâmetro de contagem (o comprimento do buffer), portanto, é muito provável que o valor de retorno (o número de bytes lidos) também seja repetido. Exceto em raras circunstâncias, é muito improvável que a implementação de método assíncrono do ReadAsync conseguirá usar um Task armazenado em cache para seu valor de retorno, mas você consegue fazer isso.

Pense em reescrever esse método, como mostra a Figura 2. Aproveitando os aspectos específicos desse método e seus cenários comuns de uso, agora conseguimos otimizar as alocações no caminho comum de uma forma que não poderíamos esperar que a infraestrutura subjacente conseguisse. Com isso, sempre que uma chamada para o ReadAsync recuperar o mesmo número de bytes que a chamada anterior para o ReadAsync, podemos evitar completamente qualquer sobrecarga de alocação do método ReadAsync retornando o mesmo Task que retornamos na invocação anterior. E para uma operação de nível baixo como essa, que esperamos que seja muito rápida e invocada repetidamente, essa otimização pode fazer uma diferença notável, principalmente no número de coletas de lixo que ocorrem.

Figura 2 Otimizando alocações de tarefa

private Task<int> m_lastTask;
 
public override Task<int> ReadAsync(
  byte [] buffer, int offset, int count,
  CancellationToken cancellationToken)
{
  if (cancellationToken.IsCancellationRequested) {
    var tcs = new TaskCompletionSource<int>();
    tcs.SetCanceled();
    return tcs.Task;
  }
 
  try {
      int numRead = this.Read(buffer, offset, count);
      return m_lastTask != null && numRead == m_lastTask.Result ?
        m_lastTask : (m_lastTask = Task.FromResult(numRead));
  }
  catch(Exception e) {
    var tcs = new TaskCompletionSource<int>();
    tcs.SetException(e);
    return tcs.Task;
  }
}

Uma otimização relacionada para evitar a alocação de tarefa pode ser realizada quando o cenário determinar armazenamento em cache. Considere um método cuja finalidade é baixar o conteúdo de uma página da Web específica e depois armazenar em cache o conteúdo baixado com êxito para acessos futuros. Essa funcionalidade pode ser escrita usando um método assíncrono, como a seguir (usando a nova biblioteca System.Net.Http.dll no .NET 4.5):

private static ConcurrentDictionary<string,string> s_urlToContents;
 
public static async Task<string> GetContentsAsync(string url)
{
  string contents;
  if (!s_urlToContents.TryGetValue(url, out contents))
  {
    var response = await new HttpClient().GetAsync(url);
    contents = response.EnsureSuccessStatusCode().Content.ReadAsString();
    s_urlToContents.TryAdd(url, contents);
  }
  return contents;
}

Trata-se de uma implementação simples. E para chamadas para GetContentsAsync que não podem ser satisfeitas a partir do cache, a sobrecarga de construir um novo Task<string> para representar esse download será irrisória quando comparada aos custos relacionados à rede. No entanto, nos casos em que o conteúdo pode ser satisfeito a partir do cache, isso pode representar um custo não irrisório, uma alocação de objeto simplesmente para encapsular e devolver dados que já estão disponíveis.

Para evitar esse custo (se isso for necessário para atingir suas metas de desempenho), você pode reescrever esse método, como mostra a Figura 3. Agora, temos dois métodos: um método público síncrono, e um método privado assíncrono para o qual o método público delega. O dicionário agora está armazenando em cache as tarefas geradas, em vez de seus conteúdos, para que tentativas futuras de baixar uma página que já foi baixada com êxito possam ser satisfeitas com um simples acesso ao dicionário para retornar uma tarefa já existente. Internamente, também aproveitamos os métodos ContinueWith em Task que nos permitem armazenar a tarefa no dicionário assim que Task estiver concluído — mas apenas se o download tiver sido concluído com êxito. É claro que esse código é mais complicado e exige mais trabalho para escrever e manter, assim como qualquer otimização de desempenho, por isso, evite gastar tempo nele até que o teste de desempenho prove que as complicações fizeram uma diferença impactante e necessária. Essas otimizações farão diferença dependendo dos cenários de uso. Você deve criar um pacote de testes que representem padrões comuns de uso, e usar a análise desses testes para determinar se essas complicações melhoraram o desempenho do seu código de uma forma significativa.

Figura 3 Armazenando tarefas em cache manualmente

private static ConcurrentDictionary<string,Task<string>> s_urlToContents;
 
public static Task<string> GetContentsAsync(string url) {
  Task<string> contents;
  if (!s_urlToContents.TryGetValue(url, out contents)) {
      contents = GetContentsAsync(url);
      contents.ContinueWith(delegate {
        s_urlToContents.TryAdd(url, contents);
      }, CancellationToken.None,
        TaskContinuationOptions.OnlyOnRanToCompletion |
          TaskContinuatOptions.ExecuteSynchronously,
        TaskScheduler.Default);
  }
  return contents;
}
 
private static async Task<string> GetContentsAsync(string url) {
  var response = await new HttpClient().GetAsync(url);
  return response.EnsureSuccessStatusCode().Content.ReadAsString();
}

Outra otimização relacionada à tarefa a ser considerada é se você realmente precisa do Task retornado de um método assíncrono. O C# e o Visual Basic oferecem suporte à criação de métodos assíncronos que retornam void, sendo que nesse caso, nenhum Task é alocado para o método, nunca. Os métodos assíncronos expostos publicamente a partir de bibliotecas sempre devem ser escritos para retornar um Task ou Task<TResult>, porque você, como um desenvolvedor de bibliotecas, não sabe se o consumidor deseja esperar a conclusão daquele método. No entanto, para determinados cenários de uso interno, métodos assíncronos que retornam void podem ter seu lugar. O principal motivo para a existência de métodos assíncronos que retornam void é o suporte a ambientes orientados por eventos, como ASP.NET e Windows Presentation Foundation (WPF). Eles facilitam a implementação de manipuladores de botão, eventos de carregamento de página e similares por meio do uso de async e await. Se você considerar o uso de um método async que retorna void, tenha cuidado com a manipulação de exceção: as exceções que escapam de um método async que retorna void se propagam para qualquer SynchronizationContext que era atual no momento em que o método async que retorna void foi invocado.

Preocupação com o contexto

Há muitos tipos de “contexto” no .NET Framework: LogicalCallContext, SynchronizationContext, HostExecutionContext, SecurityContext, ExecutionContext e muito mais (pelo número enorme, você pode esperar que os desenvolvedores do Framework sejam monetariamente incentivados a apresentar novos contextos, mas garanto que não somos). Alguns desses contextos são muito relevantes para os métodos assíncronos, não apenas em funcionalidade, mas também em seu impacto no desempenho do método assíncrono. 

SynchronizationContext O SynchronizationContext desempenha um papel importante nos métodos assíncronos. Um “contexto de sincronização” é simplesmente uma abstração em relação à capacidade de realizar marshaling de uma invocação de delegado de uma maneira específica para uma determinada biblioteca ou estrutura. Por exemplo, o WPF fornece um DispatcherSynchronizationContext para representar o thread de interface do usuário para um Dispatcher: postar um delegado nesse contexto de sincronização faz com que esse delegado seja enfileirado para execução pelo Dispatcher em seu thread. O ASP.NET fornece um AspNetSynchronizationContext, que é usado para garantir que operações assíncronas que ocorrem como parte do processamento de uma solicitação do ASP.NET serão executadas em série e serão associadas ao estado HttpContext correto. E assim por diante. No total, há cerca de 10 implementações concretas do SynchronizationContext no .NET Framework, algumas públicas e outras internas.

Ao esperar Tasks e outros tipos de awaitable fornecidos pelo NET Framework, os “awaiters” para esses tipos (como TaskAwaiter) capturam o SynchronizationContext atual no momento em que o await é emitido. Após a conclusão do awaitable, se houve um SynchronizationContext atual que foi capturado, a continuação que representa o restante do método assíncrono é postada nesse SynchronizationContext. Com isso, os desenvolvedores que escrevem um método assíncrono chamado de um thread de interface do usuário não precisam realizar marshaling de invocações manualmente de volta para o thread de interface do usuário a fim de modificar os controles de interface do usuário: esse marshaling é realizado automaticamente pela infraestrutura do Framework.

Infelizmente, esse marshaling também envolve custos. Para desenvolvedores de aplicativos que usam await para implementar seu fluxo de controle, esse marshaling automático é quase sempre a solução correta. As bibliotecas, no entanto, geralmente são outra história. Os desenvolvedores de aplicativos normalmente precisam desse marshaling porque seu código se preocupa com o contexto no qual é executado, como ser capaz de acessar controles de interface do usuário, ou ser capaz de acessar o HttpContext para a solicitação do ASP.NET correta. A maioria das bibliotecas, no entanto, não sofre essa restrição. Como resultado, esse marshaling automático costuma ser um custo totalmente desnecessário. Considere novamente o código mostrado antes para copiar dados de um fluxo para outro:

byte [] buffer = new byte[0x1000];
int numRead;
while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) {
  await source.WriteAsync(buffer, 0, numRead);
}

Se essa operação de cópia for invocada a partir de um thread de interface do usuário, todas as operações de leitura e gravação em espera forçarão a conclusão de volta no thread de interface do usuário. Para um megabyte de dados de origem e Streams que conclui leituras e gravações de forma assíncrona (que é a maioria), isso significa um aumento de 500 saltos dos threads de segundo plano para o thread de interface do usuário. Para lidar com isso, os tipos Task e Task<TResult> fornecem um método ConfigureAwait. O ConfigureAwait aceita um parâmetro continueOnCapturedContext booliano que controla esse comportamento de marshaling. Se o padrão true for usado, o await será automaticamente concluído de volta no SynchronizationContext capturado. Se false for usado, no entanto, o SynchronizationContext será ignorado e o Framework tentará continuar a execução no local em que a operação assíncrona anterior tiver sido concluída. A incorporação disso no código de cópia de fluxo resulta na versão mais eficiente a seguir:

byte [] buffer = new byte[0x1000];
int numRead;
while((numRead = await
  source.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0) {
  await source.WriteAsync(buffer, 0, numRead).ConfigureAwait(false);
}

Para desenvolvedores de bibliotecas, apenas esse impacto de desempenho é suficiente para garantir a utilização do ConfigureAwait sempre, a menos que seja uma circunstância rara em que a biblioteca tem conhecimento do domínio de seu ambiente e precisa executar o corpo do método com acesso ao contexto correto.

Há outro motivo, além do desempenho, para usar o ConfigureAwait em códigos de biblioteca. Suponha que o código anterior, sem o ConfigureAwait, estava em um método chamado CopyStreamToStreamAsync, que foi invocado a partir de um thread de interface do usuário do WPF, desta forma:

private void button1_Click(object sender, EventArgs args) {
  Stream src = …, dst = …;
  Task t = CopyStreamToStreamAsync(src, dst);
  t.Wait(); // deadlock!
}

Aqui, o desenvolvedor deveria ter escrito button1_Click como um método async e depois colocar Task em espera, em vez de usar seu método síncrono Wait. O método Wait tem seus usos importantes, mas é quase sempre errado usá-lo para espera em um thread de interface do usuário dessa forma. O método Wait não retornará até que Task esteja concluído. No caso do CopyStreamToStreamAsync, os awaits independentes tentam postar de volta para o SynchronizationContext capturado, e o método não pode ser concluído até que esses Posts sejam concluídos (porque os Posts são usados para processar o restante do método). Mas esses Posts não serão concluídos porque o thread de interface do usuário que os processaria está bloqueado na chamada para Wait. Essa é uma dependência circular, resultando em um deadlock. Se, em vez disso, CopyStreamToStreamAsync tivesse sido escrito usando ConfigureAwait(false), não haveria dependência circular nem deadlock.

ExecutionContext O ExecutionContext é uma parte integrante do .NET Framework, mesmo que muitos desenvolvedores felizmente desconheçam sua existência. O ExecutionContext é o avô dos contextos, encapsulando vários outros contextos como SecurityContext e LogicalCallContext, e representando tudo que deve fluir automaticamente pelos pontos assíncronos no código. Sempre que você tiver usado ThreadPool.QueueUserWorkItem, Task.Run, Delegate.BeginInvoke, Stream.BeginRead, WebClient.DownloadStringAsync ou qualquer outra operação assíncrona no Framework, nos bastidores ExecutionContext foi capturado, se possível, (via ExecutionContext.Capture) e esse contexto capturado depois foi usado para processar o delegado fornecido (via ExecutionContext.Run). Por exemplo, se o código que invocou ThreadPool.QueueUserWorkItem estivesse representando uma identidade do Windows naquele momento, essa mesma identidade do Windows seria representada para executar o delegado WaitCallback fornecido. E se o código que invocou Task.Run tivesse primeiro armazenado dados no LogicalCallContext, esses mesmos dados estariam acessíveis pelo LogicalCallContext no delegado Action fornecido. O ExecutionContext também flui pelos awaits nas tarefas.

Há várias otimizações no Framework para evitar capturar e executar em um ExecutionContext capturado quando isso for desnecessário, já que pode ter um custo bem alto. No entanto, ações como representar uma identidade do Windows ou armazenar dados no LogicalCallContext impedirão essas otimizações. Evitar operações que manipulam ExecutionContext, como WindowsIdentity.Impersonate e CallContext.LogicalSetData, resulta em um desempenho melhor ao usar métodos assíncronos e a assincronia em geral.

“Suba” dados para fugir da coleta de lixo

Os métodos assíncronos fornecem uma boa ilusão quando se trata de variáveis locais. Em um método síncrono, as variáveis locais no C# e no Visual Basic são baseadas em pilha, de forma que nenhuma alocação de heap é necessária para armazenar esses locais. No entanto, em métodos assíncronos, a pilha para o método desaparece quando o método assíncrono está suspenso em um ponto de espera. Para que dados estejam disponíveis para o método depois que um await retomar, eles precisam estar armazenados em algum lugar. Assim, os compiladores do C# e do Visual Basic “sobem” os locais para um struct de máquina de estado, que depois é demarcado no heap no primeiro await que suspender para que os locais possam ser mantidos durante os pontos de espera.

Anteriormente neste artigo, discuti sobre como o custo e a frequência da coleta de lixo são influenciados pelo número de objetos alocados, sendo que a frequência da coleta de lixo também é influenciada pelo tamanho dos objetos alocados. Quanto maiores os objetos alocados, mais frequente será a necessidade de executar a coleta de lixo. Assim, em um método assíncrono, quanto mais locais for preciso “subir” para o heap, mais frequentes serão as ocorrências de coleta de lixo.

No momento da redação deste artigo, os compiladores do C# e do Visual Basic às vezes “sobem” mais do que é realmente necessário. Por exemplo, considere o seguinte trecho de código:

public static async Task FooAsync() {
  var dto = DateTimeOffset.Now;
  var dt  = dto.DateTime;
  await Task.Yield();
  Console.WriteLine(dt);
}

A variável dto não é lida depois do ponto de espera, portanto, o valor gravado nela antes do await não precisa ser mantido durante o await. No entanto, o tipo de máquina de estado gerado pelo compilador para armazenar locais ainda contém a referência dto, como mostra a Figura 4.

Figura 4 Subindo locais

[StructLayout(LayoutKind.Sequential), CompilerGenerated]
private struct <FooAsync>d__0 : <>t__IStateMachine {
  private int <>1__state;
  public AsyncTaskMethodBuilder <>t__builder;
  public Action <>t__MoveNextDelegate;
 
  public DateTimeOffset <dto>5__1;
  public DateTime <dt>5__2;
  private object <>t__stack;
  private object <>t__awaiter;
 
  public void MoveNext();
  [DebuggerHidden]
  public void <>t__SetMoveNextDelegate(Action param0);
}

Isso aumenta ligeiramente o tamanho desse objeto de heap além do que é de fato necessário. Se você perceber que as coletas de lixo estão ocorrendo com mais frequência do que você espera, observe se você realmente precisa de todas as variáveis temporárias que você codificou em seu método assíncrono. Esse exemplo pode ser reescrito desta forma para evitar o campo extra na classe de máquina de estado:

public static async Task FooAsync() {
  var dt = DateTimeOffset.Now.DateTime;
  await Task.Yield();
  Console.WriteLine(dt);
}

Além disso, o GC (coletor de lixo) do .NET é um coletor gerativo, o que significa que ele divide o conjunto de objetos em grupos, conhecidos como gerações: em um alto nível, novos objetos são alocados na geração 0, e depois todos os objetos que sobrevivem a uma coleta são promovidos em uma geração (o GC do .NET atualmente usa gerações 0, 1 e 2). Isso faz com que as coletas sejam mais rápidas ao permitir que o GC colete frequentemente apenas de um subconjunto do espaço de objeto conhecido. Isso é baseado na filosofia de que os objetos recém-alocados também desaparecerão rapidamente, ao passo que os objetos que já existem há muito tempo continuarão a existir por muito tempo. O significado disso é que se um objeto sobreviver à geração 0, é provável que ele acabe existindo por algum tempo, continuando a pressionar o sistema durante esse tempo adicional. E isso significa que realmente devemos garantir que os objetos ficarão disponíveis para a coleta de lixo assim que eles não forem mais necessários.

Com a “subida” mencionada anteriormente, os locais são promovidos para campos de uma classe que mantém as raízes durante a execução do método assíncrono (contanto que o objeto em espera mantenha adequadamente uma referência ao delegado a ser invocado após a conclusão da operação em espera). Em métodos síncronos, o compilador JIT é capaz de controlar quando os locais nunca mais serão acessados, e nesses pontos pode ajudar o GC a ignorar essas variáveis como raízes, deixando os objetos referenciados disponíveis para coleta se eles não forem referenciados em nenhum outro lugar. No entanto, nos métodos assíncronos, esses locais permanecem referenciados, o que significa que os objetos que eles referenciam podem sobreviver por muito mais tempo do que sobreviveriam se esses fossem locais reais. Se você perceber que os objetos estão permanecendo ativos bem depois de seu uso, considere anular os locais que referenciam esses objetos quando terminar de usá-los. Novamente, isso deve ser feito apenas se você descobrir que essa é realmente a causa de um problema de desempenho, caso contrário, complicará o código desnecessariamente. Além disso, os compiladores do C# e do Visual Basic podem ser atualizados pela versão final ou de outra maneira no futuro para lidar com outros cenários como esses em nome do desenvolvedor, por isso, um código desse tipo escrito hoje provavelmente se tornará obsoleto no futuro.

Evitar complexidades

Os compiladores do C# e do Visual Basic são bem impressionantes em termos de onde você pode usar awaits: em praticamente qualquer lugar. As expressões await podem ser usadas como parte de expressões maiores, permitindo que você coloque instâncias Task<TResult> em espera em locais onde você pode ter qualquer outra expressão que retorna valores. Por exemplo, considere o seguinte código, que retorna a soma dos resultados de três tarefas:

public static async Task<int> SumAsync(
  Task<int> a, Task<int> b, Task<int> c)
{
  return Sum(await a, await b, await c);
}
 
private static int Sum(int a, int b, int c)
{
  return a + b + c;
}

O compilador do C# permite que você use a expressão “await b” como um argumento para a função Sum. No entanto, há vários awaits aqui cujos resultados são passados como parâmetros para Sum, e devido à ordem das regras de avaliação e à maneira como o async é implementado no compilador, esse exemplo específico exige que o compilador “vaze” os resultados temporários dos dois primeiros awaits. Como você viu anteriormente, os locais são preservados durante os pontos de espera ao “subi-los” para os campos na classe de máquina de estado. No entanto, em casos como esse, em que os valores estão na pilha de avaliação CLR, esses valores não sobem para a máquina de estado, em vez disso, vazam para um único objeto temporário e depois são referenciados pela máquina de estado. Quando você conclui o await na primeira tarefa e passa para o await da segunda, o compilador gera um código que demarca o primeiro resultado e armazena o objeto demarcado em um único campo <>t__stack na máquina de estado. Quando você conclui o await na segunda tarefa e passa para o await da terceira, o compilador gera um código que cria uma Tuple<int,int> dos primeiros dois valores, armazenando essa tupla no mesmo campo <>t__stack. Isso tudo significa que, dependendo da maneira como escreve o seu código, você pode acabar com padrões de alocação muito diferentes. Em vez disso, considere escrever SumAsync desta forma:

public static async Task<int> SumAsync(
  Task<int> a, Task<int> b, Task<int> c)
{
  int ra = await a;
  int rb = await b;
  int rc = await c;
  return Sum(ra, rb, rc);
}

Com essa alteração, o compilador agora emitirá três outros campos na classe de máquina de estado para armazenar ra, rb e rc, e não será necessário vazar nada. Assim, haverá uma compensação: uma classe de máquina de estado maior com menos alocações, ou uma classe de máquina de estado menor com mais alocações. A quantidade total de memória alocada será maior no caso em que os resultados vazam, já que cada objeto alocado tem sua própria sobrecarga de memória, mas no final das contas, o teste de desempenho poderá revelar que ele ainda assim é melhor. Em geral, como mencionado anteriormente, você não deve considerar esses tipos de micro-otimizações a menos que descubra que as alocações realmente são a causa do problema, mas mesmo assim, é útil saber de onde essas alocações estão vindo.

Indiscutivelmente, nos exemplos anteriores há um custo muito maior que deve ser levado em consideração de forma pró-ativa. O código não é capaz de invocar Sum até que todos os três awaits estejam concluídos, e nenhum trabalho é realizado entre os awaits. Cada um desses awaits produzidos requer uma quantidade razoável de trabalho, por isso, quanto menos awaits você precisar processar, melhor. Portanto, convém que você agrupe esses três awaits em apenas um ao esperar por todas as tarefas de uma vez com Task.WhenAll:

public static async Task<int> SumAsync(
  Task<int> a, Task<int> b, Task<int> c)
{
  int [] results = await Task.WhenAll(a, b, c);
  return Sum(results[0], results[1], results[2]);
}

O método Task.WhenAll aqui retorna um Task<TResult[]> que não será concluído até que todas as tarefas fornecidas estejam concluídas, e faz isso de maneira muito mais eficiente do que apenas esperar por cada tarefa específica. Também reúne os resultados de cada tarefa e os armazena em uma matriz. Se quiser evitar essa matriz, pode fazer isso forçando a vinculação ao método WhenAll não genérico que trabalha com Task em vez de Task<TResult>. Para obter o melhor desempenho, você também pode usar uma abordagem híbrida, onde primeiro verifica se todas as tarefas foram concluídas com êxito e, em caso afirmativo, obtém seus resultados individualmente — em caso negativo, aguarde o WhenAll das que não foram concluídas. Isso evitará alocações envolvidas na chamada ao WhenAll quando for desnecessário, como alocar a matriz de parâmetros a ser transmitida para o método. E, conforme mencionado anteriormente, queremos que essa função de biblioteca também suprima o marshaling de contexto. Essa solução é mostrada na Figura 5.

Figura 5 Aplicando várias otimizações

public static Task<int> SumAsync(
  Task<int> a, Task<int> b, Task<int> c)
{
  return (a.Status == TaskStatus.RanToCompletion &&
          b.Status == TaskStatus.RanToCompletion &&
          c.Status == TaskStatus.RanToCompletion) ?
    Task.FromResult(Sum(a.Result, b.Result, c.Result)) :
    SumAsyncInternal(a, b, c);
}
 
private static async Task<int> SumAsyncInternal(
  Task<int> a, Task<int> b, Task<int> c)
{
  await Task.WhenAll((Task)a, b, c).ConfigureAwait(false);
  return Sum(a.Result, b.Result, c.Result);
}

Assincronicidade e desempenho

Os métodos assíncronos são uma ferramenta de produtividade poderosa, permitindo que você crie com mais facilidade bibliotecas e aplicativos escalonáveis e responsivos. No entanto, é importante se lembrar de que a assincronicidade não é uma otimização de desempenho para uma operação individual. Pegar uma operação síncrona e torná-la assíncrona invariavelmente prejudicará o desempenho dessa operação, pois ela ainda precisará realizar tudo que a operação síncrona realizava, mas agora com restrições e considerações adicionais. Um motivo para se preocupar com a assincronicidade, desse modo, é o desempenho em conjunto: como é o desempenho geral do seu sistema quando você escreve tudo de forma assíncrona, de maneira que você possa substituir E/S e obter uma utilização melhor do sistema ao consumir recursos valiosos apenas quando eles realmente forem necessários para execução. A implementação de método assíncrono fornecida pelo .NET Framework é bem-otimizada e frequentemente fornece um desempenho tão bom quanto ou melhor do que implementações assíncronas bem-escritas usando padrões e volumes existentes mais código. Sempre que planejar desenvolver um código assíncrono no .NET Framework a partir de agora, os métodos assíncronos devem ser sua ferramenta preferencial. Ainda assim, é bom que você, como desenvolvedor, esteja ciente de tudo o que o Framework está fazendo em seu nome nesses métodos assíncronos, para que possa garantir que o resultado final será o melhor possível.

Stephen Toub é arquiteto-chefe da equipe de plataforma de computação paralela da Microsoft.

Agradecemos aos seguintes especialistas técnicos pela revisão deste artigo: Joe HoagEric Lippert, Danny ShihMads Torgersen