Dessincronização assíncrona: antipadrões no trabalho com async / waitit no .NET

Qual de nós não corta a relva? Encontro regularmente erros no código assíncrono e os faço sozinho. Para parar esta roda do Samsara, estou compartilhando com vocês os batentes mais típicos daqueles que às vezes são bastante difíceis de entender e consertar.




Este texto é inspirado no blog de Stephen Clary , um homem que sabe tudo sobre competitividade, assincronia, multithreading e outras palavras assustadoras. Ele é o autor do livro Concurrency in C # Cookbook , que coletou um grande número de padrões para trabalhar com a concorrência.

Impasse assíncrono clássico


Para entender o impasse assíncrono, vale a pena descobrir qual thread executa o método invocado usando a palavra-chave wait.


Primeiro, o método irá mergulhar na cadeia de chamadas de métodos assíncronos até encontrar uma fonte de assincronia. Como exatamente a origem da assincronia é implementada é um tópico que está além do escopo deste artigo. Agora, por simplicidade, assumimos que esta é uma operação que não requer um fluxo de trabalho enquanto aguarda seu resultado, por exemplo, uma solicitação de banco de dados ou HTTP. O início síncrono de uma operação desse tipo significa que, enquanto espera pelo resultado no sistema, haverá pelo menos um encadeamento adormecido que consome recursos, mas não realiza nenhum trabalho útil.


Em uma chamada assíncrona, meio que interrompemos o fluxo de execução dos comandos no “antes” e “depois” da operação assíncrona, e no .NET não há garantias de que o código que está aguardando seja executado no mesmo encadeamento que o código anterior. Na maioria dos casos, isso não é necessário, mas o que fazer quando esse comportamento é vital para o programa funcionar? Precisa usar SynchronizationContext . Este é um mecanismo que permite impor certas restrições nos segmentos em que o código é executado. Em seguida, trataremos de dois contextos de sincronização ( WindowsFormsSynchronizationContext e AspNetSynchronizationContext ), mas Alex Davis escreve em seu livro que existem cerca de uma dúzia deles no .NET. Sobre o SynchronizationContext bem escrito aqui , aqui e aqui, o autor implementou o seu próprio, pelo qual ele tem um grande respeito.


Portanto, assim que o código chega à origem da assincronia, ele salva o contexto de sincronização, que estava na propriedade estática do thread de SynchronizationContext.Current , a operação assíncrona inicia e libera o thread atual. Em outras palavras, enquanto aguardamos a conclusão da operação assíncrona, não bloqueamos um único encadeamento e esse é o principal lucro da operação assíncrona em comparação com a síncrona. Após concluir a operação assíncrona, devemos seguir as instruções localizadas após a fonte assíncrona e, aqui, para decidir em qual encadeamento executar o código após a operação assíncrona, precisamos consultar o contexto de sincronização salvo anteriormente. Como ele diz, faremos isso. Ele lhe dirá para executar no mesmo encadeamento que o código antes de aguardar - nós executaremos no mesmo encadeamento, não diremos - pegaremos o primeiro encadeamento do pool.


Mas e se, nesse caso em particular, for importante para nós que o código após a espera seja executado em qualquer encadeamento livre do pool de encadeamentos? Você precisa usar o mantra ConfigureAwait(false) . O valor falso passado para o parâmetro continueOnCapturedContext informa ao sistema que qualquer encadeamento do pool pode ser usado. E o que acontece se, no momento da execução do método com aguardar, não havia contexto de sincronização ( SynchronizationContext.Current == null ), como por exemplo, em um aplicativo de console. Nesse caso, não temos restrições no encadeamento no qual o código deve ser executado depois de aguardar, e o sistema pegará o primeiro encadeamento que vier do pool, como no caso de ConfigureAwait(false) .


Então, o que é um impasse assíncrono?


Impasse no WPF e WinForms


A diferença entre os aplicativos WPF e WinForms é o próprio contexto de sincronização. O contexto de sincronização do WPF e do WinForms possui um thread especial - o thread da interface do usuário. Há um encadeamento da interface do usuário por SynchronizationContext e somente desse encadeamento pode interagir com os elementos da interface do usuário. Por padrão, o código que começou a funcionar no encadeamento da interface do usuário continua a operação após uma operação assíncrona.


Agora vamos ver um exemplo:

 private void Button_Click(object sender, System.Windows.RoutedEventArgs e) { StartWork().Wait(); } private async Task StartWork() { await Task.Delay(100); var s = "Just to illustrate the instruction following await"; } 

O que acontece quando você chama StartWork().Wait() :

  1. O thread de chamada (e este é o thread da interface do usuário) irá para o método StartWork e StartWork para a await Task.Delay(100) .
  2. O thread da interface do usuário iniciará a Task.Delay(100) assíncrona Task.Delay(100) e retornará o controle ao método Button_Click , e o método Wait() da classe Task estará esperando por ele. Quando o método Wait() é chamado, o encadeamento da interface do usuário será bloqueado até o final da operação assíncrona, e esperamos que, assim que concluído, o encadeamento da interface do usuário inicie imediatamente a execução e vá além no código, no entanto, nem tudo será assim.
  3. Assim que Task.Delay(100) concluído, o thread da interface do usuário precisará primeiro continuar executando o método StartWork() e, para isso, precisa exatamente do thread no qual a execução foi iniciada. Mas o encadeamento da interface do usuário agora está aguardando o resultado da operação.
  4. StartWork() : StartWork() não pode continuar a execução e retornar o resultado, e Button_Click está aguardando o mesmo resultado e, devido ao fato de a execução ter iniciado no encadeamento da interface com o usuário, o aplicativo simplesmente trava sem chance de continuar trabalhando.

Essa situação pode ser tratada simplesmente alterando a chamada para Task.Delay(100) para Task.Delay(100).ConfigureAwait(false) :

 private void Button_Click(object sender, System.Windows.RoutedEventArgs e) { StartWork().Wait(); } private async Task StartWork() { await Task.Delay(100).ConfigureAwait(false); var s = "Just to illustrate the instruction following await"; } 

Esse código funcionará sem conflitos, pois agora um segmento do pool pode ser usado para concluir o método StartWork() , em vez de um segmento da interface do usuário bloqueado. Stephen Clary recomenda usar o ConfigureAwait(false) em todos os "métodos de biblioteca" em seu blog, mas enfatiza especificamente que o uso do ConfigureAwait(false) para tratar impasses não é uma boa prática. Em vez disso, ele aconselha NÃO usar métodos de bloqueio como Wait() , Result , GetAwaiter().GetResult() e GetAwaiter().GetResult() todos os métodos para usar async / waitit, se possível (o princípio chamado princípio assíncrono).


Impasse no ASP.NET


O ASP.NET também possui um contexto de sincronização, mas possui limitações um pouco diferentes. Ele permite que você use apenas um encadeamento por solicitação por vez e também exige que o código após aguardar seja executado no mesmo encadeamento que o código antes de aguardar.


Um exemplo:

 public class HomeController : Controller { public ActionResult Deadlock() { StartWork().Wait(); return View(); } private async Task StartWork() { await Task.Delay(100); var s = "Just to illustrate the code following await"; } } 

Esse código também causará um impasse, uma vez que, no momento da chamada para StartWork().Wait() único thread permitido será bloqueado e aguardará a StartWork() operação StartWork() e nunca será encerrada, pois o segmento em que a execução deve continuar está ocupado esperando.


Tudo isso é corrigido pelo mesmo ConfigureAwait(false) .


Impasse no ASP.NET Core (na verdade não)


Agora vamos tentar executar o código do exemplo para o ASP.NET no projeto para o ASP.NET Core. Se fizermos isso, veremos que não haverá impasse. Isso ocorre porque o ASP.NET Core não possui um contexto de sincronização . Ótimo! E agora você pode cobrir o código bloqueando chamadas e não ter medo de conflitos? Estritamente falando, sim, mas lembre-se de que isso faz com que o thread adormeça enquanto aguarda, ou seja, o thread consome recursos, mas não realiza nenhum trabalho útil.




Lembre-se de que o uso de chamadas de bloqueio elimina todas as vantagens da programação assíncrona, transformando-a em síncrona . Sim, às vezes sem usar Wait() , não funcionará para escrever um programa, mas o motivo deve ser sério.

Uso incorreto de Task.Run ()


O método Task.Run() foi criado para iniciar operações em um novo thread. Como convém a um método escrito em um padrão TAP, ele retorna Task ou Task<T> e as pessoas que encontram primeiro async / wait têm um grande desejo de quebrar o código síncrono em Task.Run() e sacrificar o resultado desse método. O código parecia se tornar assíncrono, mas, na verdade, nada mudou. Vamos ver o que acontece com esse uso do Task.Run() .


Um exemplo:

 private static async Task ExecuteOperation() { Console.WriteLine($"Before: {Thread.CurrentThread.ManagedThreadId}"); await Task.Run(() => { Console.WriteLine($"Inside before sleep: {Thread.CurrentThread.ManagedThreadId}"); Thread.Sleep(1000); Console.WriteLine($"Inside after sleep: {Thread.CurrentThread.ManagedThreadId}"); }); Console.WriteLine($"After: {Thread.CurrentThread.ManagedThreadId}"); } 

O resultado desse código será:

 Before: 1 Inside before sleep: 3 Inside after sleep: 3 After: 3 

Aqui Thread.Sleep(1000) é algum tipo de operação síncrona que requer que um thread seja concluído. Suponha que desejemos tornar nossa solução assíncrona e, para que essa operação possa ser sacrificada, envolvemos-a em Task.Run() .


Assim que o código atinge o método Task.Run() , outro thread é retirado do pool de threads e o código que passamos para Task.Run() é executado nele. O thread antigo, como convém a um thread decente, retorna ao pool e espera que seja chamado novamente para fazer o trabalho. O novo encadeamento executa o código transmitido, alcança a operação síncrona, executa de forma síncrona (espera até que a operação seja concluída) e avança mais no código. Em outras palavras, a operação permaneceu síncrona: nós, como antes, usamos o fluxo durante a execução da operação síncrona. A única diferença é que passamos um tempo alternando contextos ao chamar Task.Run() e retornar a ExecuteOperation() . Tudo ficou um pouco pior.


Deve-se entender que, apesar do fato de que nas linhas Inside after sleep: 3 e After: 3 , vemos o mesmo ID do fluxo, o contexto de execução é completamente diferente nesses locais. O ASP.NET é simplesmente mais inteligente do que nós e tenta economizar recursos ao alternar o contexto do código dentro de Task.Run() para o código externo. Aqui ele decidiu não mudar pelo menos o fluxo de execução.


Nesses casos, não faz sentido usar Task.Run() . Em vez disso, Clary recomenda tornar todas as operações assíncronas, ou seja, no nosso caso, substituir Thread.Sleep(1000) por Task.Delay(1000) , mas isso, é claro, nem sempre é possível. O que fazer nos casos em que usamos bibliotecas de terceiros que não podemos ou não queremos reescrever e tornar assíncronas até o fim, mas por um motivo ou outro, precisamos do método assíncrono? É melhor usar Task.FromResult() para Task.FromResult() o resultado dos métodos do fornecedor na tarefa. Obviamente, isso não tornará o código assíncrono, mas pelo menos economizaremos na alternância de contexto.


Por que então usar Task.Run ()? A resposta é simples: para operações ligadas à CPU, quando você precisa manter a capacidade de resposta da interface do usuário ou paralelizar os cálculos. Deve-se dizer aqui que as operações ligadas à CPU são de natureza síncrona. Foi para iniciar operações síncronas em um estilo assíncrono que Task.Run() foi inventado.

Uso indevido de vácuo assíncrono


A capacidade de escrever métodos assíncronos que retornam void foi adicionada para gravar manipuladores de eventos assíncronos. Vamos ver por que eles podem causar confusão se forem usados ​​para outros fins:

  1. Você não pode esperar pelo resultado.
  2. Não há suporte para manipulação de exceção através de try-catch.
  3. É impossível combinar chamadas através de Task.WhenAll() , Task.WhenAny() e outros métodos semelhantes.

De todas essas razões, o ponto mais interessante é o tratamento de exceções. O fato é que, nos métodos assíncronos que retornam Task ou Task<T> , as exceções são capturadas e agrupadas em um objeto Task , que será passado para o método de chamada. Em seu artigo para o MSDN, Clary escreve que, como não há valor de retorno nos métodos async-void, não há nada para incluir exceções e elas são lançadas diretamente no contexto da sincronização. O resultado é uma exceção não tratada devido à falha do processo, tendo tempo para, talvez, gravar um erro no console. Você pode obter e reservar essas exceções assinando o evento AppDomain.UnhandledException , mas não poderá parar a falha do processo, mesmo no manipulador deste evento. Esse comportamento é típico apenas para o manipulador de eventos, mas não para o método usual, do qual esperamos a possibilidade de manipulação de exceção padrão por meio de try-catch.


Por exemplo, se você escrever assim em um aplicativo ASP.NET Core, o processo certamente cairá:

 public IActionResult ThrowInAsyncVoid() { ThrowAsynchronously(); return View(); } private async void ThrowAsynchronously() { throw new Exception("Obviously, something happened"); } 

Mas vale a pena alterar o tipo de retorno do método ThrowAsynchronously para Task (sem adicionar a palavra-chave ThrowAsynchronously ) e a exceção será capturada pelo manipulador de erros padrão do ASP.NET Core, e o processo continuará ativo, apesar da execução.


Tenha cuidado com os métodos assíncronos - eles podem colocar você no processo.

aguardar em um método de linha única


O último antipadrão não é tão assustador quanto os anteriores. A conclusão é que não faz sentido usar async / waitit em métodos que, por exemplo, simplesmente encaminham o resultado de outro método assíncrono, com a possível exceção de usar wait em uso .


Em vez deste código:

 public async Task MyMethodAsync() { await Task.Delay(1000); } 

seria inteiramente possível (e preferencialmente) escrever:
 public Task MyMethodAsync() { return Task.Delay(1000); } 

Por que isso funciona? Porque a palavra-chave wait pode ser aplicada a objetos do tipo Tarefa e não a métodos marcados com a palavra-chave assíncrona. Por sua vez, a palavra-chave async diz ao compilador que esse método precisa ser implantado em uma máquina de estado e agrupa todos os valores de retorno em uma Task (ou em outro objeto semelhante a uma tarefa).


Em outras palavras, o resultado da primeira versão do método é Task , que será Completed assim que a espera por Task.Delay(1000) terminar, e o resultado da segunda versão do método for Task , retornado pelo próprio Task.Delay(1000) , que será Completed assim que os milissegundos passarem .


Como você pode ver, ambas as versões são equivalentes, mas, ao mesmo tempo, a primeira requer muito mais recursos para criar um "kit corporal" assíncrono.


Alex Davis escreve que o custo de chamar diretamente o método assíncrono pode ser dez vezes o custo de chamar o método síncrono , portanto, há algo a ser tentado.


UPD:
Como os comentários apontam corretamente, a observação assíncrona / aguardada a partir de métodos de linha única leva a efeitos colaterais negativos. Por exemplo, ao lançar uma exceção, o método que lança a Tarefa para cima não será visível na pilha. Portanto, a remoção de padrões não é recomendada por padrão . O post de Clary com a análise.

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


All Articles