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:

  1. Creates a Composite Task: It constructs an internal completion source that returns a new composite Task representing the entire group.
  2. Attaches Continuations: It registers internal callbacks on each provided task using task continuations.
  3. Tracks Completion: An internal counter keeps track of remaining incomplete tasks. Each time a constituent task finishes, the counter decrements.
  4. Signals Completion: When the counter reaches zero, the composite task transitions to a completed state (RanToCompletion, Faulted, or Canceled), allowing the await expression 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 t3

Exception 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.