Desincronización asincrónica: antipatrones en el trabajo con async / await en .NET

¿Cuál de nosotros no corta? Regularmente encuentro errores en el código asincrónico y los hago yo mismo. Para detener esta rueda de Samsara, estoy compartiendo contigo las jambas más típicas de las que a veces son bastante difíciles de atrapar y arreglar.




Este texto está inspirado en el blog de Stephen Clary , un hombre que sabe todo sobre competitividad, asincronía, multiproceso y otras palabras de miedo. Es autor del libro Concurrency in C # Cookbook , que ha recopilado una gran cantidad de patrones para trabajar con la competencia.

Punto muerto asincrónico clásico


Para comprender el punto muerto asíncrono, vale la pena averiguar qué subproceso ejecuta el método invocado usando la palabra clave wait.


Primero, el método profundizará en la cadena de llamadas de los métodos asíncronos hasta que encuentre una fuente de asincronía. Cómo se implementa exactamente la fuente de asincronía es un tema que está más allá del alcance de este artículo. Ahora, para simplificar, asumimos que esta es una operación que no requiere un flujo de trabajo mientras se espera su resultado, por ejemplo, una solicitud de base de datos o una solicitud HTTP. El inicio sincrónico de una operación de este tipo significa que mientras se espera su resultado en el sistema habrá al menos un hilo que se queda dormido que consume recursos pero no hace ningún trabajo útil.


En una llamada asincrónica, rompemos el flujo de ejecución de los comandos en el "antes" y el "después" de la operación asincrónica, y en .NET no hay garantías de que el código que está después de esperar se ejecute en el mismo hilo que el código antes de esperar. En la mayoría de los casos, esto no es necesario, pero ¿qué hacer cuando tal comportamiento es vital para que el programa funcione? Necesita usar SynchronizationContext . Este es un mecanismo que le permite imponer ciertas restricciones en los hilos en los que se ejecuta el código. A continuación, trataremos con dos contextos de sincronización ( WindowsFormsSynchronizationContext y AspNetSynchronizationContext ), pero Alex Davis escribe en su libro que hay una docena de ellos en .NET. Acerca de SynchronizationContext bien escrito aquí , aquí y aquí el autor ha implementado el suyo, por lo que tiene un gran respeto.


Entonces, tan pronto como el código llega al origen de la asincronía, guarda el contexto de sincronización, que estaba en la propiedad de hilo estático de SynchronizationContext.Current , entonces la operación asincrónica comienza y libera el hilo actual. En otras palabras, mientras esperamos la finalización de la operación asincrónica, no bloqueamos un solo subproceso y este es el principal beneficio de la operación asincrónica en comparación con la sincrónica. Después de completar la operación asincrónica, debemos seguir las instrucciones que se encuentran después de la fuente asincrónica, y aquí, para decidir en qué hilo ejecutar el código después de la operación asincrónica, debemos consultar el contexto de sincronización guardado anteriormente. Como él dice, lo haremos. Él le dirá que ejecute en el mismo hilo que el código antes de esperar - ejecutaremos en el mismo hilo, no lo dirá - tomaremos el primer hilo del grupo.


Pero, ¿qué sucede si, en este caso particular, es importante para nosotros que el código después de esperar se ejecute en cualquier subproceso libre del grupo de subprocesos? Debe usar el mantra ConfigureAwait(false) . El valor falso pasado al parámetro continueOnCapturedContext le dice al sistema que se puede usar cualquier subproceso del grupo. Y qué sucede si en el momento de la ejecución del método con wait no había ningún contexto de sincronización ( SynchronizationContext.Current == null ), como por ejemplo en una aplicación de consola. En este caso, no tenemos restricciones en el subproceso en el que se debe ejecutar el código después de esperar y el sistema tomará el primer subproceso del grupo, como en el caso de ConfigureAwait(false) .


Entonces, ¿qué es un punto muerto asíncrono?


Punto muerto en WPF y WinForms


La diferencia entre las aplicaciones WPF y WinForms es el contexto de sincronización. El contexto de sincronización de WPF y WinForms tiene un hilo especial: el hilo de la interfaz de usuario. Hay un subproceso de interfaz de usuario por SynchronizationContext y solo desde este subproceso pueden interactuar con los elementos de la interfaz de usuario. De manera predeterminada, el código que comenzó a funcionar en el subproceso de la interfaz de usuario reanuda la operación después de una operación asincrónica en él.


Ahora veamos un ejemplo:

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

Qué sucede cuando llamas a StartWork().Wait() :

  1. El hilo de llamada (y este es el hilo de la interfaz de usuario) irá al método StartWork e StartWork a la instrucción await Task.Delay(100) .
  2. El subproceso de la interfaz de usuario iniciará la Task.Delay(100) asincrónica Task.Delay(100) , y devolverá el control al método Button_Click , y allí el método Wait() de la clase Task lo esperará. Cuando se llama al método Wait() , el subproceso de la interfaz de usuario se bloqueará hasta el final de la operación asincrónica, y esperamos que tan pronto como se complete, el subproceso de la interfaz de usuario recoja inmediatamente la ejecución y avance más en el código, sin embargo, no todo será así.
  3. Tan pronto como se Task.Delay(100) , el subproceso de la interfaz de usuario primero deberá continuar ejecutando el método StartWork() y para esto necesita exactamente el subproceso en el que comenzó la ejecución. Pero el hilo de la interfaz de usuario ahora está esperando el resultado de la operación.
  4. StartWork() : StartWork() no puede continuar la ejecución y devolver el resultado, y Button_Click está esperando el mismo resultado, y debido a que la ejecución comenzó en el hilo de la interfaz de usuario, la aplicación simplemente se cuelga sin la posibilidad de continuar trabajando.

Esta situación se puede tratar simplemente cambiando la llamada a Task.Delay(100) a 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"; } 

Este código funcionará sin puntos muertos, ya que ahora se puede usar un subproceso del grupo para completar el método StartWork() , en lugar de un subproceso de interfaz de usuario bloqueado. Stephen Clary recomienda usar ConfigureAwait(false) en todos los "métodos de biblioteca" en su blog, pero enfatiza específicamente que usar ConfigureAwait(false) para tratar puntos muertos no es una buena práctica. En cambio, aconseja NO usar métodos de bloqueo como Wait() , Result , GetAwaiter().GetResult() y GetAwaiter().GetResult() todos los métodos para usar async / wait, si es posible (el llamado principio Async all-way).


Punto muerto en ASP.NET


ASP.NET también tiene un contexto de sincronización, pero tiene limitaciones ligeramente diferentes. Le permite usar solo un subproceso por solicitud a la vez y también requiere que el código después de esperar se ejecute en el mismo subproceso que el código antes de esperar.


Un ejemplo:

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

Este código también provocará un punto muerto, ya que en el momento de la llamada a StartWork().Wait() único subproceso permitido se bloqueará y esperará a que StartWork() operación StartWork() , y nunca terminará, ya que el subproceso en el que la ejecución debe continuar está ocupado esperando


Todo esto se soluciona con el mismo ConfigureAwait(false) .


Punto muerto en ASP.NET Core (en realidad no)


Ahora intentemos ejecutar el código del ejemplo para ASP.NET en el proyecto para ASP.NET Core. Si hacemos esto, veremos que no habrá punto muerto. Esto se debe a que ASP.NET Core no tiene un contexto de sincronización . Genial ¿Y ahora puede cubrir el código con llamadas de bloqueo y no tener miedo a los puntos muertos? Estrictamente hablando, sí, pero recuerde que esto hace que el hilo se duerma mientras espera, es decir, el hilo consume recursos, pero no hace ningún trabajo útil.




Recuerde que el uso de llamadas bloqueadas elimina todas las ventajas de la programación asincrónica convirtiéndola en síncrona . Sí, a veces sin usar Wait() no funcionará escribir un programa, pero la razón debe ser seria.

Uso erróneo de Task.Run ()


El método Task.Run() se creó para iniciar operaciones en un nuevo hilo. Como corresponde a un método escrito en un patrón TAP, devuelve Task o Task<T> y las personas que se enfrentan a async / wait por primera vez tienen un gran deseo de envolver el código sincrónico en Task.Run() y eliminar el resultado de este método. El código parecía volverse asíncrono, pero de hecho, nada ha cambiado. Veamos qué sucede con este uso de Task.Run() .


Un ejemplo:

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

El resultado de este código será:

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

Aquí Thread.Sleep(1000) es algún tipo de operación síncrona que requiere un subproceso para completarse. Supongamos que queremos hacer que nuestra solución sea asincrónica y para que esta operación pueda ser sacrificada, la Task.Run() en Task.Run() .


Tan pronto como el código llega al método Task.Run() , se toma otro subproceso del grupo de subprocesos y se ejecuta el código que pasamos a Task.Run() . El hilo viejo, como corresponde a un hilo decente, regresa al grupo y espera a que lo llamen nuevamente para hacer el trabajo. El nuevo subproceso ejecuta el código transmitido, alcanza la operación sincrónica, lo ejecuta sincrónicamente (espera hasta que se complete la operación) y avanza a lo largo del código. En otras palabras, la operación permaneció sincrónica: nosotros, como antes, usamos el flujo durante la ejecución de la operación sincrónica. La única diferencia es que pasamos tiempo cambiando de contexto al llamar a Task.Run() y regresar a ExecuteOperation() . Todo se ha vuelto un poco peor.


Debe entenderse que a pesar del hecho de que en las líneas Inside after sleep: 3 y After: 3 vemos el mismo Id de la secuencia, el contexto de ejecución es completamente diferente en estos lugares. ASP.NET es simplemente más inteligente que nosotros e intenta ahorrar recursos al cambiar el contexto del código dentro de Task.Run() a código externo. Aquí decidió no cambiar al menos el flujo de ejecución.


En tales casos, no tiene sentido usar Task.Run() . En cambio, Clary aconseja que todas las operaciones sean asíncronas, es decir, en nuestro caso, reemplazar Thread.Sleep(1000) con Task.Delay(1000) , pero esto, por supuesto, no siempre es posible. ¿Qué hacer en los casos en que usamos bibliotecas de terceros que no podemos o no queremos reescribir y hacer asincrónicas hasta el final, pero por una razón u otra necesitamos el método asíncrono? Es mejor usar Task.FromResult() para ajustar el resultado de los métodos del proveedor en Task. Esto, por supuesto, no hará que el código sea asíncrono, pero al menos ahorraremos en el cambio de contexto.


¿Por qué entonces usar Task.Run ()? La respuesta es simple: para operaciones vinculadas a la CPU, cuando necesita mantener la capacidad de respuesta de la interfaz de usuario o paralelizar los cálculos. Debe decirse aquí que las operaciones vinculadas a la CPU son de naturaleza síncrona. Fue para lanzar operaciones sincrónicas en un estilo asincrónico que se inventó Task.Run() .

Mal uso del vacío asíncrono


Se ha agregado la capacidad de escribir métodos asincrónicos que devuelven void para escribir controladores de eventos asincrónicos. Veamos por qué pueden causar confusión si se usan para otros fines:

  1. No puedes esperar el resultado.
  2. El manejo de excepciones a través de try-catch no es compatible.
  3. Es imposible combinar llamadas a través de Task.WhenAll() , Task.WhenAny() y otros métodos similares.

De todas estas razones, el punto más interesante es el manejo de excepciones. El hecho es que en los métodos asíncronos que devuelven Task o Task<T> , las excepciones se capturan y se envuelven en un objeto Task , que luego se pasará al método de llamada. En su artículo de MSDN, Clary escribe que, dado que no existe un valor de retorno en los métodos de asíncrono nulo, no hay nada para incluir las excepciones y se lanzan directamente en el contexto de la sincronización. El resultado es una excepción no controlada debido a que el proceso se bloquea, teniendo tiempo para, quizás, escribir un error en la consola. Puede obtener y reservar tales excepciones suscribiéndose al evento AppDomain.UnhandledException , pero ya no podrá detener el bloqueo del proceso incluso en el controlador de este evento. Este comportamiento es típico solo para el controlador de eventos, pero no para el método habitual, del cual esperamos la posibilidad de manejo de excepciones estándar a través de try-catch.


Por ejemplo, si escribe así en una aplicación ASP.NET Core, se garantiza que el proceso caerá:

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

Pero vale la pena cambiar el tipo de ThrowAsynchronously método ThrowAsynchronously a Task (sin siquiera agregar la palabra clave ThrowAsynchronously ) y el controlador de errores estándar ASP.NET Core ThrowAsynchronously la excepción, y el proceso continuará vivo a pesar de la ejecución.


Tenga cuidado con los métodos async-void : pueden ponerlo en el proceso.

esperar en un método de una sola línea


El último antipatrón no da tanto miedo como los anteriores. La conclusión es que no tiene sentido usar async / await en métodos que, por ejemplo, simplemente reenvían el resultado de otro método async más allá, con la posible excepción de usar await en el uso .


En lugar de este código:

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

sería completamente posible (y preferiblemente) escribir:
 public Task MyMethodAsync() { return Task.Delay(1000); } 

Por que funciona Porque la palabra clave de espera se puede aplicar a objetos similares a Tarea, y no a métodos marcados con la palabra clave asíncrona. A su vez, la palabra clave asíncrona solo le dice al compilador que este método debe implementarse en una máquina de estado, y todos los valores devueltos deben incluirse en una Task (o en otro objeto similar a la Tarea).


En otras palabras, el resultado de la primera versión del método es Task , que se Completed tan pronto como Task.Delay(1000) la espera de Task.Delay(1000) , y el resultado de la segunda versión del método es Task , devuelto por Task.Delay(1000) , que se Completed tan pronto como pasen 1000 milisegundos .


Como puede ver, ambas versiones son equivalentes, pero al mismo tiempo, la primera requiere muchos más recursos para crear un "kit de cuerpo" asíncrono.


Alex Davis escribe que el costo de invocar directamente el método asincrónico puede ser diez veces el costo de invocar el método sincrónico , por lo que hay algo por lo que tratar.


UPD:
Como los comentarios señalan correctamente, cortar async / esperar de los métodos de una sola línea conduce a efectos secundarios negativos. Por ejemplo, al lanzar una excepción, el método que arroja la tarea no será visible en la pila. Por lo tanto, no se recomienda eliminar los valores predeterminados de manera predeterminada . La publicación de Clary con análisis.

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


All Articles