Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

18 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 5 de noviembre de 2024
Ordenador con Tareas ejecutándose en paralelo

La clase Task de .NET dispone de algunos métodos estáticos para trabajar con colecciones de tareas de forma sencilla. Uno de ellos es Task.WhenAll(), que nos permite esperar a que todas las tareas que le pasemos como argumento se completen, o Task.WhenAny() para esperar a que finalice sólo alguna de ellas.

Sin embargo, hasta ahora no existía una forma sencilla y directa de procesar las tareas conforme se fueran completando. O bien esperábamos a que todas finalizaran con Task.WhenAll(), o bien teníamos que crear un bucle que fuera esperando la finalización de la siguiente tarea con Task.WhenAny(), saliendo cuando todas hubieran acabado.

Afortunadamente, .NET 9 ha añadido el nuevo método llamado Task.WhenEach() que lo pone más fácil, permitiéndonos detectar sobre la marcha la finalización de las tareas conforme va ocurriendo. Esto nos facilita el proceso de sus resultados secuencialmente, en el orden en el que se completan y sin esperar a que acaben todas.

Vamos a verlo, pero para entenderlo mejor, primero vamos a recordar las opciones que teníamos antes de la llegada de este método.

Esperas sobre colecciones de tareas en .NET 8 y anteriores

En versiones anteriores de .NET, si queríamos procesar los resultados obtenidos por un conjunto de tareas, teníamos varias posibilidades.

Una era esperar a que todas las tareas finalizaran y después procesar los resultados:

var tasks = ...; // Colección de tareas que retornan un entero
int[] results = await Task.WhenAll(tasks);
foreach (int result in results)
{
    ProcessResult(result);
}

El problema de este enfoque es que teníamos que esperar a que todas las tareas finalizaran antes de poder procesar los resultados. Si teníamos tareas que tardaban mucho en completarse, podíamos estar bloqueados durante un tiempo considerable, cuando en realidad podríamos haber ido adelantando trabajo si las hubiéramos procesado conforme iban finalizando.

Para hacerlo, tendríamos que recurrir a un bucle que fuera esperando a que se completara la siguiente tarea con Task.WhenAny(), del que saldríamos cuando todas hubieran acabado:

var tasks = ...; // Colección de tareas que retornan un entero
while (tasks.Any())
{
    Task<int> completedTask = await Task.WhenAny(tasks);
    tasks.Remove(completedTask);
    int result = await completedTask;
    ProcessResult(result);
}

De esta forma tendremos lo que queremos: procesar los resultados conforme se van completando las tareas. A cambio, hemos tenido que implementar a mano la lógica del bucle (y otras cosas que no vemos aquí, como el manejo de excepciones).

Task.WhenEach() en .NET 9

Task.WhenEach() tiene las ventajas de las dos fórmulas anteriores, pero ninguno de sus inconvenientes: podremos gestionar las tareas sobre la marcha, sin tener que esperar a que todas finalicen, y sin tener que implementar el bucle manualmente.

Pero lo mejor para entenderlo es verlo en acción, así que observad el siguiente ejemplo:

var tasks = ...; // Colección de tareas
await for(var completed in Task.WhenEach(tasks))
{
    // En 'completed' recibimos una tarea completada (con éxito o no)
}

Task.WhenEach() retorna un objeto IAsyncEnumerable<Task<T>>. Si no os suena, podéis echar un vistazo al post sobre enumerables asíncronos de hace algún tiempo.

En este caso, completed es una tarea que ya ha finalizado, y podemos procesarla directamente. Cuando se completó con éxito, podemos obtener su valor de retorno usando await completed, o bien acceder a su propiedad Result. Si la tarea ha fallado, podremos capturar la excepción con un bloque try-catch o a través de su propiedad Exception.

Veamos ahora un ejemplo completo que podáis copiar y pegar en una aplicación de consola vacía:

Random rnd = new Random();

var tasks = new List<Task<int>>();
for (var i = 1; i <= 10; i++)
{
    tasks.Add(CreateTask(i));
}

await foreach (Task<int> completedTask in Task.WhenEach(tasks))
{
    try
    {
        var result = await completedTask;
        ProcessResult(result);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Task Failed");
    }
}

async Task<int> CreateTask(int id)
{
    var delay = rnd.Next(1000, 5000);
    Console.WriteLine($"Launched #{id} with delay {delay}ms");
    await Task.Delay(delay);
    if (id == 4)
    {
        throw new TaskCanceledException();
    }
    return id;
}

void ProcessResult(int id) => Console.WriteLine($"Processed #{id}");

Como podéis ver, generamos una lista de 10 tareas que simplemente esperan un tiempo aleatorio entre 1 y 5 segundos y retornan un número identificador que previamente les hemos pasado.

Luego, usamos Task.WhenEach() para iterar sobre las tareas. En cada vuelta obtenemos una tarea completada, cuyo valor de retorno obtenemos y procesamos (en este caso, simplemente mostramos un mensaje por consola).

Si la tarea ha fallado, capturamos la excepción y mostramos un mensaje de error. Fijaos que para simular el caso del error, estamos forzando a que se lance una excepción en la tarea 4.

Otra posibilidad sin usar el bloque try-catch sería examinar la propiedad IsCompletedSuccessfully de la tarea, que nos indicará si ha finalizado con éxito o no, y Result para obtener el valor de retorno:

await foreach (Task<int> completedTask in Task.WhenEach(tasks))
{
    if (completedTask.IsCompletedSuccessfully)
    {
        ProcessResult(completedTask.Result);
    }
    else
    {
        Console.WriteLine($"Task Failed");
    }
}

En cualquiera de los casos, si ejecutáis el código podréis ver algo como lo siguiente:

Launched #1 with delay 4402ms
Launched #2 with delay 1393ms
Launched #3 with delay 1890ms
Launched #4 with delay 2456ms
Launched #5 with delay 2900ms
Launched #6 with delay 3061ms
Launched #7 with delay 2311ms
Launched #8 with delay 3548ms
Launched #9 with delay 3355ms
Launched #10 with delay 1208ms

Processed #10
Processed #2
Processed #3
Processed #7
Task Failed
Processed #5
Processed #6
Processed #9
Processed #8
Processed #1

Fijaos que el orden en el que aparecen los mensajes de "Processed" no es el mismo que el de los "Launched", ya que se van mostrando conforme se van completando las tareas. Por ejemplo, vemos que la primera tarea en procesarse fue la 10, porque, a pesar de haber sido lanzada en último lugar, es la que tenía el delay más bajo. La #1, en cambio, ha sido la última en completarse porque el tiempo de espera era el más alto.

¡Espero que os haya resultado interesante!

Publicado en Variable not found.

Aún no hay comentarios, ¡sé el primero!