C# Task Combinators: Task.WhenAll, Task.WhenAny, Task.WhenEach, and Parallel.ForEachAsync
Table of Contents
- Why Task Combinators Matter
- Task.WhenAll
- Start First, Await One-by-One
- Task.WhenAny
- Task.WhenEach (.NET 9)
- Parallel.ForEachAsync
- Exception Handling by Combinator
- Concurrent Scenario: Prefer task1, Fallback to task2
- Decision Guide
Why Task Combinators Matter
When an application performs multiple async operations, the hard part is usually not writing the operations themselves. The hard part is orchestration:
- Do you need all results before continuing?
- Do you only need the fastest result?
- Do you want to process results as they complete?
- Do you need bounded parallelism to protect CPU, I/O, or downstream services?
Task combinators solve these orchestration problems with expressive, reliable APIs.
Task.WhenAll
C# 5.0 / .NET 4.5
Purpose: Runs multiple tasks concurrently and completes when all of them finish.
- Returns an array of results for
Task<T>overloads.
Task<string> t1 = FetchAsync("url1");
Task<string> t2 = FetchAsync("url2");
Task<string> t3 = FetchAsync("url3");
string[] results = await Task.WhenAll(t1, t2, t3);
- [OK] Use for fan-out / scatter-gather patterns.
- [!] If one or more tasks fail,
await Task.WhenAll(...)throws. Inspect individual tasks if you need full error details.
Start First, Await One-by-One
Another common pattern is to start multiple tasks first, then await them one-by-one:
Task task1 = DoTask1();
Task task2 = DoTask2();
Task task3 = DoTask3();
await task1;
await task2;
await task3;
This is concurrent in the success path because all tasks are created before the first await.
How it differs from Task.WhenAll:
- Success case: often similar total time.
- Failure case: stops at the first failed
await, so later tasks might not be observed in that method. Task.WhenAll: gives a single coordination point and naturally fits "wait for everything" intent.
If you need all tasks to complete and want consistent exception handling, prefer Task.WhenAll:
Task task1 = DoTask1();
Task task2 = DoTask2();
Task task3 = DoTask3();
try
{
await Task.WhenAll(task1, task2, task3);
}
catch
{
// Inspect all faults if needed.
var failures = new[] { task1, task2, task3 }
.Where(t => t.IsFaulted)
.SelectMany(t => t.Exception!.Flatten().InnerExceptions)
.ToList();
throw failures.Count == 1
? failures[0]
: new AggregateException(failures);
}
Task.WhenAny
C# 5.0 / .NET 4.5
Purpose: Completes when any of the supplied tasks finishes.
- Returns the first completed task.
Task<string> fastest = await Task.WhenAny(
FetchAsync("primary"),
FetchAsync("fallback"));
string result = await fastest;
- [OK] Use for racing, timeouts, or first-response-wins patterns.
- [!] The other tasks keep running unless you cancel them.
Task.WhenEach (.NET 9)
.NET 9.0 / C# 13.0
Purpose: Returns an IAsyncEnumerable<Task<T>> that yields tasks in completion order.
Task<int>[] tasks = [ComputeAsync(1), ComputeAsync(2), ComputeAsync(3)];
await foreach (Task<int> completed in Task.WhenEach(tasks))
{
int result = await completed;
Console.WriteLine(result);
}
- [OK] Process results as soon as each task finishes, without manual bookkeeping.
Parallel.ForEachAsync
.NET 6.0
Purpose: Processes items in parallel with async/await and built-in degree-of-parallelism control.
var urls = new[] { "url1", "url2", "url3" };
await Parallel.ForEachAsync(urls,
new ParallelOptions { MaxDegreeOfParallelism = 3 },
async (url, ct) =>
{
string html = await httpClient.GetStringAsync(url, ct);
Process(html);
});
- [OK] Preferred over manual
Task.WhenAll+Selectwhen you need bounded parallelism.
Exception Handling by Combinator
Different combinators surface exceptions differently. Use the pattern that matches your intent.
Task.WhenAll: collect all failures
await Task.WhenAll(...) throws if any task fails. To inspect all faults, catch once, then inspect each task.
Task<string> t1 = FetchAsync("url1");
Task<string> t2 = FetchAsync("url2");
Task<string> t3 = FetchAsync("url3");
try
{
string[] results = await Task.WhenAll(t1, t2, t3);
return results;
}
catch
{
var failures = new[] { t1, t2, t3 }
.Where(t => t.IsFaulted)
.SelectMany(t => t.Exception!.Flatten().InnerExceptions)
.ToList();
if (failures.Count == 1)
{
throw failures[0];
}
throw new AggregateException(failures);
}
Task.WhenAny: winner first, handle loser tasks explicitly
WhenAny only tells you which task completed first. The winner can still be faulted/canceled, and losers can fault later.
using var cts = new CancellationTokenSource();
Task<string> primary = FetchAsync("primary", cts.Token);
Task<string> fallback = FetchAsync("fallback", cts.Token);
Task<string> winner = await Task.WhenAny(primary, fallback);
Task<string> loser = winner == primary ? fallback : primary;
try
{
string result = await winner; // May throw if winner faulted/canceled
cts.Cancel(); // Stop loser if possible
return result;
}
catch (Exception winnerEx)
{
// Winner failed: optionally try loser before failing the request.
try
{
return await loser;
}
catch (Exception loserEx)
{
throw new AggregateException("Winner and loser both failed.", winnerEx, loserEx);
}
}
finally
{
// Observe loser completion to avoid hidden/unobserved failures.
_ = loser.ContinueWith(_ => { }, TaskScheduler.Default);
}
Task.WhenEach: isolate per-item failures
WhenEach is best when each result is independent. Handle each completed task separately so one fault does not stop all processing.
Task<int>[] tasks = [ComputeAsync(1), ComputeAsync(2), ComputeAsync(3)];
var failures = new List<Exception>();
await foreach (Task<int> completed in Task.WhenEach(tasks))
{
try
{
int value = await completed;
Console.WriteLine($"Success: {value}");
}
catch (Exception ex)
{
failures.Add(ex);
Console.WriteLine($"Failed: {ex.Message}");
}
}
if (failures.Count > 0)
{
throw new AggregateException("One or more computations failed.", failures);
}
Parallel.ForEachAsync: fail-fast by default, aggregate when needed
By default, an unhandled exception in one iteration stops the whole operation. Catch inside the loop if you want best-effort behavior.
var urls = new[] { "url1", "url2", "url3" };
var failures = new ConcurrentBag<Exception>();
await Parallel.ForEachAsync(urls,
new ParallelOptions { MaxDegreeOfParallelism = 3 },
async (url, ct) =>
{
try
{
string html = await httpClient.GetStringAsync(url, ct);
Process(html);
}
catch (Exception ex)
{
failures.Add(new Exception($"{url} failed", ex));
}
});
if (!failures.IsEmpty)
{
throw new AggregateException(failures);
}
Concurrent Scenario: Prefer task1, Fallback to task2
Requirement:
task1andtask2must start in parallel.- If
task1completes with a non-null result, use it. - If
task1returns null, usetask2.
Simple version
Task<string?> task1 = GetPrimaryAsync();
Task<string?> task2 = GetFallbackAsync();
// Both tasks are already running concurrently.
string? primary = await task1;
if (primary is not null)
{
return primary;
}
return await task2;
Try/Catch Best Practices for This Scenario
- Catch
OperationCanceledExceptionseparately and rethrow withthrow;when caller cancellation is requested. - If you catch broad
Exception, do it only at the orchestration boundary, then inspect task states and rethrow meaningful exceptions. - Do not use
throw ex;because it resets the original stack trace. - Prefer one boundary log entry in the catch block plus final structured failure details when mapping task outcomes.
- Always observe both tasks (for example with
Task.WhenAll) to avoid hidden or unobserved exceptions. - Keep business decision logic after the catch section: first choose successful result path, then map fault/cancel outcomes deterministically.
Use this variant when you need deterministic behavior across all outcomes and explicit exception semantics.
Decision Guide
- Use
Task.WhenAllwhen all operations are required. - Use
Task.WhenAnywhen the first completed operation is enough. - Use
Task.WhenEachwhen you want streaming completion-order processing. - Use
Parallel.ForEachAsyncwhen processing many inputs with bounded concurrency. - Use the primary/fallback pattern when business logic prefers one source but can accept another.
Task combinators are not just about speed. They are about expressing concurrency intent clearly, which makes code easier to reason about, test, and operate in production.