Waiting for Multiple Tasks with Task.WhenAll in C
In C#, Task.WhenAll allows you to wait for multiple
asynchronous operations concurrently by creating a single tracking task
that completes only when every task in a given collection has finished.
Instead of waiting for tasks sequentially, which causes unnecessary
delays, Task.WhenAll lets tasks run side by side and
provides a centralized way to coordinate their completion, aggregate
their return values, and handle potential errors.
The Problem with Sequential Awaiting
When you use the await keyword on tasks one after
another, your code executes them sequentially:
// Runs sequentially: Total time is Task1 + Task2
var result1 = await FetchDataAsync();
var result2 = await FetchImagesAsync();In this pattern, FetchImagesAsync() does not even begin
until FetchDataAsync() has completely finished. If both
operations are independent I/O-bound operations (such as HTTP requests
or database queries), this approach wastes valuable execution time.
How Task.WhenAll Enables Concurrency
Task.WhenAll achieves concurrency by decoupling the
initiation of tasks from their awaiting. You invoke the asynchronous
methods first—placing them into an active state—and then pass their
references to Task.WhenAll.
// Start both operations concurrently
Task<Data> dataTask = FetchDataAsync();
Task<Image> imageTask = FetchImagesAsync();
// Wait for both to finish
await Task.WhenAll(dataTask, imageTask);
// Retrieve results safely without blocking
Data data = await dataTask;
Image image = await imageTask;Internally, Task.WhenAll does the following:
- Creates a Composite Task: It constructs an internal
completion source that returns a new composite
Taskrepresenting the entire group. - Attaches Continuations: It registers internal callbacks on each provided task using task continuations.
- Tracks Completion: An internal counter keeps track of remaining incomplete tasks. Each time a constituent task finishes, the counter decrements.
- Signals Completion: When the counter reaches zero,
the composite task transitions to a completed state
(
RanToCompletion,Faulted, orCanceled), allowing theawaitexpression to resume execution.
Returning Aggregated Results
If all input tasks return the same type T,
Task.WhenAll returns a Task<T[]>.
Awaiting it directly yields an array containing the results in the exact
order the tasks were passed in, regardless of which task finished
first:
Task<int> t1 = ProcessBatchAsync(1);
Task<int> t2 = ProcessBatchAsync(2);
Task<int> t3 = ProcessBatchAsync(3);
int[] results = await Task.WhenAll(t1, t2, t3);
// results[0] matches t1, results[1] matches t2, results[2] matches t3Exception Aggregation
When multiple tasks fail concurrently, standard sequential
await statements only catch the exception of the task that
fails first. In contrast, Task.WhenAll waits for all tasks
to settle before reporting failures.
If any task throws an unhandled exception, the composite task
transitions to the Faulted state. Internally, all thrown
exceptions are bundled into an AggregateException. When you
await Task.WhenAll(...), the runtime unwraps and rethrows
only the first exception for syntax convenience. However, you can
inspect the composite task's Exception property to access
every error that occurred:
Task allTasks = Task.WhenAll(task1, task2);
try
{
await allTasks;
}
catch (Exception)
{
// allTasks.Exception contains all aggregated exceptions
foreach (var inner in allTasks.Exception.InnerExceptions)
{
Console.WriteLine(inner.Message);
}
}Through this mechanism, Task.WhenAll ensures optimal
throughput for independent workloads while maintaining safe, coordinated
state and error management.