C #: um caso de uso para qualquer tarefa

Olá Habr! Continuamos a falar sobre programação assíncrona em C #. Hoje falaremos sobre um único caso de uso ou cenário específico do usuário, adequado para qualquer tarefa na estrutura da programação assíncrona. Entraremos em contato com os tópicos de sincronização, deadlocks, configurações do operador, tratamento de exceções e muito mais. Inscreva-se agora!



Artigos relacionados anteriores


Quase todo comportamento não padrão de métodos assíncronos em C # pode ser explicado com base em um cenário de usuário: a conversão de um código síncrono existente em assíncrono deve ser o mais simples possível. Você deve poder adicionar a palavra-chave async antes do tipo de retorno do método, adicionar o sufixo Async ao nome desse método e adicionar a palavra-chave wait aqui e na área de texto do método para obter um método assíncrono totalmente funcional.



Um cenário "simples" altera drasticamente muitos aspectos do comportamento dos métodos assíncronos: do planejamento da duração de uma tarefa ao tratamento de exceções. O script parece convincente e significativo, mas em seu contexto, a simplicidade dos métodos assíncronos se torna muito enganadora.

Contexto de sincronização


O desenvolvimento da interface do usuário (UI) é uma área em que o cenário acima é especialmente importante. Devido a longas operações no encadeamento da interface com o usuário, o tempo de resposta dos aplicativos aumenta; nesse caso, a programação assíncrona sempre foi considerada uma ferramenta muito eficaz.

private async void buttonOk_ClickAsync(object sender, EventArgs args) { textBox.Text = "Running.."; // 1 -- UI Thread var result = await _stockPrices.GetStockPricesForAsync("MSFT"); // 2 -- Usually non-UI Thread textBox.Text = "Result is: " + result; //3 -- Should be UI Thread } 

O código parece muito simples, mas há um problema. Existem restrições para a maioria das interfaces de usuário: os elementos da interface do usuário podem ser alterados apenas por threads especiais. Ou seja, na linha 3, ocorre um erro se a duração da tarefa estiver agendada no encadeamento do pool de encadeamentos. Felizmente, esse problema é conhecido há muito tempo e o conceito de um contexto de sincronização apareceu na versão do .NET Framework 2.0.

Cada interface do usuário fornece utilitários especiais para organizar tarefas em um ou mais encadeamentos especializados da interface com o usuário. O Windows Forms usa o método Control.Invoke , o WPF Control.Invoke método Dispatcher.Invoke, outros sistemas podem acessar outros métodos. Os esquemas usados ​​em todos esses casos são amplamente semelhantes, mas diferem em detalhes. O contexto de sincronização permite abstrair das diferenças fornecendo uma API para executar o código em um contexto "especial" que fornece o processamento de detalhes secundários por tipos derivados, como WindowsFormsSynchronizationContext , DispatcherSynchronizationContext , etc.

Para resolver o problema de afinidade do encadeamento, os programadores de C # decidiram entrar no contexto atual de sincronização no estágio inicial da implementação dos métodos assíncronos e planejar todas as operações subseqüentes nesse contexto. Agora, cada um dos blocos entre as instruções wait é executado no encadeamento da interface do usuário, o que possibilita a implementação do script principal. No entanto, essa solução deu origem a vários novos problemas.

Deadlocks


Vejamos um pequeno pedaço de código relativamente simples. Há algum problema aqui?

 // UI code private void buttonOk_Click(object sender, EventArgs args) { textBox.Text = "Running.."; var result = _stockPrices.GetStockPricesForAsync("MSFT").Result; textBox.Text = "Result is: " + result; } // StockPrices.dll public Task<decimal> GetStockPricesForAsync(string symbol) { await Task.Yield(); return 42; } 

Este código causa um conflito . O encadeamento da interface com o usuário inicia uma operação assíncrona e aguarda o resultado de forma síncrona. No entanto, o método assíncrono não pode ser concluído porque a segunda linha de GetStockPricesForAsync deve ser executada no thread da interface do usuário que causa o conflito.

Você objetará que esse problema é bastante fácil de resolver. Sim de fato. Você precisa proibir todas as chamadas para o Task.Wait ou Task.Wait do código da interface do usuário; no entanto, o problema ainda pode ocorrer se o componente usado por esse código estiver aguardando o resultado da operação do usuário de forma síncrona:

 // UI code private void buttonOk_Click(object sender, EventArgs args) { textBox.Text = "Running.."; var result = _stockPrices.GetStockPricesForAsync("MSFT").Result; textBox.Text = "Result is: " + result; } // StockPrices.dll public Task<decimal> GetStockPricesForAsync(string symbol) { // We know that the initialization step is very fast, // and completes synchronously in most cases, // let's wait for the result synchronously for "performance reasons". InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } // StockPrices.dll private async Task InitializeIfNeededAsync() => await Task.Delay(1); 

Esse código novamente causa um impasse. Como resolver:

  • Você não deve bloquear o código assíncrono com Task.Wait() ou Task.Result e
  • use ConfigureAwait(false) no código da biblioteca.

O significado da primeira recomendação é claro, e a segunda explicaremos abaixo.

Configurando instruções de espera


Há duas razões pelas quais um conflito ocorre no último exemplo: Task.Wait() em GetStockPricesForAsync e uso indireto do contexto de sincronização nas etapas subseqüentes em InitializeIfNeededAsync. Embora os programadores de C # não recomendem o bloqueio de chamadas para métodos assíncronos, é óbvio que na maioria dos casos esse bloqueio ainda é usado. Os programadores de C # oferecem a seguinte solução para um problema de Task.ConfigureAwait(continueOnCapturedContext:false) : Task.ConfigureAwait(continueOnCapturedContext:false) .

Apesar da aparência estranha (se uma chamada de método é executada sem um argumento nomeado, isso não significa nada), esta solução executa sua função: fornece uma continuação forçada da execução sem um contexto de sincronização.

 public Task<decimal> GetStockPricesForAsync(string symbol) { InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } private async Task InitializeIfNeededAsync() => await Task.Delay(1).ConfigureAwait(false); 

Nesse caso, a continuação da tarefa Task.Delay(1 ) (aqui está a instrução vazia) é planejada no encadeamento do pool de encadeamentos, e não no encadeamento da interface do usuário, que elimina o impasse.

Desativando o contexto de sincronização


Eu sei que o ConfigureAwait realmente resolve esse problema, mas gera muito mais. Aqui está um pequeno exemplo:

 public Task<decimal> GetStockPricesForAsync(string symbol) { InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } private async Task InitializeIfNeededAsync() { // Initialize the cache field first await _cache.InitializeAsync().ConfigureAwait(false); // Do some work await Task.Delay(1); } 

Você vê o problema? Usamos o ConfigureAwait(false) , portanto tudo deve ficar bem. Mas não é um fato.

ConfigureAwait(false) retorna um objeto ConfiguredTaskAwaitable garçom personalizado e sabemos que ele será usado apenas se a tarefa não for concluída de forma síncrona. Ou seja, se _cache.InitializeAsync() terminar de forma síncrona, um conflito ainda será possível.

Para eliminar conflitos, todas as tarefas que aguardam conclusão devem ser "decoradas" com uma chamada para o método ConfigureAwait(false) . Tudo isso irrita e gera erros.

Como alternativa, você pode usar o objeto personalizado do garçom em todos os métodos públicos para desativar o contexto de sincronização no método assíncrono:

 private void buttonOk_Click(object sender, EventArgs args) { textBox.Text = "Running.."; var result = _stockPrices.GetStockPricesForAsync("MSFT").Result; textBox.Text = "Result is: " + result; } // StockPrices.dll public async Task<decimal> GetStockPricesForAsync(string symbol) { // The rest of the method is guarantee won't have a current sync context. await Awaiters.DetachCurrentSyncContext(); // We can wait synchronously here and we won't have a deadlock. InitializeIfNeededAsync().Wait(); return 42; } 

Awaiters.DetachCurrentSyncContext retorna o seguinte objeto de garçom personalizado:

 public struct DetachSynchronizationContextAwaiter : ICriticalNotifyCompletion { /// <summary> /// Returns true if a current synchronization context is null. /// It means that the continuation is called only when a current context /// is presented. /// </summary> public bool IsCompleted => SynchronizationContext.Current == null; public void OnCompleted(Action continuation) { ThreadPool.QueueUserWorkItem(state => continuation()); } public void UnsafeOnCompleted(Action continuation) { ThreadPool.UnsafeQueueUserWorkItem(state => continuation(), null); } public void GetResult() { } public DetachSynchronizationContextAwaiter GetAwaiter() => this; } public static class Awaiters { public static DetachSynchronizationContextAwaiter DetachCurrentSyncContext() { return new DetachSynchronizationContextAwaiter(); } } 

DetachSynchronizationContextAwaiter faz o seguinte: o método async funciona com um contexto de sincronização diferente de zero. Mas se o método assíncrono funcionar sem um contexto de sincronização, a propriedade IsCompleted retornará true e a continuação do método será realizada de forma síncrona.

Isso significa que os dados do serviço estão próximos de zero quando o método assíncrono é executado a partir de um encadeamento no conjunto de encadeamentos e o pagamento é feito uma vez para transferir a execução do encadeamento da interface com o usuário para o encadeamento do conjunto de encadeamentos.

Outros benefícios dessa abordagem estão listados abaixo.

  • A probabilidade de erro é reduzida. ConfigureAwait(false) funciona apenas se aplicado a todas as tarefas que aguardam conclusão. Vale a pena esquecer pelo menos uma coisa - e pode ocorrer um impasse. No caso de um objeto de garçom personalizado, lembre-se de que todos os métodos de biblioteca pública devem começar com Awaiters.DetachCurrentSyncContext() . Os erros são possíveis aqui, mas sua probabilidade é muito menor.
  • O código resultante é mais declarativo e claro. O método ConfigureAwait com várias chamadas parece menos legível para mim (devido a elementos extras) e não é informativo o suficiente para iniciantes.

Manipulação de exceção


Qual é a diferença entre essas duas opções:

Tarefa mayFail = Task.FromException (new ArgumentNullException ());

 // Case 1 try { await mayFail; } catch (ArgumentException e) { // Handle the error } // Case 2 try { mayFail.Wait(); } catch (ArgumentException e) { // Handle the error } 

No primeiro caso, tudo atende às expectativas - o processamento de erros é realizado, mas no segundo caso isso não acontece. A biblioteca de tarefas paralelas TPL foi projetada para programação assíncrona e paralela, e Tarefa / Tarefa pode representar o resultado de várias operações. É por isso que Task.Result e Task.Wait() sempre lançam um AggregateException , que pode conter vários erros.

No entanto, nosso cenário principal muda tudo: o usuário deve poder adicionar o operador assíncrono / aguardar sem tocar na lógica de tratamento de erros. Ou seja, a instrução de espera deve ser diferente de Task.Result / Task.Wait() : deve remover o wrapper de uma exceção na instância AggregateException . Hoje vamos selecionar a primeira exceção.

Tudo ficará bem se todos os métodos baseados em Tarefa forem assíncronos e cálculos paralelos não forem usados ​​para executar tarefas. Mas, em alguns casos, tudo é diferente:

 try { Task<int> task1 = Task.FromException<int>(new ArgumentNullException()); Task<int> task2 = Task.FromException<int>(new InvalidOperationException()); // await will rethrow the first exception await Task.WhenAll(task1, task2); } catch (Exception e) { // ArgumentNullException. The second error is lost! Console.WriteLine(e.GetType()); } 

Task.WhenAll retorna uma tarefa com dois erros, no entanto, a instrução de espera recupera e preenche apenas o primeiro.

Existem duas maneiras de resolver esse problema:

  1. exibir manualmente as tarefas se elas tiverem acesso ou
  2. configure a biblioteca TPL para forçar a exceção a ser quebrada em outra AggregateException .

 try { Task<int> task1 = Task.FromException<int>(new ArgumentNullException()); Task<int> task2 = Task.FromException<int>(new InvalidOperationException()); // t.Result forces TPL to wrap the exception into AggregateException await Task.WhenAll(task1, task2).ContinueWith(t => t.Result); } catch(Exception e) { // AggregateException Console.WriteLine(e.GetType()); } 

Método de vazio assíncrono


O método baseado em tarefas retorna um token que pode ser usado para processar resultados no futuro. Se a tarefa for perdida, o token se tornará inacessível para leitura pelo código do usuário. Uma operação assíncrona que retorna o método void gera um erro que não pode ser tratado no código do usuário. Nesse sentido, os tokens são inúteis e até perigosos - agora vamos vê-lo. No entanto, nosso cenário principal assume seu uso obrigatório:

 private async void buttonOk_ClickAsync(object sender, EventArgs args) { textBox.Text = "Running.."; var result = await _stockPrices.GetStockPricesForAsync("MSFT"); textBox.Text = "Result is: " + result; } 

Mas e se GetStockPricesForAsync um erro? Uma exceção de método nulo assíncrono não tratado é empacotada no contexto de sincronização atual, disparando o mesmo comportamento do código síncrono (para obter mais informações, consulte o método ThrowAsync na página da web AsyncMethodBuilder.cs ). No Windows Forms, uma exceção não tratada no manipulador de eventos dispara o evento Application.ThreadException , para WPF, o evento Application.DispatcherUnhandledException acionado e assim por diante.

E se o método async void não obtiver o contexto de sincronização? Nesse caso, uma exceção não tratada causa uma falha fatal no aplicativo. Ele não dispara o evento [ TaskScheduler.UnobservedTaskException ] que será recuperado, mas dispara o evento irrecuperável AppDomain.UnhandledException e fecha o aplicativo. Isso acontece intencionalmente, e este é exatamente o resultado que precisamos.

Agora, vamos ver outra maneira bem conhecida: usar métodos void assíncronos apenas para manipuladores de eventos da interface do usuário.

Infelizmente, o método de nulo assíncrono é fácil de chamar por acidente.

 public static Task<T> ActionWithRetry<T>(Func<Task<T>> provider, Action<Exception> onError) { // Calls 'provider' N times and calls 'onError' in case of an error. } public async Task<string> AccidentalAsyncVoid(string fileName) { return await ActionWithRetry( provider: () => { return File.ReadAllTextAsync(fileName); }, // Can you spot the issue? onError: async e => { await File.WriteAllTextAsync(errorLogFile, e.ToString()); }); } 

À primeira vista, a expressão lambda é difícil de dizer se a função é um método baseado em tarefas ou um método de vazio assíncrono e, portanto, um erro pode surgir na sua base de código, apesar da verificação mais completa.

Conclusão


Muitos aspectos da programação assíncrona em C # foram influenciados por um cenário de usuário único - simplesmente convertendo o código síncrono de um aplicativo de interface do usuário existente em assíncrono:

  • A execução subsequente de métodos assíncronos é agendada no contexto de sincronização resultante, o que pode causar conflitos.
  • Para evitá-los, é necessário fazer chamadas ConfigureAwait(false) em qualquer lugar do código da biblioteca assíncrona.
  • aguardar tarefa; produz o primeiro erro e isso complica a criação de uma exceção de processamento para programação paralela.
  • Os métodos Async void foram introduzidos para lidar com eventos da interface do usuário, mas são fáceis de executar acidentalmente, o que causará uma falha no aplicativo se uma exceção for lançada.

Queijo grátis acontece apenas em uma ratoeira. A facilidade de uso às vezes pode levar a grandes dificuldades em outras áreas. Se você está familiarizado com o histórico de programação assíncrona em C #, o comportamento mais estranho não parece mais tão estranho e a probabilidade de erros no código assíncrono é significativamente reduzida.

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


All Articles