When you need to handle many items at the same time in .NET, two common options are Parallel.ForEachAsync and Task.WhenAll. Both run tasks in parallel, but they manage concurrency differently — and that difference can greatly affect performance.
Let’s look at how each one works and compare them with a real-world example.
The source code can be downloaded from GitHub. Tools that I have used
1. VS 2026 Insider
2. .NET 8.0
3. Console App
Parallel.ForEachAsync: Controlled Parallelism
Parallel.ForEachAsync (introduced in .NET 6) provides built-in throttling via MaxDegreeOfParallelism. It schedules work intelligently, without creating a separate task for every item.
Example
await Parallel.ForEachAsync(data, new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount
}, async (item, token) =>
{
await ProcessItemAsync(item);
});Key Idea
Runs only a limited number of iterations in parallel — typically one per CPU core.
Task.WhenAll: Fire-and-Wait for All Tasks
Task.WhenAll simply runs all tasks at once and waits until every one of them completes.
Example
var tasks = data.Select(item => ProcessItemAsync(item));
await Task.WhenAll(tasks);Key Idea
Starts one task per item, no throttling — great for small workloads, but dangerous at scale.
Custom Throttled: Task.WhenAll – using SemaphoreSlim to limit concurrency for async workloads
static async Task ForEachAsync<T>(
IEnumerable<T> source,
int maxDegreeOfParallelism,
Func<T, Task> action)
{
using var semaphore = new SemaphoreSlim(maxDegreeOfParallelism);
var tasks = source.Select(async item =>
{
await semaphore.WaitAsync();
try
{
await action(item);
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
}
//usage:
Usage:
var boundedTime = await MeasureTimeAsync(async () =>
{
await ForEachAsync(data, maxDegreeOfParallelism: 50, SimulateWorkAsync);
});Observations from Your Benchmark


Join the conversation! Your thoughts help the community grow.