Asincronía en C # y F #. Errores de asincronía en C #

¡Hola, Habr! Les presento a su atención la traducción del artículo "Async in C # y F # Asynchronous gotchas in C #" de Tomas Petricek.



En febrero, asistí a la Annual MVP Summit, un evento organizado por Microsoft para MVP. Aproveché esta oportunidad para visitar Boston y Nueva York también, dar dos charlas sobre F # y grabar la charla de Channel 9 sobre proveedores de tipos . A pesar de otras actividades (como visitar pubs, charlar con otras personas sobre F # y tomar largas siestas por las mañanas), también logré tener algunas discusiones.



imagen



Una discusión (no bajo el NDA) fue la charla de Async Clinic sobre las nuevas palabras clave en C # 5.0: async y await. Lucian y Stephen hablaron sobre los problemas comunes que enfrentan los desarrolladores de C # al escribir programas asincrónicos. En esta publicación, analizaré algunos de los problemas desde una perspectiva de F #. La conversación fue bastante animada, y alguien describió la reacción de la audiencia de F # de la siguiente manera:



imagen

(Cuando los MVP que escriben en F # ven ejemplos de código de C #, se ríen como chicas)



¿Por qué sucede esto? Resulta que muchos de los errores comunes no son posibles (o mucho menos probables) cuando se usa el modelo asincrónico de F # (que apareció en F # 1.9.2.7, lanzado en 2007 y enviado con Visual Studio 2008).



Error n. ° 1: Async no funciona de forma asincrónica



Pasemos directamente al primer aspecto complicado del modelo de programación asincrónica de C #. Eche un vistazo al siguiente ejemplo e intente imaginar en qué orden se imprimirán las líneas (no pude encontrar el código exacto que se muestra en la charla, pero recuerdo a Lucian demostrando algo similar):



  async Task WorkThenWait()
  {
      Thread.Sleep(1000);
      Console.WriteLine("work");
      await Task.Delay(1000);
  }
 
  void Demo() 
  {
      var child = WorkThenWait();
      Console.WriteLine("started");
      child.Wait();
      Console.WriteLine("completed");
  }


Si cree que se imprimirán "iniciado", "trabajo" y "completado", está equivocado. El código imprime "trabajo", "iniciado" y "completado", ¡pruébelo usted mismo! El autor quería comenzar a trabajar (llamando a WorkThenWait) y luego esperar a que se completara la tarea. El problema es que WorkThenWait comienza haciendo algunos cálculos pesados ​​(aquí Thread.Sleep) y solo después de eso usa await.



En C #, la primera pieza de código en un método asincrónico se ejecuta sincrónicamente (en el hilo del llamador). Puede solucionar esto, por ejemplo, agregando await Task.Yield () al principio.



Código F # correspondiente



En F #, esto no es un problema. Al escribir código asincrónico en F #, todo el código dentro del bloque async {…} se aplaza y se ejecuta más tarde (cuando lo ejecuta explícitamente). El código C # anterior corresponde a lo siguiente en F #:



let workThenWait() = 
    Thread.Sleep(1000)
    printfn "work done"
    async { do! Async.Sleep(1000) }
 
let demo() = 
    let work = workThenWait() |> Async.StartAsTask
    printfn "started"
    work.Wait()
    printfn "completed"
  


Obviamente, la función workThenWait no realiza el trabajo (Thread.Sleep) como parte del cálculo asincrónico, y se ejecutará cuando se llame a la función (y no cuando se inicie el flujo de trabajo asincrónico). Un patrón común en F # es envolver todo el cuerpo de una función en async. En F #, escribiría lo siguiente, que funciona como se esperaba:



let workThenWait() = async
{ 
    Thread.Sleep(1000)
    printfn "work done"
    do! Async.Sleep(1000) 
}
  


Trampa # 2: Ignorar los resultados



Aquí hay otro problema con el modelo de programación asincrónica de C # (este artículo se tomó directamente de las diapositivas de Lucian). Adivina qué sucede cuando ejecutas el siguiente método asincrónico:



async Task Handler() 
{
   Console.WriteLine("Before");
   Task.Delay(1000);
   Console.WriteLine("After");
}
 


¿Espera que imprima "Antes", espere 1 segundo y luego imprima "Después"? ¡Incorrecto! Ambos mensajes se imprimirán a la vez, sin demora intermedia. El problema es que Task.Delay devuelve una Task y nos olvidamos de esperar hasta que se complete (usando await).



Código F # correspondiente



Una vez más, probablemente no habría encontrado esto en F #. Bien puede escribir código que llame a Async.Sleep e ignore el Async devuelto:



let handler() = async
{
    printfn "Before"
    Async.Sleep(1000)
    printfn "After" 
}
 


Si pega este código en Visual Studio, MonoDevelop o Try F #, recibirá una advertencia de inmediato:



advertencia FS0020: Esta expresión debe tener unidad de tipo, pero tiene tipo Async ‹unit›. Utilice ignorar para descartar el resultado de la expresión o deje para vincular el resultado a un nombre.


advertencia FS0020: Esta expresión debe ser de tipo unit pero es de tipo Async ‹unit›. Utilice ignorar para descartar el resultado de una expresión o deje asociar el resultado con un nombre.




Aún puede compilar el código y ejecutarlo, pero si lee la advertencia, verá que la expresión devuelve Async y debe esperar su resultado usando do!:



let handler() = async 
{
   printfn "Before"
   do! Async.Sleep(1000)
   printfn "After" 
}
 


Error # 3: métodos asincrónicos que devuelven vacío



Gran parte de la conversación se dedicó a los métodos de vacío asincrónicos. Si escribe async void Foo () {…}, entonces el compilador de C # genera un método que devuelve void. Pero bajo el capó, crea y ejecuta una tarea. Esto significa que no puede predecir cuándo se realizará realmente el trabajo.



En el discurso, se hizo la siguiente recomendación sobre el uso del patrón de vacío asíncrono:



imagen

(¡Por el amor de Dios, deje de usar el vacío asíncrono!)



Para ser justos, debe tenerse en cuenta que los métodos de vacío asíncronos puedenser útil al escribir controladores de eventos. Los controladores de eventos deben devolver vacío y, a menudo, comienzan un trabajo que continúa en segundo plano. Pero no creo que sea realmente útil en el mundo MVVM (aunque ciertamente hace buenas demostraciones en conferencias).



Permítanme demostrar el problema con un fragmento del artículo de MSDN Magazine sobre programación asincrónica en C #:



async void ThrowExceptionAsync() 
{
    throw new InvalidOperationException();
}

public void CallThrowExceptionAsync() 
{
    try 
    {
        ThrowExceptionAsync();
    } 
    catch (Exception) 
    {
        Console.WriteLine("Failed");
    }
}
 


¿Crees que este código imprimirá "Fallido"? Espero que ya comprenda el estilo de este artículo ...

De hecho, la excepción no se manejará, porque después de iniciar el trabajo, ThrowExceptionAsync se cerrará inmediatamente y la excepción se lanzará en algún lugar de un hilo en segundo plano.



Código F # correspondiente



Entonces, si no necesita usar las características de un lenguaje de programación, entonces probablemente sea mejor no incluir esa característica en primer lugar. F # no le permite escribir funciones void asíncronas; si envuelve el cuerpo de una función en un bloque {…} asíncrono, el tipo de retorno será Asíncrono. Si usa anotaciones de tipo y requiere una unidad, obtendrá una falta de coincidencia de tipos.



Puede escribir código que coincida con el código C # anterior usando Async.Start:



let throwExceptionAsync() = async {
    raise <| new InvalidOperationException()  }

let callThrowExceptionAsync() = 
  try
     throwExceptionAsync()
     |> Async.Start
   with e ->
     printfn "Failed"


La excepción tampoco se manejará aquí. Pero lo que está sucediendo es más obvio, porque tenemos que escribir Async.Start explícitamente. Si no lo hacemos, recibimos una advertencia de que la función devuelve Async y estamos ignorando el resultado (como en la sección anterior "Ignorando resultados").



Error # 4: funciones lambda asincrónicas que devuelven vacío



La situación se complica aún más cuando pasa una función lambda asincrónica a un método como delegado. En este caso, el compilador de C # infiere el tipo de método del tipo de delegado. Si usa un delegado de acción (o similar), el compilador crea una función void asincrónica que inicia el trabajo y devuelve void. Si usa el delegado Func, el compilador genera una función que devuelve Task.



Aquí hay una muestra de las diapositivas de Lucian. ¿Cuándo terminará el siguiente código (perfectamente correcto), un segundo (después de que todas las tareas hayan terminado de esperar) o inmediatamente?



Parallel.For(0, 10, async i => 
{
    await Task.Delay(1000);
});


No podrá responder esta pregunta a menos que sepa que solo hay sobrecargas para For que aceptan delegados de acción y, por lo tanto, lambda siempre se compilará como un vacío asíncrono. También significa que agregar algo de carga (posiblemente carga útil) será un cambio rotundo.



Código F # correspondiente



F # no tiene "funciones lambda asincrónicas" especiales, pero puede escribir una función lambda que devuelva cálculos asincrónicos. Dicha función devolverá Async, por lo que no se puede pasar como argumento a métodos que esperan un delegado de devolución nula. El siguiente código no se compilará:



Parallel.For(0, 10, fun i -> async {
  do! Async.Sleep(1000) 
})


El mensaje de error simplemente dice que el tipo de función int -> Async no es compatible con el delegado de acción (en F # debería ser int -> unit):



error FS0041: Ninguna sobrecarga coincide con el método For. Las sobrecargas disponibles se muestran a continuación (o en la ventana Lista de errores).


error FS0041: No se encontraron sobrecargas para el método For. Las sobrecargas disponibles se muestran a continuación (o en el cuadro de lista de errores).




Para obtener el mismo comportamiento que en el código C # anterior, debemos comenzar explícitamente. Si desea ejecutar una secuencia asincrónica en segundo plano, esto se puede hacer fácilmente con Async.Start (que toma un cálculo asincrónico que devuelve una unidad, la programa y devuelve una unidad):



Parallel.For(0, 10, fun i -> Async.Start(async {
  do! Async.Sleep(1000) 
}))


Por supuesto, puedes escribir esto, pero es bastante fácil ver lo que está pasando. También es fácil ver que estamos desperdiciando recursos, como la peculiaridad de Parallel, ya que realiza cálculos intensivos de CPU (que suelen ser funciones síncronas) en paralelo.



Error # 5: tareas de anidamiento



Creo que Lucian incluyó esta piedra solo para probar la inteligencia de las personas en la audiencia, pero aquí está. La pregunta es, ¿el siguiente código esperará 1 segundo entre los dos pines de la consola?



Console.WriteLine("Before");
await Task.Factory.StartNew(
    async () => { await Task.Delay(1000); });
Console.WriteLine("After");


De manera inesperada, no hay retraso entre estas conclusiones. ¿Cómo es esto posible? El método StartNew toma un delegado y devuelve Task donde T es el tipo devuelto por el delegado. En nuestro caso, el delegado devuelve Task, por lo que obtenemos Task como resultado. await solo espera a que se complete la tarea externa (que devuelve inmediatamente la tarea interna), mientras que la tarea interna se ignora.



En C #, esto se puede solucionar usando Task.Run en lugar de StartNew (o eliminando async / await en la función lambda).



¿Puedes escribir algo como esto en F #? Podemos crear una tarea que devolverá Async usando la función Task.Factory.StartNew y una función lambda que devuelve un bloque asincrónico. Para esperar a que se complete la tarea, necesitaremos convertirla en ejecución asíncrona usando Async.AwaitTask. Esto significa que obtenemos Async <Async>:



async {
  do! Task.Factory.StartNew(fun () -> async { 
    do! Async.Sleep(1000) }) |> Async.AwaitTask }


Nuevamente, este código no se compila. El problema es que el do! requiere Async a la derecha, pero en realidad recibe Async <Async>. En otras palabras, no podemos simplemente ignorar el resultado. Necesitamos hacer algo al respecto de manera explícita

(puede usar Async.Ignore para reproducir el comportamiento de C #). Puede que el mensaje de error no sea tan claro como los anteriores, pero da una idea general:



error FS0001: se esperaba que esta expresión tuviera el tipo Async ‹unit› pero aquí tiene la unidad de tipo


error FS0001: se esperaba una expresión asíncrona 'unidad', tipo de unidad presente


Error # 6: Async no funciona



Aquí hay otro fragmento de código problemático de la diapositiva de Lucian. Esta vez, el problema es bastante simple. El siguiente fragmento define un método FooAsync asincrónico y lo llama desde el controlador, pero el código no se ejecuta de forma asincrónica:



async Task FooAsync() 
{
    await Task.Delay(1000);
}
void Handler() 
{
    FooAsync().Wait();
}


Es fácil detectar el problema: llamamos a FooAsync (). Wait (). Esto significa que creamos una tarea y luego, usando Wait, bloqueamos el programa hasta que se complete. Una simple eliminación de Wait resuelve el problema, porque solo queremos comenzar la tarea.



Puede escribir el mismo código en F #, pero los flujos de trabajo asincrónicos no usan tareas .NET (originalmente diseñadas para el cálculo vinculado a la CPU), sino que usan el tipo F # Async, que no se incluye con Wait. Esto significa que debes escribir:



let fooAsync() = async {
    do! Async.Sleep(1000) }
let handler() = 
    fooAsync() |> Async.RunSynchronously


Por supuesto, dicho código se puede escribir por accidente, pero si se enfrenta a un problema de asincronía rota , notará fácilmente que el código llama a RunSynchronously, por lo que el trabajo se realiza, como su nombre indica, sincrónicamente .



Resumen



En este artículo, he analizado seis casos en los que el modelo de programación asincrónica en C # se comporta de formas inesperadas. La mayoría de ellos se basan en la conversación de Lucian y Stephen en el MVP Summit, ¡así que gracias a ambos por una interesante lista de errores comunes!



Para F #, traté de encontrar los fragmentos de código relevantes más cercanos utilizando flujos de trabajo asincrónicos. En la mayoría de los casos, el compilador de F # emitirá una advertencia o error, o el modelo de programación no tiene una forma (directa) de expresar el mismo código. Creo que esto confirma una afirmación que hice en una publicación de blog anterior : “El modelo de programación F # definitivamente parece más apropiado para lenguajes de programación funcionales (declarativos). También creo que facilita razonar sobre lo que está pasando ".



Finalmente, este artículo no debe entenderse como una crítica destructiva de la asincronía en C # :-). Entiendo completamente por qué el diseño de C # sigue los mismos principios que sigue: para C # tiene sentido usar Task (en lugar de Async separado), lo que tiene una serie de consecuencias. Y puedo entender las razones de las otras decisiones: esta es probablemente la mejor manera de integrar la programación asincrónica en C #. Pero al mismo tiempo, creo que F # hace un mejor trabajo, en parte debido a su capacidad de composición, pero lo que es más importante debido a complementos geniales como los agentes de F # . Además, la asincronía en F # también tiene sus problemas (el error más común es que las funciones recursivas de cola deben usarse return! En lugar de do !, para evitar filtraciones), pero este es un tema para una publicación de blog separada.



PD: del traductor. El artículo fue escrito en 2013, pero lo encontré lo suficientemente interesante y relevante como para traducirlo al ruso. Esta es mi primera publicación sobre Habré, así que no patees fuerte.



All Articles