C #: un caso de uso para cualquier tarea

Hola Habr! Seguimos hablando de programación asincrónica en C #. Hoy hablaremos sobre un caso de uso único o un escenario específico del usuario adecuado para cualquier tarea en el marco de la programación asincrónica. Tocaremos los temas de sincronización, puntos muertos, configuraciones del operador, manejo de excepciones y mucho más. Únete ahora!



Artículos relacionados anteriores


Casi cualquier comportamiento no estándar de los métodos asíncronos en C # se puede explicar sobre la base de un escenario de usuario: la conversión de un código síncrono existente a asíncrono debería ser lo más simple posible. Debe poder agregar la palabra clave asíncrona antes del tipo de retorno del método, agregar el sufijo asíncrono al nombre de este método y agregar la palabra clave de espera aquí y en el área de texto del método para obtener un método asincrónico completamente funcional.



Un escenario "simple" cambia drásticamente muchos aspectos del comportamiento de los métodos asincrónicos: desde la planificación de la duración de una tarea hasta el manejo de excepciones. El guión parece convincente y significativo, pero en su contexto, la simplicidad de los métodos asincrónicos se vuelve muy engañosa.

Contexto de sincronización


El desarrollo de la interfaz de usuario (UI) es un área donde el escenario anterior es especialmente importante. Debido a las largas operaciones en el hilo de la interfaz de usuario, el tiempo de respuesta de las aplicaciones aumenta, en cuyo caso la programación asincrónica siempre se ha considerado una herramienta muy efectiva.

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 } 

El código parece muy simple, pero hay un problema. Existen restricciones para la mayoría de las interfaces de usuario: los elementos de la interfaz de usuario solo se pueden cambiar mediante hilos especiales. Es decir, en la línea 3 se produce un error si la duración de la tarea está programada en el subproceso del grupo de subprocesos. Afortunadamente, este problema se conoce desde hace mucho tiempo y el concepto de un contexto de sincronización apareció en .NET Framework 2.0.

Cada interfaz de usuario proporciona utilidades especiales para ordenar tareas en uno o más hilos de interfaz de usuario especializados. Windows Forms usa el método Control.Invoke , WPF Control.Invoke método Dispatcher.Invoke, otros sistemas pueden acceder a otros métodos. Los esquemas utilizados en todos estos casos son muy similares, pero difieren en detalles. El contexto de sincronización le permite abstraerse de las diferencias al proporcionar una API para ejecutar el código en un contexto "especial" que proporciona el procesamiento de detalles menores por tipos derivados como WindowsFormsSynchronizationContext , DispatcherSynchronizationContext , etc.

Para resolver el problema de la afinidad de subprocesos, los programadores de C # decidieron ingresar al contexto de sincronización actual en la etapa inicial de la implementación de métodos asincrónicos y planificar todas las operaciones posteriores en este contexto. Ahora, cada uno de los bloques entre las declaraciones de espera se ejecuta en el hilo de la interfaz de usuario, lo que hace posible implementar el script principal. Sin embargo, esta solución dio lugar a una serie de nuevos problemas.

Puntos muertos


Veamos un pequeño fragmento de código relativamente simple. ¿Hay algún problema aquí?

 // 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 provoca un punto muerto . El subproceso de interfaz de usuario inicia una operación asincrónica y espera el resultado sincrónicamente. Sin embargo, el método asincrónico no se puede completar porque la segunda línea de GetStockPricesForAsync debe ejecutarse en el hilo de la interfaz de usuario que causa el punto muerto.

Usted objetará que este problema es bastante fácil de resolver. Si de hecho. Task.Result prohibir todas las llamadas al Task.Wait Task.Result o Task.Wait del código de la interfaz de usuario, sin embargo, el problema aún puede ocurrir si el componente utilizado por dicho código está esperando el resultado de la operación del usuario sincrónicamente:

 // 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); 

Este código nuevamente causa un punto muerto. Cómo resolverlo:

  • No debe bloquear el código asincrónico con Task.Wait() o Task.Result y
  • use ConfigureAwait(false) en el código de la biblioteca.

El significado de la primera recomendación es claro, y la segunda lo explicaremos a continuación.

Configurar declaraciones de espera


Hay dos razones por las que se produce un punto muerto en el último ejemplo: Task.Wait() en GetStockPricesForAsync y el uso indirecto del contexto de sincronización en los pasos posteriores en InitializeIfNeededAsync. Aunque los programadores de C # no recomiendan bloquear llamadas a métodos asincrónicos, es obvio que en la mayoría de los casos este bloqueo todavía se usa. Los programadores de C # ofrecen la siguiente solución a un problema de punto muerto: Task.ConfigureAwait(continueOnCapturedContext:false) .

A pesar de la apariencia extraña (si una llamada al método se ejecuta sin un argumento con nombre, esto no significa nada en absoluto), esta solución cumple su función: proporciona una continuación forzada de la ejecución sin un contexto de sincronización.

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

En este caso, la continuación de la Task.Delay(1 ) (aquí está la declaración vacía) se planifica en el subproceso del grupo de subprocesos y no en el subproceso de la interfaz de usuario, lo que elimina el punto muerto.

Deshabilitar el contexto de sincronización


Sé que ConfigureAwait realmente resuelve este problema, pero genera mucho más. Aquí hay un pequeño ejemplo:

 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); } 

¿Ves el problema? Utilizamos ConfigureAwait(false) , por lo que todo debería estar bien. Pero no es un hecho.

ConfigureAwait(false) devuelve un objeto ConfiguredTaskAwaitable mesero personalizado, y sabemos que se usa solo si la tarea no se completa de forma sincrónica. Es decir, si _cache.InitializeAsync() finaliza sincrónicamente, todavía es posible un punto muerto.

Para eliminar los puntos muertos, todas las tareas que esperan ser completadas deben ser "decoradas" con una llamada al método ConfigureAwait(false) . Todo esto molesta y genera errores.

Alternativamente, puede usar el objeto personalizado de camarero en todos los métodos públicos para deshabilitar el contexto de sincronización en el método asincrónico:

 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 devuelve el siguiente objeto de camarero 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 hace lo siguiente: el método asíncrono funciona con un contexto de sincronización distinto de cero. Pero si el método asíncrono funciona sin un contexto de sincronización, la propiedad IsCompleted devuelve verdadero y la continuación del método se realiza sincrónicamente.

Esto significa que los datos del servicio están cerca de cero cuando el método asincrónico se ejecuta desde un subproceso en el grupo de subprocesos, y el pago se realiza una vez para transferir la ejecución desde el subproceso de la interfaz de usuario al subproceso desde el grupo de subprocesos.

Otros beneficios de este enfoque se enumeran a continuación.

  • La probabilidad de error se reduce. ConfigureAwait(false) solo funciona si se aplica a todas las tareas que esperan ser completadas. Vale la pena olvidar al menos una cosa, y puede producirse un punto muerto. En el caso de un objeto de camarero personalizado, recuerde que todos los métodos de biblioteca pública deben comenzar con Awaiters.DetachCurrentSyncContext() . Los errores son posibles aquí, pero su probabilidad es mucho menor.
  • El código resultante es más declarativo y claro. El método ConfigureAwait con varias llamadas me parece menos legible (debido a elementos adicionales) y no lo suficientemente informativo para principiantes.

Manejo de excepciones


¿Cuál es la diferencia entre estas dos opciones?

Tarea 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 } 

En el primer caso, todo cumple con las expectativas: el procesamiento de errores se realiza, pero en el segundo caso esto no sucede. La biblioteca de tareas paralelas TPL está diseñada para la programación asíncrona y paralela, y la tarea / tarea puede representar el resultado de varias operaciones. Es por eso que Task.Result y Task.Wait() siempre arrojan una AggregateException , que puede contener varios errores.

Sin embargo, nuestro escenario principal lo cambia todo: el usuario debería poder agregar el operador asíncrono / esperar sin tocar la lógica de manejo de errores. Es decir, la instrucción de espera debe ser diferente de Task.Result / Task.Wait() : debe quitar el contenedor de una excepción en la instancia AggregateException . Hoy seleccionaremos la primera excepción.

Todo está bien si todos los métodos basados ​​en Tarea son asíncronos y no se utilizan cálculos paralelos para realizar tareas. Pero en algunos casos, todo es 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 devuelve una tarea con dos errores, sin embargo, la instrucción de espera recupera y llena solo la primera.

Hay dos formas de resolver este problema:

  1. ver manualmente las tareas si tienen acceso, o
  2. configure la biblioteca TPL para forzar que la excepción se envuelva en otra 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 vacío asíncrono


El método basado en tareas devuelve un token que puede usarse para procesar resultados en el futuro. Si se pierde la tarea, el token se vuelve inaccesible para lectura por código de usuario. Una operación asincrónica que devuelve el método void arroja un error que no se puede manejar en el código de usuario. En este sentido, los tokens son inútiles e incluso peligrosos, ahora lo veremos. Sin embargo, nuestro escenario principal supone su uso obligatorio:

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

Pero, ¿qué GetStockPricesForAsync si GetStockPricesForAsync arroja un error? Una excepción de método anulado asíncrono no manejado se ordena en el contexto de sincronización actual, desencadenando el mismo comportamiento que para el código síncrono (para obtener más información, consulte el Método ThrowAsync en la página web AsyncMethodBuilder.cs ). En los formularios Windows Forms, una excepción no controlada en el controlador de eventos activa el evento Application.ThreadException , para WPF, se activa el evento Application.DispatcherUnhandledException , etc.

¿Qué pasa si el método vacío asíncrono no obtiene el contexto de sincronización? En este caso, una excepción no controlada provoca un bloqueo fatal de la aplicación. No activará el evento [ TaskScheduler.UnobservedTaskException ] que se está restaurando, pero activará el evento AppDomain.UnhandledException que no se restaurará y luego cerrará la aplicación. Esto sucede intencionalmente, y este es exactamente el resultado que necesitamos.

Ahora echemos un vistazo a otra forma conocida: usar métodos de vacío asíncrono solo para los controladores de eventos de la interfaz de usuario.

Desafortunadamente, el método de vacío asíncrono es fácil de llamar por accidente.

 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()); }); } 

A primera vista, la expresión lambda es difícil de decir si la función es un método basado en tareas o un método vacío asíncrono y, por lo tanto, un error puede arrastrarse a su base de código, a pesar de la verificación más exhaustiva.

Conclusión


Muchos aspectos de la programación asincrónica en C # fueron influenciados por un solo escenario de usuario: simplemente convirtiendo el código síncrono de una aplicación de interfaz de usuario existente en asíncrono:

  • La posterior ejecución de métodos asincrónicos se programa en el contexto de sincronización resultante, lo que puede causar puntos muertos.
  • Para evitarlos, es necesario realizar llamadas a ConfigureAwait(false) en todas partes del código de la biblioteca asincrónica.
  • esperar tarea; produce el primer error, y esto complica la creación de una excepción de procesamiento para la programación paralela.
  • Se han introducido métodos de vacío asíncrono para manejar eventos de interfaz de usuario, pero son fáciles de ejecutar por accidente, lo que hará que la aplicación se bloquee si se produce una excepción.

El queso gratis solo ocurre en una trampa para ratones. La facilidad de uso a veces puede conducir a grandes dificultades en otras áreas. Si está familiarizado con el historial de programación asincrónica en C #, el comportamiento más extraño ya no parece tan extraño, y la probabilidad de errores en el código asincrónico se reduce significativamente.

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


All Articles