Before .NET 6, running async work over a collection with controlled concurrency required manual orchestration — SemaphoreSlim guards, Task.WhenAll over batches, or third-party libraries. Parallel.ForEachAsync arrived in .NET 6 to solve this cleanly, and it has quickly become the go-to API for concurrent async processing.
The Problem It Solves
Consider downloading 500 images. Running them sequentially is slow. Firing all 500 at once overwhelms the server and your sockets. You need something in between:
// Naive approach — fires all requests simultaneously
var tasks = urls.Select(url => httpClient.GetByteArrayAsync(url));
var results = await Task.WhenAll(tasks); // 500 concurrent requests!
Using Parallel.ForEachAsync
Parallel.ForEachAsync takes an IEnumerable<T> or IAsyncEnumerable<T>, a ParallelOptions for controlling concurrency, and an async body:
var urls = GetImageUrls(); // IEnumerable<string>
await Parallel.ForEachAsync(urls, new ParallelOptions
{
MaxDegreeOfParallelism = 10,
CancellationToken = cancellationToken
},
async (url, ct) =>
{
var bytes = await httpClient.GetByteArrayAsync(url, ct);
var fileName = Path.GetFileName(new Uri(url).AbsolutePath);
await File.WriteAllBytesAsync(Path.Combine(outputDir, fileName), bytes, ct);
});
At most 10 downloads run concurrently. As each one completes, the next URL is picked up. Cancellation propagates naturally through the CancellationToken passed to both the options and the body.
MaxDegreeOfParallelism
If you omit MaxDegreeOfParallelism, the default is Environment.ProcessorCount. This makes sense for CPU-bound work but is often wrong for I/O-bound operations. A machine with 8 cores would only allow 8 concurrent HTTP requests — far below what the network can handle.
For I/O-bound work, set the degree of parallelism based on the downstream resource's capacity:
// I/O-bound: match the target service's rate limits
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 20 // External API allows 20 concurrent requests
};
// CPU-bound: use processor count (the default)
var options = new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount
};
Working with IAsyncEnumerable
Parallel.ForEachAsync accepts IAsyncEnumerable<T> sources directly, making it ideal for streaming scenarios:
async IAsyncEnumerable<Order> GetPendingOrdersAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var order in _dbContext.Orders
.Where(o => o.Status == OrderStatus.Pending)
.AsAsyncEnumerable()
.WithCancellation(ct))
{
yield return order;
}
}
// Process orders as they stream from the database
await Parallel.ForEachAsync(
GetPendingOrdersAsync(cancellationToken),
new ParallelOptions { MaxDegreeOfParallelism = 5 },
async (order, ct) =>
{
await ProcessOrderAsync(order, ct);
});
The orders are fetched lazily from the database and processed concurrently as they arrive. There is no need to materialise the entire collection into memory first.
Error Handling
If any iteration throws, Parallel.ForEachAsync cancels remaining work and throws an AggregateException (or the original exception if only one failed). This differs from Task.WhenAll, which waits for all tasks to complete even after a failure:
try
{
await Parallel.ForEachAsync(items, options, async (item, ct) =>
{
await ProcessAsync(item, ct);
});
}
catch (OperationCanceledException)
{
// Cancellation was requested
}
catch (Exception ex)
{
// One or more iterations failed
_logger.LogError(ex, "Parallel processing failed");
}
When Not to Use It
Parallel.ForEachAsync is not a replacement for all concurrency patterns. It does not return results — if you need to collect outputs, you will need a thread-safe collection:
var results = new ConcurrentBag<ProcessingResult>();
await Parallel.ForEachAsync(items, options, async (item, ct) =>
{
var result = await ProcessAsync(item, ct);
results.Add(result);
});
return results.ToList();
For scenarios where you need to aggregate results, Task.WhenAll with a projected task list may be cleaner. For complex pipelines with multiple stages, System.Threading.Channels or TPL Dataflow give you more control.
Comparison with Manual SemaphoreSlim
Before Parallel.ForEachAsync, the standard pattern looked like this:
using var semaphore = new SemaphoreSlim(10);
var tasks = urls.Select(async url =>
{
await semaphore.WaitAsync(cancellationToken);
try
{
await DownloadAsync(url, cancellationToken);
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
Parallel.ForEachAsync is functionally equivalent but handles the concurrency limiting, cancellation, and exception aggregation internally. It is less code, harder to get wrong, and expresses intent more clearly.