Iniciar varias tareas asincrónicas y procesarlas a medida que se completan (C#)

Si usa Task.WhenAny, puede iniciar varias tareas a la vez y procesarlas una por una a medida que se completen, en lugar de procesarlas en el orden en el que se han iniciado.

En el siguiente ejemplo se usa una consulta para crear una colección de tareas. Cada tarea descarga el contenido de un sitio web especificado. En cada iteración de un bucle while, una llamada awaited a WhenAny devuelve la tarea en la colección de tareas que termine primero su descarga. Esa tarea se quita de la colección y se procesa. El bucle se repite hasta que la colección no contiene más tareas.

Prerrequisitos

Puede seguir este tutorial mediante una de las opciones siguientes:

  • Visual Studio 2022 con la carga de trabajo de desarrollo de escritorio de .NET instalada. El SDK de .NET se instala automáticamente al seleccionar esta carga de trabajo.
  • SDK de .NET con un editor de código de su elección, como Visual Studio Code.

Creación de una aplicación de ejemplo

Cree una nueva aplicación de consola de .NET Core. Puede crear una mediante el comando dotnet new console o desde Visual Studio.

Abra el archivo Program.cs en el editor de código y reemplace el código existente por este:

using System.Diagnostics;

namespace ProcessTasksAsTheyFinish;

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Hello World!");
    }
}

Adición de campos

Dentro de la definición de la clase Program, agregue los dos campos siguientes:

static readonly HttpClient s_client = new HttpClient
{
    MaxResponseContentBufferSize = 1_000_000
};

static readonly IEnumerable<string> s_urlList = new string[]
{
    "https://learn.microsoft.com",
    "https://learn.microsoft.com/aspnet/core",
    "https://learn.microsoft.com/azure",
    "https://learn.microsoft.com/azure/devops",
    "https://learn.microsoft.com/dotnet",
    "https://learn.microsoft.com/dynamics365",
    "https://learn.microsoft.com/education",
    "https://learn.microsoft.com/enterprise-mobility-security",
    "https://learn.microsoft.com/gaming",
    "https://learn.microsoft.com/graph",
    "https://learn.microsoft.com/microsoft-365",
    "https://learn.microsoft.com/office",
    "https://learn.microsoft.com/powershell",
    "https://learn.microsoft.com/sql",
    "https://learn.microsoft.com/surface",
    "https://learn.microsoft.com/system-center",
    "https://learn.microsoft.com/visualstudio",
    "https://learn.microsoft.com/windows",
    "https://learn.microsoft.com/xamarin"
};

HttpClient expone la capacidad de enviar solicitudes HTTP y de recibir respuestas HTTP. s_urlList contiene todas las direcciones URL que planea procesar la aplicación.

Actualización del punto de entrada de la aplicación

El punto de entrada principal de la aplicación de consola es el método Main. Reemplace el método existente por lo siguiente:

static Task Main() => SumPageSizesAsync();

El método Main actualizado ahora se considera un método Async main, el cual permite un punto de entrada asincrónico en el archivo ejecutable. Se expresa como una llamada a SumPageSizesAsync.

Creación de un método SumPageSizes asincrónico

Agregue el método SumPageSizesAsync después del método Main:

static async Task SumPageSizesAsync()
{
    var stopwatch = Stopwatch.StartNew();

    IEnumerable<Task<int>> downloadTasksQuery =
        from url in s_urlList
        select ProcessUrlAsync(url, s_client);

    List<Task<int>> downloadTasks = downloadTasksQuery.ToList();

    int total = 0;
    while (downloadTasks.Any())
    {
        Task<int> finishedTask = await Task.WhenAny(downloadTasks);
        downloadTasks.Remove(finishedTask);
        total += await finishedTask;
    }

    stopwatch.Stop();

    Console.WriteLine($"\nTotal bytes returned:  {total:#,#}");
    Console.WriteLine($"Elapsed time:          {stopwatch.Elapsed}\n");
}

El bucle while quita una de las tareas de cada iteración. Una vez completadas todas las tareas, el bucle finaliza. El método comienza creando una instancia e iniciando una clase Stopwatch. Después, incluye una consulta que, cuando se ejecuta, crea una colección de tareas. Cada llamada a ProcessUrlAsync en el siguiente código devuelve un objeto Task<TResult>, donde TResult es un entero:

IEnumerable<Task<int>> downloadTasksQuery =
    from url in s_urlList
    select ProcessUrlAsync(url, s_client);

Debido a la ejecución diferida con LINQ, se llama a Enumerable.ToList para iniciar cada tarea.

List<Task<int>> downloadTasks = downloadTasksQuery.ToList();

El bucle while realiza los pasos siguientes para cada tarea de la colección:

  1. Espera una llamada a WhenAny para identificar la primera tarea de la colección que ha finalizado su descarga.

    Task<int> finishedTask = await Task.WhenAny(downloadTasks);
    
  2. Quita la tarea de la colección.

    downloadTasks.Remove(finishedTask);
    
  3. Espera finishedTask, que se devuelve mediante una llamada a ProcessUrlAsync. La variable finishedTask es un Task<TResult> donde TResult es un entero. La tarea ya está completa, pero la espera para recuperar la longitud del sitio web descargado, como se muestra en el ejemplo siguiente. Si se produce un error en la tarea, await iniciará la primera excepción secundaria almacenada en AggregateException, en lugar de leer la propiedad Task<TResult>.Result que iniciaría la excepción AggregateException.

    total += await finishedTask;
    

Adición de un método de proceso

Agregue el siguiente método ProcessUrlAsync después del método SumPageSizesAsync:

static async Task<int> ProcessUrlAsync(string url, HttpClient client)
{
    byte[] content = await client.GetByteArrayAsync(url);
    Console.WriteLine($"{url,-60} {content.Length,10:#,#}");

    return content.Length;
}

Para cualquier dirección URL, el método usará la instancia de client proporcionada para obtener la respuesta como byte[]. La longitud se devuelve después de que la dirección URL y la longitud se escriban en la consola.

Ejecute el programa varias veces para comprobar que las longitudes que se han descargado no aparecen siempre en el mismo orden.

Precaución

Puede usar WhenAny en un bucle, como se describe en el ejemplo, para solucionar problemas que implican un número reducido de tareas. Sin embargo, otros enfoques son más eficaces si hay que procesar un gran número de tareas. Para más información y ejemplos, vea Processing Tasks as they complete (Procesar tareas a medida que se completan).

Ejemplo completo

El código siguiente es el texto completo del archivo Program.cs para el ejemplo.

using System.Diagnostics;

HttpClient s_client = new()
{
    MaxResponseContentBufferSize = 1_000_000
};

IEnumerable<string> s_urlList = new string[]
{
    "https://learn.microsoft.com",
    "https://learn.microsoft.com/aspnet/core",
    "https://learn.microsoft.com/azure",
    "https://learn.microsoft.com/azure/devops",
    "https://learn.microsoft.com/dotnet",
    "https://learn.microsoft.com/dynamics365",
    "https://learn.microsoft.com/education",
    "https://learn.microsoft.com/enterprise-mobility-security",
    "https://learn.microsoft.com/gaming",
    "https://learn.microsoft.com/graph",
    "https://learn.microsoft.com/microsoft-365",
    "https://learn.microsoft.com/office",
    "https://learn.microsoft.com/powershell",
    "https://learn.microsoft.com/sql",
    "https://learn.microsoft.com/surface",
    "https://learn.microsoft.com/system-center",
    "https://learn.microsoft.com/visualstudio",
    "https://learn.microsoft.com/windows",
    "https://learn.microsoft.com/xamarin"
};

await SumPageSizesAsync();

async Task SumPageSizesAsync()
{
    var stopwatch = Stopwatch.StartNew();

    IEnumerable<Task<int>> downloadTasksQuery =
        from url in s_urlList
        select ProcessUrlAsync(url, s_client);

    List<Task<int>> downloadTasks = downloadTasksQuery.ToList();

    int total = 0;
    while (downloadTasks.Any())
    {
        Task<int> finishedTask = await Task.WhenAny(downloadTasks);
        downloadTasks.Remove(finishedTask);
        total += await finishedTask;
    }

    stopwatch.Stop();

    Console.WriteLine($"\nTotal bytes returned:    {total:#,#}");
    Console.WriteLine($"Elapsed time:              {stopwatch.Elapsed}\n");
}

static async Task<int> ProcessUrlAsync(string url, HttpClient client)
{
    byte[] content = await client.GetByteArrayAsync(url);
    Console.WriteLine($"{url,-60} {content.Length,10:#,#}");

    return content.Length;
}

// Example output:
// https://learn.microsoft.com                                      132,517
// https://learn.microsoft.com/powershell                            57,375
// https://learn.microsoft.com/gaming                                33,549
// https://learn.microsoft.com/aspnet/core                           88,714
// https://learn.microsoft.com/surface                               39,840
// https://learn.microsoft.com/enterprise-mobility-security          30,903
// https://learn.microsoft.com/microsoft-365                         67,867
// https://learn.microsoft.com/windows                               26,816
// https://learn.microsoft.com/xamarin                               57,958
// https://learn.microsoft.com/dotnet                                78,706
// https://learn.microsoft.com/graph                                 48,277
// https://learn.microsoft.com/dynamics365                           49,042
// https://learn.microsoft.com/office                                67,867
// https://learn.microsoft.com/system-center                         42,887
// https://learn.microsoft.com/education                             38,636
// https://learn.microsoft.com/azure                                421,663
// https://learn.microsoft.com/visualstudio                          30,925
// https://learn.microsoft.com/sql                                   54,608
// https://learn.microsoft.com/azure/devops                          86,034

// Total bytes returned:    1,454,184
// Elapsed time:            00:00:01.1290403

Vea también