Task.WhenAll takes several Tasks and returns one Task that completes when the last child finishes. That is I/O concurrency (many waits in flight). Parallel.ForEach is CPU parallelism (use the cores). Task.WaitAll is WhenAll’s blocking cousin — same clock time, a parked ThreadPool worker.
Three 2-second HTTP calls
Sequential await: A ████ B ████ C ████ ≈ 6s clock
WhenAll: A ████
B ████
C ████ ≈ 2s clock
WaitAll: same 2s clock, but a ThreadPool worker sits idle the whole time
async/await does not mean “run on many cores.” It means “do not hold a worker while I/O is in flight.”
New to this → stay here. Merging a PR → wrong vs right. On-call / interview → cap of 50 · Parallel.ForEachAsync · if an interviewer asks.
Terms used here: 429 = HTTP Too Many Requests (partner throttled you). rps = requests per second. AggregateException = wrapper WhenAll uses when more than one child faulted; await usually unwraps to the first. Parallel.ForEachAsync = .NET 6+.
Smallest example
var a = Task.Delay(1000, ct);
var b = Task.Delay(1000, ct);
var c = Task.Delay(1000, ct);
await Task.WhenAll(a, b, c); // ~1 second, not ~3
I/O concurrency vs CPU parallelism
Comparison: when to use WhenAll versus Parallel.ForEach.
| Work | Tool |
|---|---|
| Independent HTTP, SQL, blob I/O | await Task.WhenAll (cap with SemaphoreSlim) |
| CPU-bound loops (hash, image resize) | Parallel.ForEach / Parallel.ForEachAsync with MaxDegreeOfParallelism ≈ cores |
| Three calls, merge DTO | WhenAll — no semaphore needed |
Wrong vs right
I would reject unbounded WhenAll of 10,000 GetAsync calls, WaitAll on an API, and two EF queries on one DbContext in WhenAll.
await Task.WhenAll(a, b, c); // Right — worker free during I/O
Task.WaitAll(a, b, c); // Wrong on ASP.NET — [starvation](/blog/csharp-threadpool-starvation-sync-over-async)
// Wrong — DbContext is not thread-safe
var openTask = db.Orders.Where(o => o.Open).ToListAsync(ct);
var closedTask = db.Orders.Where(o => !o.Open).ToListAsync(ct);
await Task.WhenAll(openTask, closedTask); // race
Use sequential awaits, two scopes, or one query.
// Wrong — Parallel.ForEach + .Result
Parallel.ForEach(npis, npi =>
{
var row = _npi.GetAsync(npi).Result;
});
Pre-.NET 6, people did this because ForEach had no async body. Now you have ForEachAsync.
Comparison: WhenAll versus WaitAll versus sequential await versus WhenAny.
| API | Blocks thread? | Use on ASP.NET Core |
|---|---|---|
await Task.WhenAll | No | Yes |
Task.WaitAll | Yes | No |
task.Wait() / .Result | Yes | No |
sequential await | No | Yes, when order or one DbContext requires it |
await Task.WhenAny | No | First completion, timeouts, “any replica” |
WhenAll waits for all, then throws. await unwraps to the first exception. One failure does not cancel the siblings unless you cancel a token yourself.
WhenAll with a cap of 50
SemaphoreSlim is a handful of tickets — here, 50 HTTP calls in flight. Full story: SemaphoreSlim WaitAsync.
public async Task<IReadOnlyList<NpiResult>> EnrichAsync(
IReadOnlyList<string> npis,
CancellationToken ct)
{
using var gate = new SemaphoreSlim(50, 50);
var tasks = npis.Select(async npi =>
{
await gate.WaitAsync(ct);
try
{
return await _npi.GetAsync(npi, ct);
}
finally
{
gate.Release();
}
});
return await Task.WhenAll(tasks);
}
Select still creates 10,000 task state machines. The gate keeps 50 HTTP calls in flight. I still prefer WhenAll when I need results in input order. ConcurrentBag does not.
Parallel.ForEachAsync
await Parallel.ForEachAsync(
npis,
new ParallelOptions { MaxDegreeOfParallelism = 50, CancellationToken = ct },
async (npi, token) =>
{
var row = await _npi.GetAsync(npi, token);
results.Add(row);
});
MaxDegreeOfParallelism defaulting to ProcessorCount (8) makes I/O look “mysteriously slow” compared to WhenAll of 1,000. You throttled to 8.
A partner dump with 10,000 NPIs and unbounded WhenAll opened thousands of sockets and tripped 429s. Staging with 20 ids was instant. Unbounded WhenAll is not a throttle.
When sync / Parallel belongs on an API
Rare: you must hash a payload on the request. Then Task.Run + a bound, or a Channel worker. Do not Parallel.ForEach 10,000 items on the request thread and hope.
Common mistakes
- WhenAll without a cap against a partner SLA of 10 rps
- Sharing
HttpClientincorrectly — IHttpClientFactory - Ignoring cancellation so Angular leaving does not stop the remaining calls
What this is not
Outbound reusable lock: SemaphoreSlim. Request-path async: async await in ASP.NET Core. Topic map: async & threading hub.
If an interviewer asks
WhenAll vs Parallel.ForEach; WhenAll vs WaitAll; three 2-second calls; WhenAll on one DbContext.
Strong answer: I/O → WhenAll + cap. CPU → Parallel. Never one context in parallel.
. Bring the loop and the partner’s rate limit.