Async/Await

Práticas recomendadas na programação assíncrona

Stephen Cleary

 

Nos dias de hoje há uma riqueza de informações sobre o novo suporte para async e await no Microsoft .NET Framework 4.5. Este artigo é a "segunda etapa" do aprendizado sobre programação assíncrona; presumo que você já tenha lido pelo menos um artigo introdutório sobre o assunto. Este artigo não apresenta nada de novo, já que o mesmo conselho pode ser encontrado online em fontes como Stack Overflow, fóruns do MSDN e a perguntas frequentes sobre async/await. Este artigo destaca apenas algumas práticas recomendadas que podem se perder na avalanche de documentação disponível.

As práticas recomendadas neste artigo estão mais para o que você chamaria de "diretrizes" do que regras existentes. Há exceções para cada uma destas diretrizes. Explicarei o raciocínio por trás de cada diretriz para que fique claro quando é e não é aplicável. As diretrizes estão resumidas na Figura 1; falarei sobre cada uma nas seções a seguir.

Figura 1 Resumo das diretrizes de programação assíncrona

Name Description Exceções
Evitar async void Prefira métodos async Task aos métodos async void Manipuladores de eventos
Completamente assíncrono Não misturar código de bloqueio e assíncrono Método principal de console
Configurar o contexto Usar ConfigureAwait(false) quando for possível Métodos que requerem contexto

Evitar async void

Existem três tipos possíveis de retorno para métodos async: Task, Task<T> e void, mas os tipos de retorno naturais para os métodos async são apenas Task e Task<T>. Ao converter o código síncrono em assíncrono, qualquer método que retorna um tipo T torna-se um método async que retorna Task<T>, e qualquer método que retorna void torna-se um método async que retorna Task. O trecho de código a seguir ilustra um método de retorno void síncrono e seu equivalente assíncrono:

void MyMethod()
{
  // Do synchronous work.
  Thread.Sleep(1000);
}
async Task MyMethodAsync()
{
  // Do asynchronous work.
  await Task.Delay(1000);
}

Os métodos async que retornam void têm uma finalidade específica: viabilizar manipuladores de eventos assíncronos. É possível haver um manipulador de eventos que retorne algum tipo real, mas isso não funciona bem com a linguagem; invocar um manipulador de eventos que retorna um tipo é muito incomum, e a noção de um manipulador de eventos retornando algo de fato não faz muito sentido. Os manipuladores de eventos naturalmente retornam void e, portanto, os métodos async retornam void para que você possa ter um manipulador de eventos assíncrono. No entanto, parte da semântica de um método async void é sutilmente diferente da semântica de um método async Task ou async Task<T>.

Os métodos async void têm semânticas diferentes de tratamento de erros. Quando uma exceção é lançada fora de um método async Task ou async Task<T>, essa exceção é capturada e colocada no objeto Task. Com métodos async void, não há objeto Task. Portanto, todas as exceções lançadas fora do método async void serão geradas diretamente no SynchronizationContext que estava ativo quando o método async void foi iniciado. A Figura 2 ilustra que as exceções lançadas dos métodos async void não podem ser detectadas naturalmente.

Figura 2 Exceções de um método async void não podem ser detectadas com Catch

private async void ThrowExceptionAsync()
{
  throw new InvalidOperationException();
}
public void AsyncVoidExceptions_CannotBeCaughtByCatch()
{
  try
  {
    ThrowExceptionAsync();
  }
  catch (Exception)
  {
    // The exception is never caught here!
    throw;
  }
}

Essas exceções podem ser observadas usando AppDomain.UnhandledException ou um evento catch-all semelhante para aplicativos GUI/ASP.NET, mas usar esses eventos para tratamento de exceção regular é uma receita para incapacidade de gerenciamento.

Os métodos async void têm semânticas de composição diferentes. Métodos async que retornam Task ou Task<T> podem ser facilmente compostos usando await, Task.WhenAny, Task.WhenAll e assim por diante. Métodos async que retornam void não fornecem uma maneira fácil de informar o código de chamada que eles completaram. É fácil iniciar vários métodos de async void, mas não é fácil determinar quando eles terminaram. Os métodos async void notificarão seu SynchronizationContext quando começarem e terminarem, mas um SynchronizationContext personalizado é uma solução complexa para o código de aplicativo regular.

Métodos async void são difíceis de testar. Por causa das diferenças de tratamento de erros e composição, é difícil escrever testes de unidade que a chamam métodos async void. O suporte de teste assíncrono MSTest funciona apenas para métodos async que retornam Task ou Task<T>. É possível instalar um SynchronizationContext que detecta quando todos os métodos async void foram concluídos e coletar as exceções, mas é muito mais fácil fazer apenas os métodos async void retornarem Task.

É claro que métodos async void têm várias desvantagens em relação aos métodos async Task, mas eles são bastante úteis em um caso específico: manipuladores de eventos assíncronos. As diferenças na semântica fazem sentido para manipuladores de eventos assíncronos. Eles lançam suas exceções diretamente no SynchronizationContext, que é semelhante a como os manipuladores de eventos síncronos se comportam. Os manipuladores de eventos síncronos são geralmente privados. Então, não podem ser compostos ou testados diretamente. Uma abordagem que eu gosto de usar é minimizar o código em meu manipulador de eventos assíncronos — por exemplo, fazê-lo aguardar um método async Task que contém a lógica real. O código a seguir ilustra essa abordagem, usando métodos async void para manipuladores de eventos sem sacrificar a capacidade de teste:

private async void button1_Click(object sender, EventArgs e)
{
  await Button1ClickAsync();
}
public async Task Button1ClickAsync()
{
  // Do asynchronous work.
  await Task.Delay(1000);
}

Métodos async void podem causar estragos se o chamador não estiver esperando que eles sejam async. Quando o tipo de retorno é Task, o chamador sabe que está lidando com uma operação futura; quando o tipo de retorno é void, o chamador pode presumir que o método está concluído no momento em que retorna. Esse problema pode surgir de muitas maneiras inesperadas. É geralmente errado fornecer uma implementação (ou substituição) assíncrona de um método que retorna void em uma interface (ou classe base). Alguns eventos também presumem que seus manipuladores estão concluídos quando retornam. Uma armadilha sutil é passar um lambda async para um método usando um parâmetro Action; nesse caso, o lambda async retorna void e herda todos os problemas dos métodos async void. Como regra geral, lambdas async só devem ser usados se forem convertidos em um tipo de delegado que retorna Task (por exemplo, Func<Task>).

Para resumir essa primeira diretriz, prefira async Task a async void. Os métodos async Task facilita a manipulação de erros, com capacidade de composição e teste. A exceção a esta diretriz são os manipuladores de eventos assíncronos, que devem retornar void. Essa exceção inclui métodos que são logicamente manipuladores de eventos, mesmo se não forem literalmente manipuladores de eventos (por exemplo, implementações ICommand.Execute).

Completamente assíncrono

O código assíncrono me lembra da história de um colega que mencionou que o mundo era suspenso no espaço e foi imediatamente contestado por uma senhora idosa alegando que o mundo repousava sobre as costas de uma tartaruga gigante. Quando o homem perguntou sobre o que a tartaruga se apoiava, a senhora respondeu: "Você é muito inteligente, meu jovem, mas é tartaruga até embaixo!" Quando você converter código síncrono em assíncrono, verá que ele funciona melhor se o código assíncrono chamar e for chamado por outro código assíncrono — até o final (ou "início", se preferir). Outros também notaram o comportamento espalhado da programação assíncrona e o chamaram de "contagioso" ou o compararam com um vírus zumbi. Tartarugas ou zumbis, é definitivamente verdade que o código assíncrono tende a conduzir o código circundante a ser também assíncrono. Esse comportamento é inerente a todos os tipos de programação assíncrona, não apenas as novas palavras-chave async/await.

"Completamente assíncrono" significa que você não deve misturar código síncrono e assíncrono sem considerar criteriosamente as consequências. Em particular, é geralmente uma má ideia bloquear o código assíncrono chamando Task.Wait ou Task.Result. Esse é um problema especialmente comum para programadores que estão "mergulhando seus dedos" na programação assíncrona, convertendo apenas uma pequena parte de seu aplicativo e envolvendo-a em uma API síncrona para que o resto do aplicativo fique isolado das mudanças. Infelizmente, eles se deparam com problemas com deadlocks. Depois de responder a muitas perguntas relacionadas a async nos fóruns do MSDN, Stack Overflow e email, posso dizer que esta é de longe a pergunta mais frequente dos novatos em async assim que aprendem o básico: "Por que meu código parcialmente async gera deadlock?"

A Figura 3 mostra um exemplo simples onde um método bloqueia no resultado de um método async. Esse código funcionará muito bem em um aplicativo de console, mas se tornará deadlock quando chamado de um contexto de GUI ou ASP.NET. Esse comportamento pode ser confuso, especialmente considerando que acionar o depurador implica que o await nunca termina. A causa real do deadlock está mais acima da pilha de chamadas quando Task.Wait é chamado.

Figura 3 Um problema comum de deadlock quando há bloqueio no código assíncrono

public static class DeadlockDemo
{
  private static async Task DelayAsync()
  {
    await Task.Delay(1000);
  }
  // This method causes a deadlock when called in a GUI or ASP.NET context.
  public static void Test()
  {
    // Start the delay.
    var delayTask = DelayAsync();
    // Wait for the delay to complete.
    delayTask.Wait();
  }
}

A causa raiz desse deadlock é a forma como await trata os contextos. Por padrão, quando se espera uma tarefa incompleta, o "contexto" atual é capturado e usado para retomar o método quando a tarefa é concluída. Esse "contexto" é o SynchronizationContext atual a menos que seja nulo, caso em que é o TaskScheduler atual. Aplicativos GUI e ASP.NET têm um SynchronizationContext que permite somente um trecho de código seja executado por vez. Quando await termina, ele tenta executar o restante do método async dentro contexto capturado. Mas esse contexto já tem um thread, que (sincronicamente) está aguardando o método async terminar. Um fica aguardando o outro, causando um deadlock.

Observe que os aplicativos de console não causam esse deadlock. Eles têm um pool de threads SynchronizationContext em vez de um SynchronizationContext de um trecho por vez; assim, quando await é concluído, ele agenda o restante do método async em um thread do pool de threads. O método consegue terminar, o que conclui sua tarefa retornada, e não há deadlock. Essa diferença de comportamento pode ser confusa quando programadores escrevem um programa de console de teste, observam que o código parcialmente assíncrono funciona conforme o esperado e depois movem o mesmo código para um aplicativo GUI ou ASP.NET, onde ocorre deadlock.

A melhor solução para esse problema é permitir que o código assíncrono evolua naturalmente pela base de código. Se você seguir essa solução, verá que o código assíncrono expande para seu ponto de entrada, geralmente um manipulador de eventos ou ação de controlador. Os aplicativos de console não podem seguir essa solução totalmente porque o método Main não pode ser assíncrono. Se o método Main fosse assíncrono, ele poderia retornar antes ser concluído, causando a finalização do programa. A Figura 4 demonstra esta exceção à diretriz: o método Main para um aplicativo de console é uma das poucas situações em que o código pode bloquear no método assíncrono.

Figura 4 O método Main pode chamar Task.Wait ou Task.Result

class Program
{
  static void Main()
  {
    MainAsync().Wait();
  }
  static async Task MainAsync()
  {
    try
    {
      // Asynchronous implementation.
      await Task.Delay(1000);
    }
    catch (Exception ex)
    {
      // Handle exceptions.
    }
  }
}

Permitir que o async evolua pela base de código é a melhor solução, mas isso significa que há bastante trabalho inicial para um aplicativo ver o real benefício do código assíncrono. Existem algumas técnicas para converter incrementalmente uma grande base de código em código assíncrono, mas elas estão fora do escopo deste artigo. Em alguns casos, usar Task.Wait ou Task.Result pode ajudar em uma conversão parcial, mas você deve ficar atento ao problema de deadlock e ao problema de tratamento de erros. Explicarei o problema de tratamento de erros agora e mostrarei como evitar o problema de deadlock neste artigo.

Cada tarefa armazenará uma lista de exceções. Quando você espera uma tarefa, a primeira exceção é relançada; assim, você pode detectar o tipo de exceção específico (por exemplo, InvalidOperationException). No entanto, ao bloquear sincronicamente em uma tarefa usando Task.Wait ou Task.Result, todas as exceções são encapsuladas em uma AggregateException e lançadas. Veja novamente a Figura 4. O try/catch em MainAsync detectará um tipo de exceção específico, mas se você colocar o try/catch em Main, ele sempre detectará uma AggregateException. O tratamento de erros é muito mais fácil de se lidar quando você não tem uma AggregateException; então, eu coloquei o try/catch "global" em MainAsync.

Até agora, eu mostrei dois problemas com o bloqueio no código assíncrono: possíveis deadlocks e manipulação de erros mais complicada. Há também um problema com o uso de código de bloqueio em um método async. Considere este exemplo simples:

public static class NotFullyAsynchronousDemo
{
  // This method synchronously blocks a thread.
  public static async Task TestNotFullyAsync()
  {
    await Task.Yield();
    Thread.Sleep(5000);
  }
}

Este método não é totalmente assíncrono. Ele renderá imediatamente, retornando uma tarefa incompleta, mas quando ele reiniciar sincronicamente, irá bloquear qualquer thread que estiver sendo executado. Se esse método for chamado de um contexto de GUI, ele irá bloquear o thread de GUI; se ele for chamado de um contexto de solicitação do ASP.NET, ele irá bloquear o thread de solicitação do ASP.NET atual. O código assíncrono funciona melhor se não bloquear sincronicamente. A Figura 5 é uma "cola" das substituições de async para operações síncronas.

Figura 5 O "jeito async" de fazer as coisas

Para fazer isto... Em vez disto... Use isto
Recuperar o resultado de uma tarefa em segundo plano Task.Wait ou Task.Result await
Aguardar a conclusão de qualquer tarefa Task.WaitAny await Task.WhenAny
Recuperar os resultados de várias tarefas Task.WaitAll await Task.WhenAll
Aguardar um período Thread.Sleep await Task.Delay

Para resumir essa segunda diretriz, evite misturar async e código de bloqueio. Misturar async e código de bloqueio pode causar deadlocks, tratamento de erros mais complexo e bloqueio inesperado dos threads de contexto. A exceção a essa diretriz é o método Main para aplicativos de console, ou — se você for um usuário avançado — gerenciar uma base de código parcialmente assíncrona.

Configurar o contexto

No início deste artigo, expliquei brevemente como o "contexto" é capturado por padrão quando uma tarefa incompleta é aguardada, e que esse contexto capturado é usado para retomar o método async. O exemplo na Figura 3 mostra como retomar o contexto quando há conflito com o bloqueio síncrono para causar um deadlock. Esse comportamento de contexto também pode causar outro problema — de desempenho. Conforme os aplicativos GUI assíncronos ficam maiores, você pode encontrar muitas partes pequenas de métodos async usando o thread de GUI como contexto. Isso pode causar lentidão já que a capacidade de resposta sofre "milhares de cortes de papel".

Para atenuar isso, aguarde o resultado de ConfigureAwait sempre que possível. O trecho de código a seguir ilustra o comportamento de contexto padrão e o uso de ConfigureAwait:

async Task MyMethodAsync()
{
  // Code here runs in the original context.
  await Task.Delay(1000);
  // Code here runs in the original context.
  await Task.Delay(1000).ConfigureAwait(
    continueOnCapturedContext: false);
  // Code here runs without the original
  // context (in this case, on the thread pool).
}

Usando ConfigureAwait, você permite uma pequena quantidade de paralelismo: parte do código assíncrono pode ser executado em paralelo com o thread de GUI em vez de se cansar constantemente com fragmentos de trabalho a fazer.

Além de desempenho, o ConfigureAwait tem um outro aspecto importante: pode evitar deadlocks. Considere a Figura 3 novamente; se você adicionar "ConfigureAwait(false)" à linha de código em DelayAsync, o deadlock é evitado. Dessa vez, quando await termina, ele tenta executar o restante do método async dentro contexto do pool de threads. O método consegue terminar, o que conclui sua tarefa retornada, e não há deadlock. Essa técnica é particularmente útil quando você precisa converter gradualmente um aplicativo síncrono em assíncrono.

Se você puder usar ConfigureAwait em algum ponto do método, então recomendo usá-lo para cada await desse método após esse ponto. Lembre-se de que o contexto é capturado apenas quando se espera uma tarefa incompleta; se a tarefa já estiver completa, o contexto não é capturado. Algumas tarefas podem ser concluídas mais rapidamente do que o esperado em situações de rede e hardware diferentes, e você precisa lidar graciosamente com a tarefa retornada como concluída antes de ser aguardada. A Figura 6 mostra um exemplo modificado.

Figura 6 Como lidar com uma tarefa retornada como concluída antes de ser aguardada

async Task MyMethodAsync()
{
  // Code here runs in the original context.
  await Task.FromResult(1);
  // Code here runs in the original context.
  await Task.FromResult(1).ConfigureAwait(continueOnCapturedContext: false);
  // Code here runs in the original context.
  var random = new Random();
  int delay = random.Next(2); // Delay is either 0 or 1
  await Task.Delay(delay).ConfigureAwait(continueOnCapturedContext: false);
  // Code here might or might not run in the original context.
  // The same is true when you await any Task
  // that might complete very quickly.
}

Você não deve usar ConfigureAwait quando há código await no método que precisa do contexto. Para aplicativos GUI, isso inclui qualquer código que manipula os elementos GUI, agrava propriedades associadas a dados ou depende de um tipo específico de GUI como Dispatcher/CoreDispatcher. Para aplicativos ASP.NET, isso inclui qualquer código que usa HttpContext.Current ou gera uma resposta do ASP.NET, incluindo instruções de retorno em ações do controlador. A Figura 7 demonstra um padrão comum em aplicativos GUI — fazer com que um manipulador de eventos async desabilite seu controle no início do método, executar alguns awaits e reabilitar o controle no final do manipulador; o manipulador de eventos não pode desistir de seu contexto porque ele precisa reabilitar seu controle.

Figura 7 Fazer com que um manipulador de eventos async desabilite e reabilite seu controle

private async void button1_Click(object sender, EventArgs e)
{
  button1.Enabled = false;
  try
  {
    // Can't use ConfigureAwait here ...
    await Task.Delay(1000);
  }
  finally
  {
    // Because we need the context here.
    button1.Enabled = true;
  }
}

Cada método async tem seu próprio contexto e, portanto, se um método async chamar outro método async, seus contextos serão independentes. A Figura 8 mostra uma pequena modificação da Figura 7.

Figura 8 Cada método async tem seu próprio contexto

private async Task HandleClickAsync()
{
  // Can use ConfigureAwait here.
  await Task.Delay(1000).ConfigureAwait(continueOnCapturedContext: false);
}
private async void button1_Click(object sender, EventArgs e)
{
  button1.Enabled = false;
  try
  {
    // Can't use ConfigureAwait here.
    await HandleClickAsync();
  }
  finally
  {
    // We are back on the original context for this method.
    button1.Enabled = true;
  }
}

O código sem contexto é mais reutilizável. Tente criar uma barreira em seu código entre o código sensível ao contexto e o código sem contexto, e minimize o código sensível ao contexto. Na Figura 8, recomendo colocar toda a lógica central do manipulador de eventos em um método async Task testável e sem contexto, deixando somente o mínimo de código no manipulador de eventos sensíveis ao contexto. Mesmo se você estiver escrevendo um aplicativo ASP.NET, se tiver uma biblioteca central possivelmente compartilhada com aplicativos de desktop, considere usar ConfigureAwait no código da biblioteca.

Para resumir essa terceira diretriz, use Configure­Await quando possível. O código sem contexto tem melhor desempenho para aplicativos GUI e é uma técnica útil para evitar deadlocks ao trabalhar com uma base de código parcialmente assíncrona. As exceções a essa diretriz são métodos que exigem o contexto.

Saiba quais são suas ferramentas

Há muito a aprender sobre async e await, e é natural ficar um pouco desorientado. A Figura 9 é uma referência rápida de soluções para problemas comuns.

Figura 9 Soluções para problemas comuns de async

Problema Solução
Criar uma tarefa para executar o código Task.Run ou TaskFactory.StartNew (não o construtor Task ou Task.Start)
Criar um wrapper de tarefa para uma operação ou um evento TaskFactory.FromAsync ou TaskCompletionSource<T>
Cancelamento de suporte CancellationTokenSource e CancellationToken
Relatar o andamento IProgress<T> e Progress<T>
Lidar com fluxos de dados TPL Dataflow ou Reactive Extensions
Sincronizar o acesso a um recurso compartilhado SemaphoreSlim
Inicializar um recurso assincronamente AsyncLazy<T>
Estruturas de produtor/consumidor ideais para async TPL Dataflow ou AsyncCollection<T>

O primeiro problema é a criação da tarefa. Obviamente, um método async pode criar uma tarefa, e essa é a opção mais fácil. Se você precisar executar o código no pool de threads, use Task.Run. Se você quiser criar um wrapper de tarefa para uma operação ou um evento assíncrono existente, use TaskCompletionSource<T>. O próximo problema comum é como lidar com cancelamento e relatórios de andamento. A biblioteca de classes de base (BCL) inclui tipos especificamente destinados a resolver esses problemas: CancellationTokenSource/CancellationToken e IProgress<T>/Progress<T>. O código assíncrono deve usar o padrão assíncrono baseado em tarefa, ou TAP (msdn.microsoft.com/library/hh873175), que explica a criação da tarefa, o cancelamento e o relatório de andamento em detalhes.

Outro problema que surge é como lidar com fluxos de dados assíncronos. Os códigos Task são ótimos, mas podem retornar apenas um objeto e conclusão por vez. Para fluxos assíncronos, você pode usar TPL Dataflow ou Reactive Extensions (Rx). TPL Dataflow cria uma "malha" que parece ser o atuante. Rx é mais poderoso e eficiente, mas tem uma curva de aprendizado mais difícil. TPL Dataflow e Rx têm métodos ideais para async e funcionam bem com o código assíncrono.

Só porque seu código é assíncrono não significa que ele é seguro. Recursos compartilhados ainda precisam ser protegidos, e isso é complicado pelo fato de que você não pode executar await em um bloqueio. Este é um exemplo de código assíncrono que pode corromper o estado compartilhado se for executado duas vezes, mesmo se for sempre executado no mesmo thread:

int value;

Task<int> GetNextValueAsync(int current);

async Task UpdateValueAsync()

{

  value = await GetNextValueAsync(value);

}

O problema é que o método lê o valor e suspende a si próprio no await, e quando o método é retomado, ele presume que o valor não mudou. Para resolver esse problema, a classe SemaphoreSlim foi aumentada com as sobrecargas WaitAsync ideais para async. A Figura 10 demonstra SemaphoreSlim.WaitAsync.

Figura 10 SemaphoreSlim permite sincronização assíncrona

SemaphoreSlim mutex = new SemaphoreSlim(1);

int value;

Task<int> GetNextValueAsync(int current);

async Task UpdateValueAsync()

{

  await mutex.WaitAsync().ConfigureAwait(false);

  try

  {

    value = await GetNextValueAsync(value);

  }

  finally

  {

    mutex.Release();

  }

}

O código assíncrono é usado muitas vezes para inicializar um recurso que é armazenado em cache e compartilhado. Não há um tipo interno para isso, mas Stephen Toub desenvolveu um AsyncLazy<T> que age como uma combinação de Task<T> e Lazy<T>. O tipo original é descrito em seu blog (bit.ly/dEN178), e uma versão atualizada está disponível na minha biblioteca AsyncEx (nitoasyncex.codeplex.com).

Finalmente, algumas estruturas de dados ideais para async são necessárias às vezes. O TPL Dataflow fornece um BufferBlock<T>, que funciona como uma fila de produtores/consumidores ideal para async. Opcionalmente, o AsyncEx fornece AsyncCollection<T>, que é uma versão assíncrona de BlockingCollection<T>.

Espero que as diretrizes e dicas neste artigo tenham sido úteis. Async é um recurso de linguagem verdadeiramente impressionante e agora é um ótimo momento para começar a usá-lo!

Stephen Cleary é marido, pai e programador que mora no norte de Michigan. Ele trabalha com multithreading e programação assíncrona há 16 anos e tem usado o suporte assíncrono no Microsoft .NET Framework desde o primeiro CTP. Seu site, incluindo seu blog, é stephencleary.com.

Agradecemos ao seguinte especialista técnico pela revisão deste artigo: Stephen Toub
Stephen Toub trabalha na equipe do Visual Studio da Microsoft. Ele se especializou em áreas relacionadas a paralelismo e assincronia.