How to use Task.WhenAll()?

When we have multiple Tasks and want all of them to complete asynchronously, we can use Task.WhenAll(). This method creates a new Task that ensures that all given Tasks get completed. But this method has some pitfalls that I will write about in this article.

When can we use Task.WhenAll()?

Sometimes we have multiple independent Tasks that can be executed at the same time. Instead of executing each Task sequentially, we can use Task.WhenAll(). This method takes a list of Tasks, completes them all and then returns.

Let’s take this simple code example:

public static async Task Main()
{
    Stopwatch stopwatch = Stopwatch.StartNew();

    Task<int> task1 = GetIntAsync(stopwatch, 1, 1);
    Task<int> task2 = GetIntAsync(stopwatch, 2, 2);

    await Task.WhenAll(task1, task2);

    Console.WriteLine($"Finished after {stopwatch.Elapsed.Seconds:0}s");
}

public static async Task<int> GetIntAsync(Stopwatch stopwatch, int id, int delaySeconds)
{
    await LogAndDelayAsync(stopwatch, id, delaySeconds);

    return id;
}

private static async Task LogAndDelayAsync(Stopwatch stopwatch, int id, int delaySeconds)
{
    Console.WriteLine($"Task {id} - Start ({stopwatch.Elapsed.Seconds:0}s)");

    await Task.Delay(delaySeconds * 1000);

    Console.WriteLine($"Task {id} - End ({stopwatch.Elapsed.Seconds:0}s)");
}

In this example we call GetIntAsync() twice but without using await. By using await, the application would wait for the Task to finish and therefore run the Tasks sequentially. The Tasks we get by calling the GetIntAsync() are already running. With the call to Task.WhenAll(), we wait for the Tasks to finish and then we can use the results.

The console output of the code above will look like this:

Task 1 - Start (0s)
Task 2 - Start (0s)
Task 1 - End (1s)
Task 2 - End (2s)
Finished after 2s

We can see that both Tasks start at the same time. The first Task finishes after 1 second and the second Task after 2 seconds. Both Tasks together take 2 seconds (time of the longest Task) and not 3 seconds (sum of all Tasks).

To get the results back from the Tasks we can either use the Result property or use await again. Since the Tasks are completed, Result won’t block and await won’t run the Task again but return the value immediately. I prefer await since this is the more natural way to get results from Tasks:

Console.WriteLine(task1.Result);
Console.WriteLine(task2.Result);

Console.WriteLine(await task1);
Console.WriteLine(await task2);

What happens, when a Task fails?

In the chapter above, everything went well, but this is not always the case. In the following code, the Tasks throw an InvalidOperationException and we have a simple catch clause:

public static async Task Main()
{
    Stopwatch stopwatch = Stopwatch.StartNew();

    Task<int> task1 = GetIntAsync(stopwatch, 1, 1);
    Task<int> task2 = GetIntAsync(stopwatch, 2, 2);

    try
    {
        await Task.WhenAll(task1, task2);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"catched Exception - {ex.GetType().Name}: {ex.Message}");
    }

    Console.WriteLine($"Finished after {stopwatch.Elapsed.Seconds:0}s");
}

public static async Task<int> GetIntAsync(Stopwatch stopwatch, int id, int delaySeconds)
{
    await LogAndDelayAsync(stopwatch, id, delaySeconds);

    throw new InvalidOperationException($"Task {id}: Expected Error!");
}

When we run this code, we get the following output:

Task 1 - Start (0s)
Task 2 - Start (0s)
Task 1 - End (1s)
Task 2 - End (2s)
catched Exception - InvalidOperationException: Task 1: Expected Error!
Finished after 2s

Based on this log, we can see that we only get an Exception from the first Task but it still takes 2 seconds which means that Task.WhenAll() waited for both Tasks to complete.

We only see one Exception, even though the resulting Task from Task.WhenAll() has multiple Exceptions. This is because with await, only the first Exception is thrown instead of an AggregateException which would contain all Exceptions. The reason for this behavior is described in this old article about Task Exception Handling.

We can get all Exceptions by accessing the Task we get from Task.WhenAll() or can check the state of the original Tasks:

public static async Task Main()
{
    Stopwatch stopwatch = Stopwatch.StartNew();

    Task<int> task1 = GetIntAsync(stopwatch, 1, 1);
    Task<int> task2 = GetIntAsync(stopwatch, 2, 2);

    Task<int[]> whenAllTask = Task.WhenAll(task1, task2);

    try
    {
        await whenAllTask;
    }
    catch (Exception ex)
    {
        Console.WriteLine($"catched Exception - {ex.GetType().Name}: {ex.Message}");

        AggregateException? aggregateException = whenAllTask.Exception;

        if (aggregateException != null)
        {
            Console.WriteLine("whenAllTask.Exception - AggregateException");

            foreach (Exception innerException in aggregateException.InnerExceptions)
            {
                Console.WriteLine($"   {innerException.GetType().Name}: {innerException.Message}");
            }
        }
    }

    Console.WriteLine($"Task 1 - Status: '{task1.Status}', Exception: '{(task1.Exception == null ? "" : task1.Exception.Message)}'");
    Console.WriteLine($"Task 2 - Status: '{task2.Status}', Exception: '{(task2.Exception == null ? "" : task2.Exception.Message)}'");

    Console.WriteLine($"Finished after {stopwatch.Elapsed.Seconds:0}s");
}

This code will lead to the following output:

Task 1 - Start (0s)
Task 2 - Start (0s)
Task 1 - End (1s)
Task 2 - End (2s)
catched Exception - InvalidOperationException: Task 1: Expected Error!
whenAllTask.Exception - AggregateException
   InvalidOperationException: Task 1: Expected Error!
   InvalidOperationException: Task 2: Expected Error!
Task 1 - Status: 'Faulted', Exception: 'One or more errors occurred. (Task 1: Expected Error!)'
Task 2 - Status: 'Faulted', Exception: 'One or more errors occurred. (Task 2: Expected Error!)'
Finished after 2s

Depending on the requirements, it is enough to know that any Exception occurred. In other cases, we can get all individual failed Tasks and start a retry procedure for example. This is also important for logging. Just by catching and logging an Exception, we might miss other Exceptions.

But even in error cases, the Task.WhenAll() method ensures that all Tasks are completed.

How to get an AggregateException?

With an extension method we can catch the AggregateException on the Task object directly instead of just the first Exception:

public static Task<T> WithAggregateException<T>(this Task<T> sourceTask)
{
    ArgumentNullException.ThrowIfNull(sourceTask);

    static Task<T> HandleTaskResult(Task<T> task)
    {
        if (task.IsFaulted)
        {
            return Task.FromException<T>(task.Exception);
        }

        return task;
    }

    return sourceTask.ContinueWith(HandleTaskResult,
                                   CancellationToken.None,
                                   TaskContinuationOptions.ExecuteSynchronously,
                                   TaskScheduler.Default)
                     .Unwrap();
}

This method can be appended to Task.WhenAll():

try
{
    await Task.WhenAll(task1, task2)
              .WithAggregateException();
}
catch (AggregateException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}

And then we get an AggregateException which can be used for simple but complete logging:

Task 1 - Start (0s)
Task 2 - Start (0s)
Task 1 - End (1s)
Task 2 - End (2s)
catched Exception - AggregateException: One or more errors occurred. (Task 1: Expected Error!) (Task 2: Expected Error!)
Task 1 - Status: 'Faulted', Exception: 'One or more errors occurred. (Task 1: Expected Error!)'
Task 2 - Status: 'Faulted', Exception: 'One or more errors occurred. (Task 2: Expected Error!)'
Finished after 2s

How is Cancellation handled?

When a single Task is cancelled, an OperationCanceledException is thrown. The other Tasks are completed anyways. We can see this by updating the GetIntAsync() method in the sample above:

public static async Task<int> GetIntAsync(Stopwatch stopwatch, int id, int delaySeconds, CancellationToken cancellationToken)
{
    await LogAndDelayAsync(stopwatch, id, delaySeconds);

    cancellationToken.ThrowIfCancellationRequested();

    return id;
}

And update the calls to the method:

CancellationTokenSource cts = new CancellationTokenSource();
cts.Cancel();

Task<int> task1 = GetIntAsync(stopwatch, 1, 1, cts.Token);
Task<int> task2 = GetIntAsync(stopwatch, 2, 2, CancellationToken.None);

We get the following result after 2 seconds:

Task 1 - Start (0s)
Task 2 - Start (0s)
Task 1 - End (1s)
Task 2 - End (2s)
catched Exception - OperationCanceledException: The operation was canceled.
Task 1 - Status: 'Canceled', Exception: ''
Task 2 - Status: 'RanToCompletion', Exception: ''
Finished after 2s

When one Task is cancelled and another throws a different Exception, then the Exception is thrown. We can find cancelled Tasks by checking each individual Task. By throwing an Exception in the GetIntAsync() method, the output would look like this:

Task 1 - Start (0s)
Task 2 - Start (0s)
Task 1 - End (1s)
Task 2 - End (2s)
catched Exception - InvalidOperationException: Task 2: Expected Error!
whenAllTask.Exception - AggregateException
   InvalidOperationException: Task 2: Expected Error!
Task 1 - Status: 'Canceled', Exception: ''
Task 2 - Status: 'Faulted', Exception: 'One or more errors occurred. (Task 2: Expected Error!)'
Finished after 2s

Can we get rid of Task.WhenAll()?

People on the world wide web wrote, that Task.WhenAll() is not needed because the Tasks are executed asynchronously anyways when they’re not awaited immediately. While this might work in the best case, we get different results with Exceptions. When one Task throws, the other is not guaranteed to be completed. This can be tested with the following code:

public static async Task Main()
{
    Stopwatch stopwatch = Stopwatch.StartNew();

    Task<int> task1 = GetIntAsync(stopwatch, 1, 1);
    Task<int> task2 = GetIntAsync(stopwatch, 2, 2);

    try
    {
        await task1;
        await task2;
    }
    catch (Exception ex)
    {
        Console.WriteLine($"catched Exception - {ex.GetType().Name}: {ex.Message}");
    }

    Console.WriteLine($"Task 1 - Status: '{task1.Status}', Exception: '{(task1.Exception == null ? "" : task1.Exception.Message)}'");
    Console.WriteLine($"Task 2 - Status: '{task2.Status}', Exception: '{(task2.Exception == null ? "" : task2.Exception.Message)}'");

    Console.WriteLine($"Finished after {stopwatch.Elapsed.Seconds:0}s");
}

public static async Task<int> GetIntAsync(Stopwatch stopwatch, int id, int delaySeconds)
{
    await LogAndDelayAsync(stopwatch, id, delaySeconds);

    throw new InvalidOperationException($"Task {id}: Expected Error!");
}

Which leads to the following output:

Task 1 - Start (0s)
Task 2 - Start (0s)
Task 1 - End (1s)
catched Exception - InvalidOperationException: Task 1: Expected Error!
Task 1 - Status: 'Faulted', Exception: 'One or more errors occurred. (Task 1: Expected Error!)'
Task 2 - Status: 'WaitingForActivation', Exception: ''
Finished after 1s

As we can see, we get the result after 1 second. The second Task is started (see second line) but not activated and definitely not completed. By omitting Task.WhenAll() we get an unpredictable result.

Conclusion

Using Task.WhenAll() is a good option in certain situations and can reduce the runtime significantly. The error handling is not as easy as it looks when we need all Exceptions, for example for logging.