C# -  Async e Await - Embaixo do Capô - II


 Hoje continuamos a tratar da programação assíncrona com async e await no C#.

Continuando o artigo anterior vamos entender o que esta por trás do async e await .

Async e Await — Abrindo o capô

A palavra-chave async é, na verdade, apenas uma maneira de eliminar a ambigüidade do compilador em relação a await. Então, quando falamos sobre a abordagem async/await, é realmente a palavra-chave await que faz todo o trabalho pesado. Mas antes de vermos o que o await faz, vamos falar sobre o que ele não faz.

A palavra-chave await não bloqueia a thread atual. O que queremos dizer com isso ?

Considere o seguinte código:


 System.Threading.Thread.Sleep(1000);   
 

O código acima bloqueia a execução da thread atual por um segundo. Outras threads no aplicativo podem continuar a executar, mas a thread atual não faz absolutamente nada até que a operação de suspensão seja concluída. Outra maneira de descrevê-lo é que a thread espera de forma síncrona.

Agora, vejamos outro exemplo, desta vez da Biblioteca Task Parallel Library:


 var httpClient = new HttpClient();

 var minhaTask = httpClient.GetStringAsync("https://...");

 var minhaString = minhaTask.GetAwaiter().GetResult();
 

No trecho de código acima, a classe HttpClient está retornando uma instância de Task, mas estamos chamando GetAwaiter().GetResult() na tarefa, que é uma chamada de bloqueio.

Novamente, isso é um código síncrono; nenhuma execução ocorrerá na thread atual até que GetResult retorne com os dados retornados pela operação (os dados de string solicitados neste exemplo). Da mesma forma, a propriedade Result de uma tarefa também é bloqueada de forma síncrona até que os dados sejam retornados.

Por último, mas não menos importante, há também um método Wait() que está bloqueando, por exemplo:

...
   minhaTask.Wait();  
...

Mesmo se a tarefa for assíncrona, se você chamar um método de bloqueio ou propriedade de bloqueio na tarefa, a execução aguardará a conclusão da tarefa - mas fará isso de forma síncrona, de modo que a thread atual esteja completamente ocupada durante a espera. Portanto, se você usar uma das propriedades ou métodos acima, certifique-se de que é realmente isso que você pretende fazer.

A palavra-chave await, por outro lado, não bloqueia, o que significa que a thread atual está livre para fazer outras coisas durante a espera.

Mas o que mais a thread atual estaria fazendo ?

await da perspectiva do método chamador

Sabemos agora que a palavra-chave await não bloqueia a thread - ele libera a thread de chamada. Mas como esse comportamento sem bloqueio se manifesta no método de chamada ? 

Considere o seguinte código.

void OnButtonClick()
{
  DownloadETrataImagem("https://...jpg");
  ShowDialog("Sucesso!");
}

async Task DownloadETrataImagem(string url)
{
  await DownloadImagem(...);
  await TrataImagem(...);
  await SalvaImagem(...);
}

Se você executasse este código, notaria um problema: a caixa de diálogo de sucesso é exibida antes da conclusão da operação de download!

Isso demonstra um ponto importante: quando um método que usa await não é esperado, a execução do método de chamada continua antes que o método chamado seja concluído.

Podemos,  corrigir isso  usando um await e um async em OnButtonClick da seguinte forma:

async void OnButtonClick()
{
  await DownloadETrataImagem("https://...jpg");
  ShowDialog("Sucesso!");
}

async Task DownloadETrataImagem(string url)
{
  await DownloadImagem(...);
  await TrataImagem(...);
  await SalvaImagem(...);
}

Mas uma questão maior permanece.

Em todos os casos, em algum ponto após um await, um método precisa "acordar", por assim dizer, e continuar com o restante de seu código.

Como exatamente a execução retoma uma parte de um método ?

Para responder a essa pergunta, vamos tentar registrar a pilha de chamadas em DownloadETrataImagem. Uma maneira de fazer isso é usar:

Console.WriteLine(new System.Diagnostics.StackTrace());

Que vai resultar no seguinte resultado:

at OnButtonClick()
 at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
  at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.AsyncStateMachineBox`1.MoveNext()
  at System.Threading.Tasks.AwaitTaskContinuation.RunOrScheduleAction(IAsyncStateMachineBox box, Boolean allowInlining)
  at System.Threading.Tasks.Task.RunContinuations(Object continuationObject)
  at System.Threading.Tasks.Task`1.TrySetResult(TResult result)
  at System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1.SetExistingTaskResult(TResult result)
  at System.Runtime.CompilerServices.AsyncTaskMethodBuilder.SetResult()
at DownloadAndBlur()
  at System.Threading.ExecutionContext.RunInternal...

Observe que há muitos métodos sendo chamados aqui que não definimos em nosso código, incluindo AsyncStateMachineBox.MoveNext() e AsyncTaskMethodBuilder.SetResult. É evidente que o compilador está gerando um monte de código em nosso nome para acompanhar o estado de execução.

Os detalhes do código gerado estão fora do escopo deste artogp (e variam dependendo do compilador C# e da versão), mas basta dizer que existe uma máquina de estado produzida que usa instruções goto em combinação com os métodos da Biblioteca Task Parallel, juntamente com algum rastreamento de exceção e contexto (ou seja, thread).

Se você estiver interessado em ir mais fundo, tente inspecionar um assembly .NET que inclua await em um descompilador .NET (ILSpy) capaz de descompilar para C#. Desta forma, você pode ver todos os detalhes!

Manipulação de exceção com Await

Embora o fluxo de controle no que diz respeito ao tratamento de exceções seja exatamente o esperado ao usar await, vale a pena repetir que o oposto não é o caso.

Considere o seguinte código:

async void OnButtonClick
{
  string imagemUrl = null;
  try
  {
    DownloadETrataImagem(imagemUrl);
  }
  catch (Exception ex)
  {
    Console.WriteLine($"Exception: {ex}");
  }  
  Console.WriteLine("Concluído!");
}
async Task DownloadETrataImagem(string url)
{
  if (url == null)
  {
    throw new ArgumentNullException(nameof(url));
  }  
  ...
}

Pode-se esperar que a saída inclua a Exception: ArgumentNullException. No entanto, não é apenas esse o caso, a menos que você tenha um depurador anexado que faça uma pausa em todas as exceções, você não saberá que uma exceção ocorreu !

No entanto, esse problema não surge ao usar o await. Portanto, a menos que você tenha um bom motivo para não fazer isso, é melhor usar await em todos os seus métodos assíncronos.

Mas, você pode perguntar, e o que costuma ser chamado de "disparar e esquecer"?

Isso se refere à situação em que você não deseja aguardar (usar await em) um método assíncrono e não está particularmente preocupado quando ele terminar.

Nesse caso, considere, no mínimo, adicionar um ContinueWith com TaskContinuationOptions.OnlyOnFaulted onde você pode registrar quaisquer exceções que possam surgir. Melhor ainda, vá em frente e aguarde (use await no) o método, mas faça com que a chamada do método seja a última coisa que você faz em seu método externo.

Dessa forma, nenhum de seus outros códigos terá sua execução adiada enquanto ainda aproveita o tratamento de exceção que vem com o uso de await.

Desta forma , embora o fluxo de controle de um aplicativo seja sempre um pouco complicado quando ele possui aspectos assíncronos, saber um pouco sobre como o await funciona nos bastidores ajuda muito.

A coisa mais importante sobre a palavra-chave await é usá-la. Conforme você observa o comportamento de seu aplicativo e soluciona casos extremos, o fluxo de controle de await apresentado será reforçado em sua mente e sua confiança em relação a essa poderosa ferramenta aumentará.

E estamos conversados
...

"O que, passando, se põe em questão alheia, é como aquele que pega um cão pelas orelhas."
Provérbios 26:17

Referências:


José Carlos Macoratti