Cuando usa correctamente Task.Run y ​​cuando solo async-await

318

Me gustaría preguntarle su opinión sobre la arquitectura correcta cuando usarla Task.Run. Estoy experimentando una interfaz de usuario lenta en nuestra aplicación WPF .NET 4.5 (con el marco Caliburn Micro).

Básicamente estoy haciendo (fragmentos de código muy simplificados):

public class PageViewModel : IHandle<SomeMessage>
{
   ...

   public async void Handle(SomeMessage message)
   {
      ShowLoadingAnimation();

      // Makes UI very laggy, but still not dead
      await this.contentLoader.LoadContentAsync();

      HideLoadingAnimation();
   }
}

public class ContentLoader
{
    public async Task LoadContentAsync()
    {
        await DoCpuBoundWorkAsync();
        await DoIoBoundWorkAsync();
        await DoCpuBoundWorkAsync();

        // I am not really sure what all I can consider as CPU bound as slowing down the UI
        await DoSomeOtherWorkAsync();
    }
}

De los artículos / videos que leí / vi, sé que await asyncno necesariamente se ejecuta en un hilo de fondo y para comenzar a trabajar en segundo plano, debe envolverlo con esperar Task.Run(async () => ... ). El uso async awaitno bloquea la interfaz de usuario, pero aún se está ejecutando en el subproceso de la interfaz de usuario, por lo que la hace lenta.

¿Dónde está el mejor lugar para poner Task.Run?

Debería solo

  1. Ajuste la llamada externa porque esto es menos trabajo de subprocesamiento para .NET

  2. , ¿o debería ajustar solo los métodos vinculados a la CPU que se ejecutan internamente, Task.Runya que esto lo hace reutilizable para otros lugares? No estoy seguro aquí si comenzar a trabajar en subprocesos de fondo en el núcleo es una buena idea.

Anuncio (1), la primera solución sería así:

public async void Handle(SomeMessage message)
{
    ShowLoadingAnimation();
    await Task.Run(async () => await this.contentLoader.LoadContentAsync());
    HideLoadingAnimation();
}

// Other methods do not use Task.Run as everything regardless
// if I/O or CPU bound would now run in the background.

Anuncio (2), la segunda solución sería así:

public async Task DoCpuBoundWorkAsync()
{
    await Task.Run(() => {
        // Do lot of work here
    });
}

public async Task DoSomeOtherWorkAsync(
{
    // I am not sure how to handle this methods -
    // probably need to test one by one, if it is slowing down UI
}
Lukas K
fuente
Por cierto, la línea en (1) await Task.Run(async () => await this.contentLoader.LoadContentAsync());simplemente debería ser await Task.Run( () => this.contentLoader.LoadContentAsync() );. AFAIK no ganas nada agregando un segundo awaity asyncadentro Task.Run. Y como no estás pasando parámetros, eso se simplifica un poco más await Task.Run( this.contentLoader.LoadContentAsync );.
ToolmakerSteve
En realidad, hay una pequeña diferencia si tienes una segunda espera dentro. Ver este artículo . Lo encontré muy útil, solo con este punto en particular no estoy de acuerdo y prefiero regresar directamente Tarea en lugar de esperar. (como sugiere en su comentario)
Lukas K

Respuestas:

365

Tenga en cuenta las pautas para realizar el trabajo en un hilo de la interfaz de usuario , recopiladas en mi blog:

  • No bloquee el hilo de la interfaz de usuario durante más de 50 ms a la vez.
  • Puede programar ~ 100 continuaciones en el hilo de la interfaz de usuario por segundo; 1000 es demasiado.

Hay dos técnicas que debes usar:

1) Úselo ConfigureAwait(false)cuando pueda.

Por ejemplo, en await MyAsync().ConfigureAwait(false);lugar de await MyAsync();.

ConfigureAwait(false)le indica awaitque no necesita reanudar en el contexto actual (en este caso, "en el contexto actual" significa "en el hilo de la interfaz de usuario"). Sin embargo, para el resto de ese asyncmétodo (después del ConfigureAwait), no puede hacer nada que suponga que está en el contexto actual (por ejemplo, actualizar elementos de la interfaz de usuario).

Para obtener más información, consulte mi artículo de MSDN Mejores prácticas en programación asincrónica .

2) Se usa Task.Runpara llamar a métodos vinculados a la CPU.

Debe usar Task.Run, pero no dentro de ningún código que desee que sea reutilizable (es decir, código de biblioteca). Por lo tanto se utiliza Task.Runpara llamar al método, no como parte de la aplicación del método.

Así, el trabajo puramente vinculado a la CPU se vería así:

// Documentation: This method is CPU-bound.
void DoWork();

Que llamarías usando Task.Run:

await Task.Run(() => DoWork());

Los métodos que son una combinación de CPU y E / S deben tener una Asyncfirma con documentación que indique su naturaleza de CPU:

// Documentation: This method is CPU-bound.
Task DoWorkAsync();

A lo que también llamarías usando Task.Run(ya que está parcialmente vinculado a la CPU):

await Task.Run(() => DoWorkAsync());
Stephen Cleary
fuente
44
¡Gracias por su rápida respuesta! Conozco el enlace que publicaste y vi los videos a los que se hace referencia en tu blog. En realidad, es por eso que publiqué esta pregunta: en el video se dice (igual que en su respuesta) no debe usar Task.Run en el código central. Pero mi problema es que necesito ajustar ese método cada vez que lo uso para no ralentizar las respuestas (tenga en cuenta que todo mi código es asíncrono y no está bloqueando, pero sin Thread.Run es simplemente lento). También estoy confundido si es un mejor enfoque simplemente envolver los métodos vinculados a la CPU (muchas llamadas de Task.Run) o envolver completamente todo en una Task.Run?
Lukas K
12
Todos sus métodos de biblioteca deberían estar usando ConfigureAwait(false). Si lo haces primero, entonces puedes encontrar que Task.Runes completamente innecesario. Si no todavía necesita Task.Run, entonces no hay mucha diferencia con el tiempo de ejecución en este caso si se llama a una o varias veces, por lo que acaba de hacer lo que es más natural para su código.
Stephen Cleary
2
No entiendo cómo la primera técnica lo ayudará. Incluso si lo usa ConfigureAwait(false)en su método enlazado a la CPU, sigue siendo el hilo de la interfaz de usuario que va a hacer el método enlazado a la CPU, y solo todo lo que se pueda hacer después se puede hacer en un hilo TP. ¿O entendí mal algo?
Darius
44
@ user4205580: No, Task.Runentiende las firmas asincrónicas, por lo que no se completará hasta que DoWorkAsyncse complete. El extra async/ awaites innecesario. Explico más sobre el "por qué" en mi serie de blogs sobre Task.Runetiqueta .
Stephen Cleary
3
@ user4205580: No. La gran mayoría de los métodos asincrónicos "centrales" no lo usan internamente. La forma normal de implementar un método asincrónico "central" es usar TaskCompletionSource<T>una de sus anotaciones abreviadas como FromAsync. Tengo una publicación de blog que detalla más por qué los métodos asincrónicos no requieren hilos .
Stephen Cleary
12

Un problema con su ContentLoader es que internamente funciona secuencialmente. Un mejor patrón es paralelizar el trabajo y luego sincronizarlo al final, para obtener

public class PageViewModel : IHandle<SomeMessage>
{
   ...

   public async void Handle(SomeMessage message)
   {
      ShowLoadingAnimation();

      // makes UI very laggy, but still not dead
      await this.contentLoader.LoadContentAsync(); 

      HideLoadingAnimation();   
   }
}

public class ContentLoader 
{
    public async Task LoadContentAsync()
    {
        var tasks = new List<Task>();
        tasks.Add(DoCpuBoundWorkAsync());
        tasks.Add(DoIoBoundWorkAsync());
        tasks.Add(DoCpuBoundWorkAsync());
        tasks.Add(DoSomeOtherWorkAsync());

        await Task.WhenAll(tasks).ConfigureAwait(false);
    }
}

Obviamente, esto no funciona si alguna de las tareas requiere datos de otras tareas anteriores, pero debería brindarle un mejor rendimiento general para la mayoría de los escenarios.

Paul Hatcher
fuente