Back to Blog
C#

C# Task.WhenAll: Awaiting Multiple Tasks

c# task whenall: Learn how to use C# Task.WhenAll to await multiple asynchronous operations concurrently, handle exceptions, and manage cancellation.

C#asyncTask.WhenAllconcurrencyparallelism
Illustration of multiple asynchronous tasks merging into a single await point in C#

c# task whenall requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

When you need to start several asynchronous operations and wait for all of them to finish before continuing, Task.WhenAll is the standard tool in C#. It accepts a collection of tasks and returns a single task that completes when every input task has completed. This is different from awaiting each task sequentially, because the operations are started at the same time and run concurrently.

Basic Usage of Task.WhenAll

The simplest form of Task.WhenAll takes a set of Task objects and returns a Task that completes when all of them finish. The tasks must already be started; WhenAll does not start them. For example:

Task task1 = DoWorkAsync(); Task task2 = DoWorkAsync(); await Task.WhenAll(task1, task2);

Here, DoWorkAsync is called twice, and both operations begin immediately. The await suspends the current method until both tasks complete, but the operations themselves run concurrently. If you instead wrote await task1; await task2;, the second operation would not start until the first one finished, which defeats the purpose of parallel execution.

Getting Results from Task.WhenAll

When the tasks return values, use the generic overload Task.WhenAll<TResult> which returns a Task<TResult[]>. The resulting array contains the results in the same order as the input tasks, regardless of when each task actually completes.

Task<int> task1 = GetNumberAsync(); Task<int> task2 = GetNumberAsync(); int[] results = await Task.WhenAll(task1, task2); Console.WriteLine($"Sum: {results[0] + results[1]}");

This is particularly useful when you need to aggregate data from multiple independent sources, such as calling several web APIs or reading multiple files. The array ordering matches the order of the tasks you passed in, so you can reliably map results back to their originating requests.

How Exceptions Are Propagated

If any of the tasks passed to Task.WhenAll faults, the returned task also faults. The exception is an AggregateException that contains all the exceptions from the individual tasks. When you await the faulted task, the first exception in the aggregate is rethrown by default, which can hide additional failures. To observe all exceptions, catch AggregateException explicitly.

try { await Task.WhenAll(task1, task2); } catch (AggregateException ex) { foreach (var inner in ex.InnerExceptions) { Console.WriteLine(inner.Message); } }

Note that if you use await without a catch block, only the first exception surfaces. This is often sufficient, but if you need to log every failure, you must inspect InnerExceptions. Also, if any task is canceled and none fault, the returned task transitions to the canceled state, and awaiting it throws TaskCanceledException.

Performance and Concurrency Considerations

Task.WhenAll itself does not limit concurrency. If you start 1000 tasks at once, all 1000 run concurrently, which can exhaust the thread pool or overwhelm external resources like a database or a remote service. The method only waits; it does not throttle. For controlled parallelism, combine WhenAll with a SemaphoreSlim to limit how many tasks run at any given time.

var semaphore = new SemaphoreSlim(10); var tasks = urls.Select(async url => { await semaphore.WaitAsync(); try { return await DownloadAsync(url); } finally { semaphore.Release(); } }); var results = await Task.WhenAll(tasks);

This pattern keeps the number of concurrent downloads at ten while still allowing all URLs to be processed in one batch. Without such throttling, you risk thread pool starvation or timeouts in downstream services.

Comparing Task.WhenAll with Sequential Await and Task.WaitAll

Sequential await is simple but serializes execution. Task.WhenAll is asynchronous and non-blocking, ideal for UI or server contexts. Task.WaitAll blocks the calling thread, which can cause deadlocks in UI or ASP.NET contexts if not used carefully.

ApproachExecutionBlockingBest Use Case
Sequential awaitOne at a timeNoDependent operations
Task.WhenAllConcurrentNoIndependent operations, async context
Task.WaitAllConcurrentYesConsole apps, no async context needed

Use Task.WhenAll whenever you are already in an async method and the operations are independent. Reserve Task.WaitAll for scenarios where you cannot use await, such as a Main method in older C# versions or when you must block deliberately.

Cancellation with Task.WhenAll

Task.WhenAll accepts an optional CancellationToken. If the token is canceled while the tasks are running, the returned task transitions to the canceled state. However, the individual tasks are not automatically canceled; they continue unless they observe the token themselves.

using var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromSeconds(5)); try { await Task.WhenAll(task1, task2, cts.Token); } catch (TaskCanceledException) { Console.WriteLine("Operation timed out."); }

In this example, the WhenAll operation aborts after five seconds, but task1 and task2 may still be running in the background. To actually stop them, you must pass the same token to each underlying operation and ensure they react to cancellation. The token passed to WhenAll only controls the wait, not the tasks themselves.

Common Pitfalls and Edge Cases

One frequent mistake is starting tasks inside a loop and accidentally awaiting each one immediately, which turns parallel execution into sequential execution. For example, await Task.WhenAll(urls.Select(async url => await DownloadAsync(url))) works correctly because the lambda returns a task, but foreach (var url in urls) { await DownloadAsync(url); } does not. Always collect the tasks first, then call WhenAll.

Another edge case is an empty collection. Task.WhenAll with no tasks returns an already completed task, so await Task.WhenAll(Array.Empty<Task>()) succeeds immediately. This is useful when a list of operations is conditionally empty.

If you mix faulted and canceled tasks, the faulted state takes precedence. The returned task faults if any task faults, even if others are canceled. Only when no task faults and at least one is canceled does the returned task become canceled. Understanding this precedence helps you write predictable error handling.

Finally, be aware that Task.WhenAll does not guarantee that all tasks start at the exact same moment. It simply waits for all of them. The actual concurrency depends on the scheduler and the nature of the operations. For CPU-bound work, consider Parallel.ForEachAsync or Task.Run with a bounded degree of parallelism, but for I/O-bound work, Task.WhenAll is usually the right choice.

c# task whenall: Practical Usage and Code Examples | RYUSLOG DEV