📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How does Parallel.ForEachAsync work, and how does it compare with Task.WhenAll and SemaphoreSlim for concurrent I/O?

Parallel.ForEachAsync in .NET 6+, MaxDegreeOfParallelism defaults, cancellation and exception behaviour, collecting results, Parallel.ForAsync and alternatives.

Mid-Level Coding Intermediate async concurrency parallel-foreachasync throttling

🎯 Key points

  • Parallel.ForEachAsync (.NET 6+) runs async bodies with bounded concurrency
  • Default MaxDegreeOfParallelism is the processor count; set it for I/O
  • Accepts IEnumerable<T> and IAsyncEnumerable<T> sources
  • The first exception cancels the loop and faults the task
  • It returns no results; use an indexed array or concurrent collection
  • Task.WhenAll does not limit concurrency; SemaphoreSlim needs more code
  • .NET 8 adds Parallel.ForAsync for ranges

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"Parallel.ForEachAsync (.NET 6+) runs an async body for each item of an IEnumerable<T> or IAsyncEnumerable<T> with bounded concurrency: at most ParallelOptions.MaxDegreeOfParallelism bodies in flight. For ForEachAsync, 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 a CancellationToken that'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 + WhenAll limits concurrency but needs more code and doesn't stop on the first failure. Parallel.ForEachAsync gives 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 synchronous Parallel.ForEach with an async lambda: that's async void."


1. Usage

await Parallel.ForEachAsync(
    customerIds,
    new ParallelOptions { MaxDegreeOfParallelism = 20, CancellationToken = ct },
    async (id, token) => await SyncCustomerAsync(id, token));

2. Behaviour

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

3. Comparison

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

4. Choosing the degree

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

5. Collecting results safely

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

Production example

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.

🔍 What the interviewer is checking

Checks practical async concurrency control.

Green flags

  • Explains bounded concurrency and the default degree.
  • Knows cancellation-on-failure semantics.
  • Collects results safely.
  • Compares with Task.WhenAll and SemaphoreSlim.

Red flags

  • Uses Parallel.ForEach (sync) with async lambdas.
  • Unbounded WhenAll over huge lists.

📚 Learn in depth

1. How it works internally (simplified)

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.

2. The Parallel.ForEach + async lambda bug

Parallel.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.

3. Continue-on-error pattern

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);

4. Ordered results without locks

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

5. Related APIs

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

6. Observability

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.

7. Try it yourself

  1. Run the code example: 200 items × 100 ms at degree 20 ≈ 1 s; check the failure count.
  2. Remove the try/catch and see the loop stop on the first failure.
  3. Replace with Parallel.ForEach(ids, async id => ...) and observe "Done" printing immediately.
  4. Replace with unbounded Task.WhenAll against a local test server with a connection limit and watch errors.
  5. Feed an IAsyncEnumerable<int> source that yields slowly and confirm items are processed as they arrive.
  6. Vary the degree (4, 16, 64) against a test API and plot throughput vs latency.

💻 Code

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}";
}

⚠ Common mistakes

1. Default degree for I/O work

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.

2. Unbounded Task.WhenAll over thousands of calls

What 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.

3. Parallel.ForEach with async lambdas

What 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.

4. Adding to a List<T> from the body

What happens: results.Add(item) from concurrent bodies. Why it is wrong: concurrent writes corrupt the list. Do instead: indexed array or concurrent collection.

5. Expecting the loop to continue after a failure

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.

6. Ignoring the body's token

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.

7. Large fan-out inside an ASP.NET Core request

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.

8. Retrying without limits inside bodies

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.

9. Assuming completion order matches input order

What happens: results are appended as completed and assumed ordered. Why it is wrong: completion order varies. Do instead: write by index.

10. Not reporting per-item failures

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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate compares with SemaphoreSlim Why: Checks async throttling
    How to answer

    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.

  2. When: Candidate mentions CPU-bound parallelism Why: Checks PLINQ judgement
    How to answer

    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.

  3. When: Candidate needs several stages Why: Checks TPL Dataflow
    How to answer

    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.

🛠 Real-world problems: how you handle them

Migration overwhelming a partner API with Task.WhenAll
Situation
A [data migration] synced [80,000] customer records to a partner API.
Signals
Thousands of simultaneous connections, socket exhaustion and HTTP 429 errors; the run failed after [10 minutes].
Investigation
The script used `Task.WhenAll(ids.Select(SyncAsync))`, starting every call at once.
Resolution
Switched to `Parallel.ForEachAsync` with `MaxDegreeOfParallelism = 16`, a resilience handler for retries, and per-item error recording.
Prevention
Added a template for batch scripts with bounded concurrency and a failure report.

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.

↩ Usually asked after

More in Async Advanced

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me