Parallel.ForEachAsync in .NET 6+, MaxDegreeOfParallelism defaults, cancellation and exception behaviour, collecting results, Parallel.ForAsync and alternatives.
Parallel.ForEachAsync (.NET 6+) runs an async body for each item in a sequence (IEnumerable<T> or IAsyncEnumerable<T>) with a bounded number of concurrent operations, set by ParallelOptions.MaxDegreeOfParallelism (default: processor count). The body receives a CancellationToken that is cancelled if any iteration throws, so it stops early on failure. It is the simplest way to say "process these 10,000 items, at most 20 at a time". Compared with Task.WhenAll it limits concurrency; compared with SemaphoreSlim it needs less code. It does not return results, so write to a concurrent collection or a preallocated array.
"
Parallel.ForEachAsync(.NET 6+) runs an async body for each item of anIEnumerable<T>orIAsyncEnumerable<T>with bounded concurrency: at mostParallelOptions.MaxDegreeOfParallelismbodies in flight. ForForEachAsync, the default is the processor count, which is rarely the right number for I/O, so I always set it explicitly from what the dependency can handle. Items are pulled lazily as workers free up, so async-stream sources are streamed. Each body gets aCancellationTokenthat's cancelled if any body throws: the loop stops starting new items and the returned task faults. Compared with the alternatives:Task.WhenAll(items.Select(...))starts everything at once, so there's no limit.SemaphoreSlim+WhenAlllimits concurrency but needs more code and doesn't stop on the first failure.Parallel.ForEachAsyncgives bounded fan-out in a few lines, but returns no results, so I write into a preallocated array by index or a concurrent collection. And never use the synchronousParallel.ForEachwith an async lambda: that'sasync void."
await Parallel.ForEachAsync(
customerIds,
new ParallelOptions { MaxDegreeOfParallelism = 20, CancellationToken = ct },
async (id, token) => await SyncCustomerAsync(id, token));
| Aspect | Behaviour |
|---|---|
| Concurrency | At most MaxDegreeOfParallelism bodies at once |
Default degree (ForEachAsync) |
Environment.ProcessorCount |
| Source consumption | Pulled lazily; IAsyncEnumerable<T> sources are streamed |
| First exception | Cancels the token passed to bodies, stops starting new items, faults the returned task |
| Exceptions | await rethrows the first; the task's Exception.InnerExceptions has all |
| External cancellation | ParallelOptions.CancellationToken → OperationCanceledException |
| Results | None returned: collect yourself |
| Ordering | Completion order ≠ input order |
| Approach | Limits concurrency | Results | Stops on first error | Code |
|---|---|---|---|---|
Task.WhenAll(items.Select(DoAsync)) |
No | Yes (array, in order) | No: waits for all | Smallest |
SemaphoreSlim + WhenAll |
Yes | Yes | No | Medium |
Parallel.ForEachAsync |
Yes | Manual | Yes | Small |
Channel<T> + N consumers |
Yes | Manual | Manual | Larger; long-running |
| TPL Dataflow | Yes, per stage | Via blocks | Configurable | Larger; multi-stage |
Parallel.ForEach with async lambda |
❌ | ❌ | ❌ | Bug: async void, loop ends immediately |
| Work in the body | Degree |
|---|---|
| Calls to one external API | Its rate/connection limit (e.g. 8–64), measured |
| Database writes | Below the connection pool size, leaving room for other traffic |
| CPU-heavy body | About core count |
| Mixed | Increase until latency or error rate rises, then back off |
| Need | Technique |
|---|---|
| Ordered results | Preallocate T[], iterate items.Select((x, i) => (x, i)), write results[i] |
| Unordered results | ConcurrentBag<T> / ConcurrentQueue<T> |
| Keyed results | ConcurrentDictionary<TKey, TValue> |
| Partial success | Catch inside the body and record failures |
Our [data migration] synced [80,000] customer records to a partner API with await Task.WhenAll(ids.Select(SyncAsync)). It started every call at once: thousands of simultaneous connections, socket exhaustion, a wall of HTTP 429s, and the run failed after [about 10 minutes]. I switched to Parallel.ForEachAsync with MaxDegreeOfParallelism = 16 (the partner's documented concurrency limit), added the standard resilience handler for retries with backoff, and caught per-item failures into a ConcurrentBag so one bad record didn't abort the batch. The run finished in [about 40 minutes] with no throttling errors and produced a failure report for [12] records. We turned this into a template for batch scripts.
Checks practical async concurrency control.
Green flags
Task.WhenAll and SemaphoreSlim.Red flags
Parallel.ForEach (sync) with async lambdas.WhenAll over huge lists.Parallel.ForEachAsync(source, options, body):
create linked CTS (options.CancellationToken)
start N worker tasks (N = MaxDegreeOfParallelism)
each worker:
loop:
lock source enumerator; MoveNext / MoveNextAsync; take item
await body(item, linkedToken)
on exception: record it, cancel linked CTS, stop
await all workers; throw aggregated exceptions if any
The workers share one enumerator, which is why items are pulled lazily and why IAsyncEnumerable<T> sources work naturally.
Parallel.ForEach + async lambda bugParallel.ForEach(ids, async id => await SyncAsync(id)); // compiles: lambda is Action<int> -> async void
Console.WriteLine("Done"); // prints immediately; work still running; exceptions crash
Parallel.ForEach waits for the synchronous part of each delegate only. Use Parallel.ForEachAsync.
var failures = new ConcurrentQueue<(int Id, Exception Error)>();
await Parallel.ForEachAsync(ids, new ParallelOptions { MaxDegreeOfParallelism = 16, CancellationToken = ct },
async (id, token) =>
{
try { await SyncAsync(id, token); }
catch (Exception ex) when (ex is not OperationCanceledException) { failures.Enqueue((id, ex)); }
});
if (!failures.IsEmpty) await WriteFailureReportAsync(failures, ct);
var results = new Customer[ids.Length];
await Parallel.ForEachAsync(Enumerable.Range(0, ids.Length), opts, async (i, token) =>
results[i] = await LoadAsync(ids[i], token)); // each index written by exactly one worker
| API | Version | Use |
|---|---|---|
Parallel.ForEachAsync |
.NET 6 | Bounded async fan-out over sequences |
Parallel.ForAsync |
.NET 8 | Bounded async loop over an integer range |
Task.WhenEach |
.NET 9 | Process results of already-started tasks in completion order |
SemaphoreSlim.WaitAsync |
.NET 4.5 | Shared limit across unrelated code paths |
Log or meter: items processed, failures, per-item latency, and in-flight count. A degree set too high shows up as rising dependency latency and 429/503s; too low shows up as idle dependency capacity and long batch times.
Parallel.ForEach(ids, async id => ...) and observe "Done" printing immediately.Task.WhenAll against a local test server with a connection limit and watch errors.IAsyncEnumerable<int> source that yields slowly and confirm items are processed as they arrive.using System.Collections.Concurrent;
using System.Diagnostics;
int[] ids = Enumerable.Range(1, 200).ToArray();
var results = new string[ids.Length];
var failures = new ConcurrentBag<(int Id, string Error)>();
var sw = Stopwatch.StartNew();
await Parallel.ForEachAsync(
ids.Select((id, index) => (id, index)),
new ParallelOptions { MaxDegreeOfParallelism = 20 },
async (item, token) =>
{
try
{
results[item.index] = await FetchAsync(item.id, token); // keeps input order
}
catch (HttpRequestException ex)
{
failures.Add((item.id, ex.Message)); // continue on per-item failure
}
});
Console.WriteLine($"Done in {sw.ElapsedMilliseconds} ms, failures: {failures.Count}");
Console.WriteLine(results[0]);
// .NET 8+: integer ranges
await Parallel.ForAsync(0, 5, async (i, token) => await Task.Delay(10, token));
static async Task<string> FetchAsync(int id, CancellationToken ct)
{
await Task.Delay(100, ct); // stands in for an HTTP call
if (id % 50 == 0) throw new HttpRequestException($"Partner rejected {id}");
return $"customer-{id}";
}
What happens: MaxDegreeOfParallelism not set.
Why it is wrong: defaults to processor count, usually unrelated to what the dependency can handle.
Do instead: set it explicitly from the dependency's capacity, then measure.
Task.WhenAll over thousands of callsWhat happens: Task.WhenAll(ids.Select(CallAsync)) for 80,000 ids.
Why it is wrong: everything starts at once: socket exhaustion, rate limits, overload.
Do instead: Parallel.ForEachAsync or a SemaphoreSlim limit.
Parallel.ForEach with async lambdasWhat happens: Parallel.ForEach(items, async x => await ...).
Why it is wrong: async void: the loop doesn't wait; exceptions crash the process.
Do instead: Parallel.ForEachAsync.
List<T> from the bodyWhat happens: results.Add(item) from concurrent bodies.
Why it is wrong: concurrent writes corrupt the list.
Do instead: indexed array or concurrent collection.
What happens: one bad record aborts the batch. Why it is wrong: the first exception cancels the loop. Do instead: catch inside the body when partial success is acceptable; report failures.
What happens: bodies call APIs without the token parameter.
Why it is wrong: after a failure or cancellation, running bodies keep working.
Do instead: pass the body's token to every call.
What happens: a request triggers thousands of parallel calls. Why it is wrong: one request monopolises resources; timeouts and starvation for others. Do instead: move large batches to background jobs; keep request fan-out small.
What happens: each body retries forever on failure. Why it is wrong: multiplies load on a struggling dependency. Do instead: bounded retries with backoff (resilience handler) and a circuit breaker.
What happens: results are appended as completed and assumed ordered. Why it is wrong: completion order varies. Do instead: write by index.
What happens: failures are swallowed and the batch reports success. Why it is wrong: silent data loss. Do instead: collect failures and produce a report/metric.
Parallel.ForEachAsync is my default for bounded fan-out over a known set of items. The real design work is picking the degree from the dependency's capacity and deciding whether one failure should stop the batch.
| Need | Choice |
|---|---|
| Bounded concurrent I/O over a list | Parallel.ForEachAsync |
| Small fixed set, want all results | Task.WhenAll |
| Shared limit across code paths | SemaphoreSlim |
| Long-running, continuous work | Channel<T> with consumers |
| Multi-stage pipeline | TPL Dataflow |
SemaphoreSlim is a lightweight, in-process counting semaphore that supports WaitAsync, so it works with async/await. With a count of 1 it is an async lock: await gate.WaitAsync(ct); try { ... } finally { gate.Release(); }. With a count of N it limits how many async operations run at once, for example ten concurrent calls to a partner API. It is not re-entrant and has no owner, so calling it again from inside the protected section deadlocks, and every successful wait must be paired with exactly one Release in finally.
PLINQ (Parallel LINQ) runs LINQ to Objects queries on multiple cores: source.AsParallel().Where(...).Select(...) partitions the input and processes partitions on ThreadPool threads, then merges results. It helps for CPU-bound work on large in-memory collections where each item is expensive. It makes things worse for small inputs, cheap per-item work, I/O-bound work (threads block), lambdas with shared mutable state, and server code where all cores are already busy with requests. Results are unordered unless you add AsOrdered(), and exceptions arrive as an AggregateException.
TPL Dataflow (System.Threading.Tasks.Dataflow, a NuGet package) is a library of blocks you link into a pipeline: BufferBlock<T>, TransformBlock<TIn,TOut>, TransformManyBlock, BatchBlock<T>, ActionBlock<T>, BroadcastBlock<T> and join blocks. Each block has its own buffering, parallelism (MaxDegreeOfParallelism) and capacity (BoundedCapacity), and LinkTo with PropagateCompletion = true flows completion and errors through. I choose it for multi-stage pipelines with different parallelism per stage, batching or fan-out, where writing the same with channels would need a lot of plumbing. For a single stage, Parallel.ForEachAsync or a channel is simpler.
How to tell it (STAR)
Our [migration script] started all 80,000 partner calls at once and hit rate limits. I replaced it with `Parallel.ForEachAsync` at a degree of [16], added retries and recorded per-item failures. The run finished in [40 minutes] with no throttling errors.
Last updated 31 Oct 2026.