Composing async work: WhenAll WhenAny and WhenEach

 
 
  • Gérald Barré

This post is part of the series 'Async/await in C#'. Be sure to check out the rest of the blog posts of the series!

Everything so far has been about a single operation. This post is about running several, which is where async stops being a syntax feature and starts changing what your program does.

The mechanics are three combinators and one throttle, and each has an exception behaviour that is worth knowing before you meet it in production.

#Starting is what makes them concurrent

Before the combinators, the thing they depend on. An async method starts running when you call it, not when you await it. That is what makes concurrency possible:

C#
// Concurrent: every operation is started, then all are awaited.
var tasks = urls.Select(url => FetchAsync(url)).ToArray();
var results = await Task.WhenAll(tasks);

// Sequential: each is started and finished before the next begins.
foreach (var url in urls)
{
    results.Add(await FetchAsync(url));
}

The ToArray() is load bearing. Select is lazy, so without it the sequence is enumerated inside WhenAll, and although that still works it is a habit worth keeping: materialise, then await.

SequentialWhenAll
Wall clocksum of allslowest one
Load on the dependencyone at a timeall at once
Failurestops at the firstall run, all reported

The benchmark, 100 operations of 5 ms each:

MethodMeanRatioAllocated
Sequential561.067 ms1.0016.56 KB
WhenAll5.657 ms0.0117.44 KB
WhenEach5.711 ms0.0118.05 KB
ParallelForEachAsync72.893 ms0.1327.21 KB
SemaphoreThrottled73.031 ms0.1335.62 KB
Benchmark project, measured with BenchmarkDotNet

A hundredfold improvement, for less than a kilobyte more. That is the case for concurrency in one line.

The throttled versions take 13 times longer than WhenAll, which is exactly right: they allow 8 at a time rather than 100, so they take 100/8 rounds. You do not pay that for nothing, and the section on throttling explains what you buy.

#WhenAll

Waits for everything. The exception behaviour is the part to internalise:

C#
var task = Task.WhenAll(
    Task.FromException(new InvalidOperationException("first")),
    Task.FromException(new ArgumentException("second")));

// await rethrows only the first
await Assert.ThrowsAsync<InvalidOperationException>(() => task);

// but all of them are on the task
Assert.Equal(2, task.Exception.InnerExceptions.Count);

Every task runs to completion, even after one fails. WhenAll does not cancel the others; it has no way to.

All exceptions are collected, but await unwraps to the first. If you need them all, keep the task and read task.Exception:

C#
var task = Task.WhenAll(tasks);
try
{
    await task;
}
catch
{
    foreach (var exception in task.Exception!.InnerExceptions)
    {
        logger.LogError(exception, "Operation failed");
    }

    throw;
}

This is the same trap as getting all exceptions from Parallel.ForEachAsync, and it is why "only one of my five failures was logged" is such a common report.

#WhenAny

Returns as soon as one task finishes. Useful for "first response wins" and for racing an operation against a timeout, and it has a sharp edge:

C#
var winner = await Task.WhenAny(fast, slowAndFailing);
// slowAndFailing is still running, and its exception is unobserved

The losers keep running. They hold their connections, their memory and their thread pool work items, and nobody is watching their results. If a loser fails, the exception goes nowhere.

So WhenAny needs a cleanup plan:

  • Pass a CancellationToken to every operation and cancel it once you have a winner. This is the good answer.
  • Or at minimum observe the losers so their failures are logged rather than silently dropped.

Since .NET 6, racing against a timeout does not need WhenAny at all. WaitAsync says it more directly:

C#
// Instead of Task.WhenAny(work, Task.Delay(timeout))
await work.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);

#WhenEach

Added in .NET 9, and it fills a real gap. Before it, processing results as they arrived meant a loop over WhenAny with a list you removed from, which is O(n²) and easy to get wrong:

C#
await foreach (var task in Task.WhenEach(tasks))
{
    var result = await task;
    Process(result);
}

Tasks come out in completion order, not in the order you passed them. The sample checks that a fast operation is yielded before a slow one started earlier.

The inner await task is not redundant. WhenEach hands you the completed Task, and awaiting it is what produces the value or throws its exception. That also means you can handle each failure individually and keep going, which neither WhenAll nor WhenAny lets you do.

Use it when results are worth processing as they arrive rather than in a batch at the end: streaming to a client, updating a UI, or writing to a pipeline.

#Throttling

WhenAll over ten thousand items starts ten thousand operations at once. Your database will notice. Two ways to bound it:

Parallel.ForEachAsync, which is the right default:

C#
await Parallel.ForEachAsync(items, new ParallelOptions
{
    MaxDegreeOfParallelism = 8,
    CancellationToken = cancellationToken,
}, async (item, cancellationToken) => await ProcessAsync(item, cancellationToken));

It handles the concurrency limit, threads the token through, and works on an IAsyncEnumerable<T> as well as an IEnumerable<T>, so you can throttle a stream without materialising it.

SemaphoreSlim, when you need the limit shared across code that is not one loop:

C#
await semaphore.WaitAsync(cancellationToken);
try
{
    await ProcessAsync(item, cancellationToken);
}
finally
{
    semaphore.Release();
}

The finally is mandatory. A Release that is skipped on an exception path permanently reduces the limit, and a few of those turn into a hang that looks like a deadlock.

In the benchmark the two are indistinguishable in time, with the semaphore allocating about 30% more. Prefer Parallel.ForEachAsync unless you need a semaphore's flexibility.

#What to take away

  • Concurrency comes from starting the operations, not from the combinator. Materialise the sequence first.
  • WhenAll runs everything even after a failure and collects all exceptions, but await rethrows only the first. Read task.Exception when you need them all.
  • WhenAny leaves the losers running and their exceptions unobserved. Cancel them, or observe them.
  • Use WaitAsync rather than racing against Task.Delay.
  • WhenEach (.NET 9) yields tasks in completion order and lets you handle each failure independently.
  • Throttle with Parallel.ForEachAsync. Use SemaphoreSlim when the limit spans more than one loop, and release in a finally.

The next post covers what happens when concurrent operations need to touch the same state: locking in async code, and why lock and await do not mix.

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?