Search code examples
c#multithreadingasynchronoustask-parallel-libraryparallel.foreachasync

Task.WhenAll vs Parallel.ForEachAsync - Which approach is best and why?


I am trying to gain understanding of Threading and Task Parallel Library in .NET. So I was experimenting with running tasks concurrently using 2 approaches as can be seen below.

Some background: I have got list of 5000 photos from https://jsonplaceholder.typicode.com/photos endpoint and I want to download those photos(being discarded but essentially mimics downloading). I am trying to do this using different approaches and figure out time taken by each approach and why.

  1. First approach, which is sequential, downloads one photo after another and takes longest (approx 24 minutes). This is understandable since I am awaiting to complete download of previous photo until next one begins. So no complaints here.

  2. Second approach uses List<Task> and adds each photo download task to the list and finally awaits for all tasks to complete. This takes approx 1 minute 7 seconds. Since it's downloading multiple photos parallelly, so was expecting lesser time compared to first i.e. sequential approach and it is the case. .

  3. Third approach uses Parallel.ForEachAsync(). To my surprise, this took 5 minutes 19 seconds to download all photos. I was expecting this to perform similar to the second approach however that's not the case.

using System.Diagnostics;
using System.Text.Json;

var httpClient = new HttpClient();

var photosResponse = await httpClient.GetAsync("https://jsonplaceholder.typicode.com/photos");
var content = await photosResponse.Content.ReadAsStringAsync();
var photos = JsonSerializer.Deserialize<List<Photo>>(content, new JsonSerializerOptions
{
    PropertyNameCaseInsensitive = true
})!;

var stopwatch = new Stopwatch();
stopwatch.Start();

// // 1.Sequential - Time taken: 23m 58s
// foreach (var photo in photos)
// {
//     Console.WriteLine($"Downloading {photo.Id} on Thread {Environment.CurrentManagedThreadId}");
//     var imageResponse = await httpClient.GetAsync(photo.Url);
//     _ = await imageResponse.Content.ReadAsByteArrayAsync();
//     Console.WriteLine($"Downloaded {photo.Id} on Thread {Environment.CurrentManagedThreadId}");
// }

// 2.Tasks - Time taken: 1m 7s
var tasks = new List<Task>();
foreach (var photo in photos!)
{
    tasks.Add(DownloadPhotoTask(photo, httpClient));
}
await Task.WhenAll(tasks);

// // 3.Parallel.ForEach - Time taken: 5m 19s
// await Parallel.ForEachAsync(photos, (photo, _) => DownloadPhotoValueTask(photo, httpClient));

stopwatch.Stop();

Console.WriteLine($"Time elapsed: {stopwatch.Elapsed}");
return;


async Task DownloadPhotoTask(Photo photo, HttpClient httpClientInternal)
{
    Console.WriteLine($"Downloading {photo.Id} on Thread {Environment.CurrentManagedThreadId}");
    var imageResponse = await httpClientInternal.GetAsync(photo.Url);
    _ = await imageResponse.Content.ReadAsByteArrayAsync();
    Console.WriteLine($"Downloaded {photo.Id} on Thread {Environment.CurrentManagedThreadId}");
}

async ValueTask DownloadPhotoValueTask(Photo photo, HttpClient httpClientInternal)
{
    Console.WriteLine($"Downloading {photo.Id} on Thread {Environment.CurrentManagedThreadId}");
    var imageResponse = await httpClientInternal.GetAsync(photo.Url);
    _ = await imageResponse.Content.ReadAsByteArrayAsync();
    Console.WriteLine($"Downloaded {photo.Id} on Thread {Environment.CurrentManagedThreadId}");
}
public record Photo(int Id, string Title, string Url);

What is the significant difference between time taken by 2nd approach and 3rd approach? Which is better? If duration is the only parameter, then obviously from my test, 2nd comes out as the best approach. If it is, why do we need Parallel.ForEachAsync()?

I am also interested to learn more about the internal workings of 2nd and 3rd approaches.


Solution

  • The Parallel.ForEachAsync has the option to limit the degree of parallelism, and this option is set by default to Environment.ProcessorCount for this method (source code). If you want to download the images with unlimited parallelism, risking to be perceived as a DOS attacker and be blocked by the remote server, you can set this option to Int32.MaxValue:

    ParallelOptions parallelOptions = new()
    {
        MaxDegreeOfParallelism = Int32.MaxValue
    };
    
    await Parallel.ForEachAsync(photos, parallelOptions, async (photo, cancellationToken) =>
    {
        await DownloadPhotoValueTask(photo, httpClient);
    });
    

    This way the DownloadPhotoValueTask will be invoked on ThreadPool threads, using all the threads available in the ThreadPool. No limitation will be imposed on how many asynchronous operations will be concurrently in flight. My expectation is that the performance of this approach will be very similar with your 2nd approach (the Task.WhenAll). Creating the tasks in parallel shouldn't make much of a difference compared to creating them on a single thread. The HttpClient APIs are not known to do extensive use of the CPU, so all the DownloadPhotoValueTask tasks should be created almost instantly either way.

    To answer directly your questions:

    What is the significant difference between time taken by 2nd approach and 3rd approach?

    The 3rd approach limits the level of concurrency, while the 2nd is not.

    Which is better?

    If the speed of the downloading is the only criterion, both are equally good (after configuring the Parallel.ForEachAsync according to the requirements).

    Why do we need Parallel.ForEachAsync?

    For many reasons:

    • We want to limit the number of concurrent requests to remote servers, in order to not trigger their anti-DOS defensive mechanisms.
    • We want to limit the number of tasks that are concurrently in-flight, because each task consumes resources like memory, and too many active tasks could crash our process with an OutOfMemoryException.

    You can find more reasons in this related question.

    As a side note, you are advised to pass the cancellationToken argument to the APIs HttpClient.GetAsync and HttpContent.ReadAsByteArrayAsync, to get more responsive completion of the parallel loop in case of an error.