DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

The WhenAny Drain Loop Can Finally Retire

I approved a PR last week that fanned out six HTTP calls and drained them with the same loop I've been writing since about 2015: dump the tasks into a List, Task.WhenAny in a while, Remove the winner, process, repeat. Approved it without blinking. An hour later it hit me that Task.WhenEach has been in the runtime since .NET 9, it exists precisely to delete that loop, and neither the author nor I had ever actually switched. So I did what I do with old habits now: put a stopwatch on it.

The loop in question

You've written this. Possibly today:

var pending = new List<Task<int>>(tasks);
while (pending.Count > 0)
{
    var done = await Task.WhenAny(pending);
    pending.Remove(done);
    sum += await done;
}
Enter fullscreen mode Exit fullscreen mode

It exists because Task.WhenAll has one blunt rule: nothing is usable until everything is done. Fan out five service calls for a dashboard and the 150 ms profile call renders exactly when the 1,200 ms legacy pricing call does. The drain loop fixes that by handing you results in completion order. So does WhenEach, in three lines:

static Task<string>[] FanOut() =>
[
    Call("profile", 150),
    Call("orders", 300),
    Call("recs", 500),
    Call("inventory", 800),
    Call("legacy-pricing", 1_200),
];

var all = await Task.WhenAll(FanOut());          // one big wait

await foreach (var t in Task.WhenEach(FanOut())) // results as they land
    Console.WriteLine($"{await t} usable at {sw.ElapsedMilliseconds} ms");
Enter fullscreen mode Exit fullscreen mode
WhenAll : all 5 results usable at 1204 ms
WhenEach: profile        usable at   150 ms
WhenEach: orders         usable at   298 ms
WhenEach: recs           usable at   499 ms
WhenEach: inventory      usable at   799 ms
WhenEach: legacy-pricing usable at  1198 ms
Enter fullscreen mode Exit fullscreen mode

Those delays are simulated Task.Delay calls, so of course the timeline looks like that. The point is the shape, not the milliseconds: WhenEach returns an IAsyncEnumerable<Task<T>> that yields each task the moment it completes. Same shape the drain loop gave us. Which is why everyone wrote the drain loop.

What the old loop actually costs

Here's the part I'd never measured. Every iteration calls WhenAny with the surviving list. WhenAny copies that list into a fresh array and hooks a continuation onto every task still pending. Next iteration does it all again for n−1 tasks, then n−2. Drain 5,000 tasks and you've registered roughly 12.5 million continuations, and List.Remove is a linear scan on top. The loop is quietly quadratic.

I drained an identical batch of 5,000 tasks (random 1–50 ms delays, fixed seed so every strategy sees the same work) three ways. .NET 10, Release build, small Linux cloud container, and the run shown is typical of several:

WhenAll (baseline)   elapsed  57 ms | cpu  22 ms | allocated   1.4 MB
WhenEach             elapsed  50 ms | cpu  18 ms | allocated   1.3 MB
WhenAny drain loop   elapsed 274 ms | cpu 357 ms | allocated 110.4 MB
Enter fullscreen mode Exit fullscreen mode

The milliseconds wobble between runs; the ratios don't. Not a lab, but you don't need a lab to see it. The drain loop allocated about 85x more than WhenEach and burned more CPU than the whole batch needed to complete. WhenEach costs the same as WhenAll: one continuation per task, registered once, feeding an internal queue. Completion-order streaming for the price of a batch wait.

At 5,000 tasks the old loop is embarrassing. At six tasks it's irrelevant, and I want to be fair to my 2015 self here. But "how many tasks will this loop see in production" is exactly the kind of number nobody picks on purpose. Mine was six in the PR. It was 5,000 in the export job the PR was copied from.

The failure timing surprised me

One more thing I checked because I genuinely wasn't sure: when does a faulted task become visible? Task.WhenAll doesn't throw the moment something fails. It waits for every task to finish, then throws, and the successful results go down with the same await. With one of four calls exploding at 400 ms:

WhenAll : learned "recs exploded" at 802 ms; the three good results are gone
WhenEach: profile   ok at  150 ms
WhenEach: orders    ok at  298 ms
WhenEach: "recs exploded" seen at  403 ms; other calls unaffected
WhenEach: inventory ok at  797 ms
Enter fullscreen mode Exit fullscreen mode

With WhenEach, each element is an already-completed task you inspect yourself:

await foreach (var t in Task.WhenEach(MixedFanOut()))
{
    if (t.IsCompletedSuccessfully) Render(t.Result);
    else ShowFallbackTile(t.Exception);
}
Enter fullscreen mode Exit fullscreen mode

The failure surfaced 400 ms earlier and the other three results stayed usable. For dashboards, scatter-gather aggregators, anything that can render partial results, I think this is the real win. The allocation numbers are just the tiebreaker.

When I'd keep the old ways

If you need every result before you can do anything with any of them, WhenAll is simpler and reads better. Keep it; WhenEach buys you nothing there. If you're on .NET 8, WhenEach doesn't exist yet, and for a handful of tasks the drain loop remains a perfectly honest pattern. And WhenEach yields in completion order, so if you need results in submission order you'll be re-sorting afterward, at which point WhenAll was probably fine all along.

Stated opinion: the WhenAny drain loop might be the most copy-pasted concurrency snippet in C# history, and I'd be glad to never review it again. It had a good decade. It can retire.

Full runnable sample: https://github.com/ssukhpinder/dev-to-code-samples/tree/main/008-task-wheneach-drain

Where's the drain loop still hiding in your codebase? I found two in mine while writing this. Tell me below.

— still putting stopwatches on habits nobody asked me to

Top comments (0)