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.