Running multiple Tasks: Maybe Task.WhenAll is not the best choice!

In this article, we're going to consider a specific scenario where using Task.WhenAll is not the best choice and is not efficient enough!

DISCLAIMER: The solution provided may not apply to all scenarios and you need to know your requirements first!

So, imagine we have 3 tasks that are independent and want to run all of them in parallel.

async Task DoOne()
{
    await Task.Delay(1000);
    Console.WriteLine("Done one!");
}

async Task DoTwo()
{
    await Task.Delay(2000);
    Console.WriteLine("Done two!");
}

async Task DoThree()
{
    await Task.Delay(3000);
    Console.WriteLine("Done three!");
}

The first run in sequence

In this case, if we want to do them one by one, we'll have this code:

Console.WriteLine($"In sequence Start: {TimeProvider.System.GetLocalNow()}");

await DoOne();
await DoTwo();
await DoThree();

Console.WriteLine($"In sequence End: {TimeProvider.System.GetLocalNow()}");

Which prints on the console:

In sequence Start: 8/4/2024 6:04:03 PM +07:00
Done one!
Done two!
Done three!
In sequence End: 8/4/2024 6:04:09 PM +07:00

It took 6 seconds to run (1+2+3) as a result of running sequentially.

Run in Parallel

but you may say I want to run all of them in parallel. Ok, no issue here is the code:

Console.WriteLine($"In Parallel Start: {TimeProvider.System.GetLocalNow()}");

var taskOne = DoOne();
var taskTwo = DoTwo();
var taskThree = DoThree();

await Task.WhenAll(taskOne, taskTwo, taskThree);

Console.WriteLine($"In Parallel End: {TimeProvider.System.GetLocalNow()}");

On the console, we see:

In Parallel Start: 8/4/2024 6:12:21 PM +07:00
Done one!
Done two!
Done three!
In Parallel End: 8/4/2024 6:12:24 PM +07:00

3 seconds to run (equal to DoThree time). Nice!

This is the way of running tasks in parallel.

What Task.WhenAll() does?

It creates a task that will be completed when all of the supplied tasks have been completed. So, it will add another task to keep track of the given tasks and check them until all of them are completed, adds a little overhead at runtime needs to deal with one more task but it's ok and working!

Let's make a little change to the task one:

async Task DoOne()
{
    await Task.Delay(1000);
    throw new Exception("Error in DoOne");
}

For any reason if one of the tasks fails (in our case DoOne) what happens? How Task.WhenAll handles?

try
{
    Console.WriteLine($"In Parallel Start: {TimeProvider.System.GetLocalNow()}");

    var taskOne = DoOne();
    var taskTwo = DoTwo();
    var taskThree = DoThree();

    await Task.WhenAll(taskOne, taskTwo, taskThree);

    Console.WriteLine($"In Parallel End: {TimeProvider.System.GetLocalNow()}");
}
catch (Exception xx)
{
    Console.WriteLine($"ERROR: {TimeProvider.System.GetLocalNow()} -- {xx.Message}");
}

And this is the console logs:

In Parallel Start: 8/4/2024 6:36:32 PM +07:00
Done two!
Done three!
ERROR: 8/4/2024 6:36:35 PM +07:00 -- Error in DoOne

Interesting! It took 3 seconds to return the result which is the exception and means it is waiting for all other tasks to be done no matter if the first one after 1 second already throws an exception. What if you want to finish running if one of the tasks fails because the end result is not accepted as we have an exception in the application?

Don't use Task.WhenAll!

As mentioned, if you want to interrupt the runtime as soon as one of the tasks fails then Task.WhenAll can't help. Let's change code as below:

try
{
    Console.WriteLine($"In Parallel (new way) End: {TimeProvider.System.GetLocalNow()}");

    var taskOne = DoOne();
    var taskTwo = DoTwo();
    var taskThree = DoThree();

    await taskOne;
    await taskThree;
    await taskTwo;

    Console.WriteLine($"In Parallel (new way) End: {TimeProvider.System.GetLocalNow()}");
}
catch (Exception xx)
{
    Console.WriteLine($"ERROR: {TimeProvider.System.GetLocalNow()} -- {xx.Message}");
    throw;
}

And on the console:

In Parallel (new way) End: 8/4/2024 6:48:06 PM +07:00
ERROR: 8/4/2024 6:48:07 PM +07:00 -- Error in DoOne

Yay! only took 1 second to throw an exception but wait let's answer some questions about the new approach:

1 - Are tasks running in parallel? Yes, they run in parallel, basically, the task started running after this line: var taskOne = DoOne(); and we need to give them a while to be completed either by Task.WhenAll or await them.

2- What happens to those 2 tasks? They also running until the end!!! What?? Yes, when a task starts it will run to be completed unless we use CancellationToken for all tasks and cancel it in the catch scope.

3- Is it a good approach to use always? It depends! if the tasks tend to throw exceptions it is a good way to consider otherwise better to use Task.WhenAll as it is clearer and all devs understand it.

Hope you enjoy it!

Code Simple!

2
An error has occurred. This application may no longer respond until reloaded. Reload x