Running Work Concurrently

Task.WhenAll waits for every task and Task.WhenAny for the first to finish; the two handle failures very differently.

Syntaxpublic static Task<TResult[]> WhenAll<TResult>(params Task<TResult>[] tasks)

Awaiting calls one after another is sequential: each wait starts when the one before it ends. If the calls do not depend on each other, start them all first and then wait once.

Task.WhenAll

Task.WhenAll returns a single task that finishes when every supplied task has finished. Given Task<T> values it returns Task<T[]>, and the array is in argument order, not completion order. Total time is roughly the slowest call rather than the sum.

If several tasks fail, the combined task holds all their exceptions in an AggregateException on its Exception property, but await throws only the first one. To see them all, catch, then inspect the task's Exception.InnerExceptions.

Task.WhenAny

Task.WhenAny finishes as soon as the first supplied task finishes, and its result is that task, not that task's value. So await Task.WhenAny(a, b) gives you a Task<T> you then await or read. It completes successfully even when the winner failed or was canceled, so a failure is only reported when you look at the winning task.

What trips people up

The losers keep running. WhenAny cancels nothing, and an exception in a task you never looked at goes unobserved. .NET 9 added Task.WhenEach, which hands you each task as it completes; it is consumed with the async streams this course covers later.

Example

Example · csharp
// Both calls start here, before anything is awaited.
Task<string> slow = LabelAsync("slow", 400);
Task<string> quick = LabelAsync("quick", 50);

// WhenAny completes as soon as the first of them completes. Its result
// is the winning Task<string>, so the value is read off that task.
Task<string> winner = await Task.WhenAny(slow, quick);
Console.WriteLine($"first done: {await winner}");

// WhenAll waits for every task and returns the results in the order the
// tasks were passed in, not the order they finished.
string[] all = await Task.WhenAll(slow, quick);
Console.WriteLine($"all done: {string.Join(", ", all)}");

static async Task<string> LabelAsync(string name, int milliseconds)
{
    await Task.Delay(milliseconds);
    return name;
}

// Output:
// first done: quick
// all done: slow, quick

When to use it

  • Calling three independent services for one page and waiting once for all of them.
  • Racing a slow primary source against a cached fallback and taking whichever answers first.
  • Importing a batch of records concurrently and reporting every row that failed.

More examples

Fanning out over a collection

This is the everyday use: one call per item, all in flight at once. The ToArray is the part people leave out, and the bug it causes is subtle, because a deferred LINQ query re-runs every time it is enumerated. Note also that the output keeps the input order, which is what makes WhenAll safe to zip back against the ids.

Example · csharp
int[] ids = [3, 1, 2];

// Select is lazy, so ToArray is what actually calls LoadAsync for each
// id and starts all three. Without it the tasks would be created during
// enumeration, and enumerating twice would start the work twice.
Task<string>[] loads = ids.Select(LoadAsync).ToArray();

string[] names = await Task.WhenAll(loads);
Console.WriteLine(string.Join(" | ", names));

static async Task<string> LoadAsync(int id)
{
    await Task.Delay(50);
    return $"item-{id}";
}

// Output:
// item-3 | item-1 | item-2

Seeing every failure, not just the first

Keep the WhenAll task in a variable whenever more than one failure is possible, because awaiting it loses all but the first exception. This is the shape to use in a batch import or a fan-out to several services, where a log line naming only one of four failures sends you looking in the wrong place.

Example · csharp
Task<int> first = FailAsync("disk full");
Task<int> second = FailAsync("timeout");

Task<int[]> all = Task.WhenAll(first, second);

try
{
    await all;
}
catch (Exception ex)
{
    // await rethrows only the first exception.
    Console.WriteLine($"await threw: {ex.Message}");

    // The combined task still holds all of them.
    foreach (Exception inner in all.Exception!.InnerExceptions)
    {
        Console.WriteLine($"inner: {inner.Message}");
    }
}

static async Task<int> FailAsync(string reason)
{
    await Task.Delay(10);
    throw new InvalidOperationException(reason);
}

// Output:
// await threw: disk full
// inner: disk full
// inner: timeout

Starting everything before awaiting anything

The two loops print the same thing and one takes three times as long, which is why this mistake survives code review. Look for await inside a foreach over independent items: if the body does not need the previous result, collect the tasks and await them together. Keep the sequential form when the calls must be ordered, or when the remote service would rate-limit a burst.

Example · csharp
// Sequential by accident: the await inside the loop means call two does
// not start until call one has returned. Three calls, three waits.
List<string> sequential = [];

foreach (int id in (int[])[1, 2, 3])
{
    sequential.Add(await FetchAsync(id));
}

Console.WriteLine($"sequential: {string.Join(",", sequential)}");

// Concurrent: every call is started first, then awaited once.
Task<string>[] started =
[
    FetchAsync(1),
    FetchAsync(2),
    FetchAsync(3)
];

string[] concurrent = await Task.WhenAll(started);
Console.WriteLine($"concurrent: {string.Join(",", concurrent)}");

static async Task<string> FetchAsync(int id)
{
    await Task.Delay(100);
    return $"r{id}";
}

// Output:
// sequential: r1,r2,r3
// concurrent: r1,r2,r3

Discussion

  • Be the first to comment on this lesson.