Task.WhenAll, Task.WhenAny, and Task.Run in C#

Task.WhenAll runs multiple tasks concurrently and waits for all of them to finish. Task.WhenAny returns as soon as the first task completes. Task.Run schedules synchronous, CPU-bound work on a thread pool thread.

Choosing the right one depends on whether you need all results, just the fastest result, or need to move CPU work off the calling thread.

Task.WhenAll — Wait for All Tasks

Task.WhenAll starts all tasks at once and suspends until every task has completed. The total time is determined by the slowest task, not the sum of all tasks.

C# Example Code
using System;
using System.Diagnostics;
using System.Net.Http;
using System.Threading.Tasks;

// Sequential: total time ≈ 1000 + 500 + 800 = 2300 ms
async Task RunSequential()
{
    await Task.Delay(1000);
    await Task.Delay(500);
    await Task.Delay(800);
    Console.WriteLine("Sequential: ~2300 ms");
}

// Concurrent: total time ≈ max(1000, 500, 800) = 1000 ms
async Task RunConcurrent()
{
    var sw = Stopwatch.StartNew();

    await Task.WhenAll(
        Task.Delay(1000),
        Task.Delay(500),
        Task.Delay(800)
    );

    Console.WriteLine($"Concurrent: ~{sw.ElapsedMilliseconds} ms"); // ~1000 ms
}

Collecting Results with Task.WhenAll

When all tasks return the same type, Task.WhenAll returns an array of results in the same order the tasks were passed in.

C# Example Code
static async Task<int> FetchUserCountAsync(int regionId)
{
    await Task.Delay(300); // simulate DB query
    return regionId * 100;
}

static async Task CollectResults()
{
    Task<int> region1 = FetchUserCountAsync(1);
    Task<int> region2 = FetchUserCountAsync(2);
    Task<int> region3 = FetchUserCountAsync(3);

    int[] counts = await Task.WhenAll(region1, region2, region3);

    Console.WriteLine($"Region 1: {counts[0]}"); // 100
    Console.WriteLine($"Region 2: {counts[1]}"); // 200
    Console.WriteLine($"Region 3: {counts[2]}"); // 300
    Console.WriteLine($"Total: {counts[0] + counts[1] + counts[2]}"); // 600
}

Exception Handling with Task.WhenAll

When one or more tasks fail, await Task.WhenAll(...) re-throws the first exception. To inspect all failures, catch the Task before awaiting and read its Exception.InnerExceptions.

C# Example Code
static async Task HandleMultipleExceptions()
{
    Task t1 = Task.FromException(new InvalidOperationException("Region 1 failed"));
    Task t2 = Task.FromException(new TimeoutException("Region 2 timed out"));
    Task t3 = Task.Delay(200); // succeeds

    Task allTasks = Task.WhenAll(t1, t2, t3);

    try
    {
        await allTasks;
    }
    catch
    {
        // allTasks.Exception contains all failures
        foreach (var ex in allTasks.Exception!.InnerExceptions)
        {
            Console.WriteLine($"Error: {ex.Message}");
        }
        // Output:
        // Error: Region 1 failed
        // Error: Region 2 timed out
    }
}

Task.WhenAny — First Completed Task

Task.WhenAny returns a Task<Task> that completes as soon as the first task finishes. The returned value is the completed task, which you then await to get its result or exception.

C# Example Code
static async Task<string> QueryPrimaryAsync()
{
    await Task.Delay(800);
    return "primary result";
}

static async Task<string> QueryReplicaAsync()
{
    await Task.Delay(300);
    return "replica result";
}

static async Task UseWhenAny()
{
    Task<string> primary = QueryPrimaryAsync();
    Task<string> replica = QueryReplicaAsync();

    // Returns whichever task finishes first
    Task<string> winner = await Task.WhenAny(primary, replica);
    string result = await winner; // await the winning task to unwrap the result

    Console.WriteLine($"Got: {result}"); // Got: replica result (faster)
}

Timeout Pattern with Task.WhenAny

Combine Task.WhenAny with Task.Delay to implement a timeout without a CancellationToken.

C# Example Code
static async Task<string> FetchWithTimeoutAsync()
{
    Task<string> dataTask = FetchSlowDataAsync();
    Task timeoutTask = Task.Delay(TimeSpan.FromSeconds(2));

    Task completedFirst = await Task.WhenAny(dataTask, timeoutTask);

    if (completedFirst == timeoutTask)
    {
        throw new TimeoutException("Operation timed out after 2 seconds.");
    }

    return await dataTask; // safe — we know it completed
}

static async Task<string> FetchSlowDataAsync()
{
    await Task.Delay(3000); // slower than the timeout
    return "slow data";
}

Parallel Async Work with a List

Use Select + Task.WhenAll to fan out over a collection and gather results.

C# Example Code
using System.Collections.Generic;
using System.Linq;

static async Task<IEnumerable<string>> FetchAllPagesAsync(IEnumerable<string> urls)
{
    using HttpClient client = new HttpClient();

    IEnumerable<Task<string>> fetchTasks = urls.Select(url =>
        client.GetStringAsync(url));

    string[] pages = await Task.WhenAll(fetchTasks);
    return pages;
}

// Usage
List<string> urls = new()
{
    "https://example.com/page1",
    "https://example.com/page2",
    "https://example.com/page3",
};

IEnumerable<string> results = await FetchAllPagesAsync(urls);
Console.WriteLine($"Fetched {results.Count()} pages");

Task.Run — Offload CPU-Bound Work

Task.Run schedules a delegate on the thread pool. Use it when you have synchronous, CPU-intensive code and want to keep the calling thread (UI thread or ASP.NET request thread) free.

C# Example Code
static int HeavyComputation(int n)
{
    // Simulate CPU work
    int result = 0;
    for (int i = 0; i < n; i++) result += i % 7;
    return result;
}

static async Task RunCpuWork()
{
    // Offload to thread pool — calling thread is released
    int result = await Task.Run(() => HeavyComputation(10_000_000));
    Console.WriteLine($"Result: {result}");
}

Combining Task.Run with Task.WhenAll

Run multiple CPU-bound operations concurrently by combining Task.Run with Task.WhenAll.

C# Example Code
static async Task RunParallelCpuWork()
{
    Task<int> job1 = Task.Run(() => HeavyComputation(5_000_000));
    Task<int> job2 = Task.Run(() => HeavyComputation(8_000_000));
    Task<int> job3 = Task.Run(() => HeavyComputation(3_000_000));

    int[] results = await Task.WhenAll(job1, job2, job3);
    Console.WriteLine($"Jobs completed: {results[0]}, {results[1]}, {results[2]}");
}

Comparison: WhenAll vs WhenAny vs Task.Run

Task.WhenAllTask.WhenAnyTask.Run
Completes whenAll tasks finishFirst task finishesScheduled work completes
Best forFan-out async I/O, collecting resultsRace patterns, timeouts, first-availableCPU-bound work off calling thread
ReturnsTask / Task<T[]>Task<Task> / Task<Task<T>>Task / Task<T>
Exception behaviorThrows first; all failures in .ExceptionThrows if winner faultedThrows as AggregateException
CancellationPass CancellationToken to each taskCheck if winner is cancelled taskPass CancellationToken to Task.Run

Key Takeaways

  • Use Task.WhenAll when you need all results and want them concurrently.
  • Use Task.WhenAny for race patterns — timeout, first-available replica, progress tracking.
  • Use Task.Run to move CPU-bound synchronous code off the calling thread; avoid it for I/O tasks that already have async APIs.
  • Always await the task returned by Task.WhenAny a second time to propagate exceptions from the winner.
  • To capture all exceptions from Task.WhenAll, store the task before awaiting and inspect .Exception.InnerExceptions.