The Task Parallel Library gives you half a dozen ways to run work concurrently, and picking the wrong one is one of the most common sources of production incidents in .NET services: thread pool starvation from Task.Run misuse, PLINQ queries that are slower than sequential LINQ, and Parallel.ForEach used where the work is actually I/O-bound and never needed a worker thread in the first place. Interviewers probe this area because it separates candidates who know the API surface from candidates who understand the underlying scheduling model — the thread pool, work-stealing queues, and the cost of a context switch. Expect questions that force you to justify a specific choice among Task.Run, Parallel.ForEachAsync, PLINQ, and hand-rolled concurrency limiting, not just describe what each one does. The questions below also cover TaskCompletionSource, the WhenAll/WhenAny/WhenEach family, and the ASP.NET Core anti-pattern of wrapping request-handling code in Task.Run.
Q1 What's the practical difference between CPU-bound and I/O-bound work, and why does that distinction drive your concurrency choices?#
Short answer: CPU-bound work keeps a core busy computing (parsing, hashing, image processing) and benefits from being spread across worker threads with Task.Run or Parallel.For/ForEach. I/O-bound work spends almost all its time waiting on something external — a database, an HTTP call, a disk — and should use async/await over a native async API so no thread is held hostage while waiting; wrapping I/O in Task.Run just burns a pool thread to block on I/O that was already non-blocking underneath.
// Wrong: burns a thread pool thread to do nothing but wait on disk I/O.
public Task<string> ReadConfigAsync(string path) =>
Task.Run(() => File.ReadAllText(path));
// Correct: the OS-level async I/O completes without occupying a thread
// while the read is in flight.
public Task<string> ReadConfigAsync(string path) =>
File.ReadAllTextAsync(path);The reason this matters at scale: the thread pool has a limited number of threads, grown slowly under sustained pressure. If every incoming request wraps I/O in Task.Run, you can exhaust the pool under load even though the machine's CPUs are mostly idle — the classic "thread pool starvation" incident. CPU-bound work has the opposite failure mode: running it on the request thread synchronously blocks that thread for the duration of the computation, so it still needs to move to a worker, just genuinely, not just as an await wrapper around already-async I/O.
What interviewers look for: A candidate who can look at a code sample and immediately say "that's I/O, don't wrap it in Task.Run" or "that's a tight CPU loop, it does need a worker thread." Bonus points for connecting this to thread pool internals — minimum thread counts, the injection rate under sustained starvation, and why that rate is deliberately slow.
Common mistakes: Treating "make it async" and "make it run on another thread" as the same thing; reflexively wrapping any slow-looking call in Task.Run without checking whether an async overload already exists.
Follow-up questions:
- How would you detect thread pool starvation in a running service?
- Is there ever a good reason to run I/O-bound work inside
Task.Run?
Q2 How does Parallel.ForEachAsync work, and how is it different from Parallel.ForEach wrapping each item in Task.Run?#
Short answer: Parallel.ForEachAsync<TSource> takes an async body (Func<TSource, CancellationToken, ValueTask>) and internally runs a bounded number of concurrent "worker loops" — controlled by ParallelOptions.MaxDegreeOfParallelism — each pulling items from a shared, partitioned enumerator and awaiting the body before pulling the next one; it composes naturally with async/await, propagates cancellation, and aggregates exceptions. Wrapping each item of a synchronous Parallel.ForEach in an unawaited Task.Run(async () => ...) instead creates an uncontrolled fire-and-forget storm — Parallel.ForEach finishes as soon as it has launched every item, not when the async work inside each Task.Run actually completes, so exceptions, cancellation and the overall completion signal are all lost.
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 8,
CancellationToken = ct,
};
await Parallel.ForEachAsync(orderIds, options, async (orderId, token) =>
{
var order = await _orders.GetAsync(orderId, token);
await _shipping.NotifyAsync(order, token);
});This is the right default for "run this async operation for every item in a collection, with a cap on concurrency" — it replaced a lot of hand-rolled SemaphoreSlim plus Task.WhenAll boilerplate that was common before .NET 6.
What interviewers look for: Recognition that Parallel.ForEachAsync is the modern, correct tool for bounded-concurrency async fan-out, and a clear explanation of why the naive Parallel.ForEach + Task.Run(async ...) combination is broken — not just "it's wrong," but why: the outer loop has no way to wait on work it never awaited.
Common mistakes: Forgetting to set MaxDegreeOfParallelism, which defaults to Environment.ProcessorCount — often far too high for I/O-bound fan-out against a downstream service with its own connection limits; awaiting the loop but still leaving per-item exception handling out, so one failure aborts the whole batch when partial success might be the right behavior.
Follow-up questions:
- What happens to items that are already in flight when one iteration throws?
- How would you process items from an
IAsyncEnumerable<T>source instead of a plain list?
Q3 What is PLINQ, and when does it help or hurt compared to sequential LINQ?#
Short answer: PLINQ (.AsParallel()) partitions a LINQ query across multiple threads automatically, which pays off for CPU-bound, side-effect-free transformations over reasonably large in-memory collections; it usually hurts for small collections (partitioning and merge overhead exceeds the work itself), I/O-bound delegates (there's nothing to parallelize — you're just adding threads around waiting), and code with shared mutable state, where you'd be trading a correctness bug for questionable speed.
var flagged = transactions
.AsParallel()
.WithDegreeOfParallelism(Environment.ProcessorCount)
.Where(t => RunFraudHeuristics(t))
.ToList();
// When result order doesn't matter, ForAll() skips PLINQ's result-merging
// step entirely and can be noticeably faster than materializing a list.
transactions.AsParallel().Where(RunFraudHeuristics).ForAll(FlagForReview);PLINQ defaults to unordered execution and may run the query sequentially anyway if its internal heuristics decide parallelizing wouldn't help — .AsParallel() is a request, not a guarantee. .AsOrdered() restores input ordering but adds buffering and merge cost, so use it only when the order genuinely matters downstream.
What interviewers look for: A candidate who treats PLINQ as a targeted tool for CPU-bound, embarrassingly parallel transforms over real data volumes, not a default replacement for .Where() and .Select(). Mentioning ForAll() as an optimization when order is irrelevant is a strong signal.
Common mistakes: Applying .AsParallel() to a query with side effects (writing to a shared List<T>, mutating a dictionary) without synchronization; assuming PLINQ always beats sequential LINQ and skipping the benchmark that would prove it.
Follow-up questions:
- Why might a
.AsParallel()query actually run slower than the sequential version? - What does
WithExecutionMode(ParallelExecutionMode.ForceParallelism)override, and when would you use it?
Q4 What are partitioners, and when would you write a custom one?#
Short answer: A partitioner decides how the TPL splits a data source across worker threads — System.Collections.Concurrent.Partitioner provides static and dynamic strategies out of the box (range partitioning for indexed loops, chunk partitioning for IEnumerable<T> sources); you write a custom partitioner, usually by deriving from OrderablePartitioner<TSource>, when the default strategy causes load imbalance — most often because per-item cost varies wildly and static range splitting leaves some worker threads idle while others are still grinding through expensive items.
// Default: reasonable for roughly uniform-cost items.
Parallel.ForEach(items, ProcessItem);
// Explicit, load-balancing chunk partitioner: better when item cost varies
// a lot, since idle workers can steal smaller chunks from a shared source
// instead of being stuck with one large, unevenly sized static range.
var partitioner = Partitioner.Create(items, EnumerablePartitionerOptions.NoBuffering);
Parallel.ForEach(partitioner, ProcessItem);For indexed loops, Parallel.For(0, n, ...) uses range partitioning by default, dividing the index range into contiguous chunks up front; that's cheap but assumes uniform cost per index. When it isn't uniform — for example, processing rows where a handful are far larger than the rest — a dynamic, load-balancing partitioner keeps threads fed from a shared source instead of finishing early and sitting idle.
What interviewers look for: Understanding that "parallel" doesn't automatically mean "balanced," and the ability to diagnose a symptom (some threads finish early, total wall time barely improves over fewer threads) as a partitioning problem rather than a scheduling one.
Common mistakes: Assuming more MaxDegreeOfParallelism always helps when the real bottleneck is an unbalanced static partition; never profiling to confirm the imbalance before reaching for a custom partitioner, which adds real complexity.
Follow-up questions:
- How does chunk size interact with partitioning overhead — what happens if chunks are too small?
- When would you reach for a custom
OrderablePartitioner<T>instead of the built-inPartitioner.Createoverloads?
Q5 Why is calling Task.Run inside an ASP.NET Core controller action or minimal API handler almost always wrong?#
Short answer: Kestrel already dispatches requests onto the thread pool — there is no dedicated per-request thread the way classic System.Web had — so wrapping handler logic in Task.Run just adds an extra hop through the same pool for no parallelism benefit, and if it's used to "fire and forget" work, that work can be silently aborted when the request completes and its scope is disposed.
// Anti-pattern: queues onto the same pool the request is already running
// on, adds a scheduling hop, and ties nothing to the request's lifetime
// or cancellation.
app.MapPost("/orders", async (OrderRequest request, IOrderService service) =>
{
return await Task.Run(() => service.CreateOrderAsync(request));
});
// Correct: the handler is already async; call the async API directly.
app.MapPost("/orders", async (OrderRequest request, IOrderService service, CancellationToken ct) =>
{
var order = await service.CreateOrderAsync(request, ct);
return Results.Created($"/orders/{order.Id}", order);
});If there's genuinely CPU-bound work in the handler (image resizing, cryptographic hashing of a large payload), Task.Run does offload it from the calling continuation — but the better question is usually whether that work belongs in the request path at all, versus a background job queued through a hosted service or a message queue, so the HTTP response isn't held hostage by CPU work in the first place. See Background Services and Worker Processes in .NET for that pattern.
What interviewers look for: Recognition that this is about wasted scheduling overhead and broken lifetime management, not a vague "it's bad practice" rule. A senior candidate should be able to describe the double-hop concretely: request arrives on a pool thread, Task.Run queues new work back onto the same pool, and under load that queuing adds latency without adding throughput.
Common mistakes: Using Task.Run to "make things async" when the method is already async; using it for fire-and-forget work without wiring it to IHostedService/BackgroundService or an explicit lifetime, so it can vanish mid-flight when the request's scope disposes its dependencies.
Follow-up questions:
- What actually breaks if you fire-and-forget a
Task.Runinside a request handler that references a scopedDbContext? - How would you deliberately offload real CPU-bound work from a hot request path?
Q6 What is TaskCompletionSource for, and when do you need it instead of just writing an async method?#
Short answer: TaskCompletionSource<TResult> lets you produce a Task<TResult> that you complete manually, from code that isn't itself async — typically to bridge an event-based, callback- based, or hardware/driver API into the Task-based world so callers can await it like any other asynchronous operation.
public Task<SensorReading> ReadNextAsync(CancellationToken ct)
{
var tcs = new TaskCompletionSource<SensorReading>(
TaskCreationOptions.RunContinuationsAsynchronously);
void OnReading(object? sender, SensorReading reading)
{
_sensor.ReadingReceived -= OnReading;
tcs.TrySetResult(reading);
}
ct.Register(() => tcs.TrySetCanceled(ct));
_sensor.ReadingReceived += OnReading;
return tcs.Task;
}The TaskCreationOptions.RunContinuationsAsynchronously flag matters more than it looks: without it, calling SetResult/SetException runs every continuation registered on that task synchronously, on the thread that called SetResult — often a hardware callback thread or an event-raising thread you don't control. That can cause unexpected reentrancy, hold a driver callback thread far longer than it expects, or even deadlock if a continuation tries to synchronously wait on something that thread also owns. Requesting asynchronous continuations posts them to the thread pool instead, decoupling the completer from whatever the awaiting code does next.
What interviewers look for: Knowledge of the RunContinuationsAsynchronously gotcha specifically — it's one of the most common subtle bugs in hand-rolled TaskCompletionSource code, and asking about it separates candidates who've actually shipped this pattern from those who've only read about it.
Common mistakes: Using SetResult instead of TrySetResult in code that might race with cancellation or a duplicate completion, which throws InvalidOperationException on the second call instead of failing gracefully; forgetting to unhook the event handler, leaking a subscription per call.
Follow-up questions:
- What happens if both the event callback and a cancellation token try to complete the same
TaskCompletionSource? - Why does
Task.Runinternally use aTaskCompletionSource-like mechanism rather than the delegate driving completion directly?
Q7 What's the difference between Task.WhenAll, Task.WhenAny and Task.WhenEach, and when would you reach for each?#
Short answer: Task.WhenAll returns a task that completes once every input task has finished, aggregating all failures; Task.WhenAny returns as soon as one task finishes, handing you that single completed task so you can act on it (and typically loop to keep waiting on the rest); Task.WhenEach, added in .NET 9, returns an IAsyncEnumerable<Task> you can await foreach over, yielding each task as it completes — a cleaner, more efficient way to process results in completion order than the classic "loop calling WhenAny and removing the winner" pattern.
var downloads = urls.Select(u => _client.GetStringAsync(u)).ToList();
// Older pattern: correct, but re-scans the remaining list on every
// iteration, which gets expensive as the batch grows.
while (downloads.Count > 0)
{
var finished = await Task.WhenAny(downloads);
downloads.Remove(finished);
Process(await finished);
}
// Task.WhenEach: streams tasks in completion order without the manual
// bookkeeping or the repeated re-scan.
await foreach (var completed in Task.WhenEach(downloads))
{
Process(await completed);
}Use WhenAll when you genuinely need everything to finish before proceeding (and want every failure, via .Exception?.InnerExceptions, not just the first). Use WhenAny for a single race — for example, racing a real operation against a timeout task. Use WhenEach whenever you want to react to results as they arrive across more than a couple of tasks.
What interviewers look for: Awareness that the WhenAny-in-a-loop pattern, while correct, has an O(n) rescan on every completion (O(n^2) overall for n tasks), and that WhenEach exists specifically to fix that ergonomics-and-performance problem. Bonus points for knowing WhenEach is a relatively recent (.NET 9) addition rather than assuming it's always been there.
Common mistakes: Using WhenAny for a full batch instead of WhenEach or WhenAll; forgetting that the task WhenAny returns is itself not yet "observed" — you still need to await it to surface its result or exception.
Follow-up questions:
- How would you implement a timeout for a single operation using
WhenAny? - What's the behavioral difference between
WhenEachyielding tasks and manually subscribing a continuation to each task withContinueWith?
Q8 How do you limit concurrency when kicking off many tasks at once?#
Short answer: Three common tools, chosen by shape: SemaphoreSlim(initialCount, maxCount) with WaitAsync/Release around arbitrary async code when you need fine-grained control or the work doesn't map cleanly onto "one async body per item"; ParallelOptions.MaxDegreeOfParallelism with Parallel.ForEachAsync when the shape genuinely is "run this async body for every item in a collection"; and a bounded System.Threading.Channels.Channel<T> when you want an ongoing producer/consumer pipeline with real backpressure rather than a fixed, known-size batch.
var gate = new SemaphoreSlim(initialCount: 10);
var tasks = orderIds.Select(async id =>
{
await gate.WaitAsync();
try
{
return await _orders.GetAsync(id);
}
finally
{
gate.Release();
}
});
var orders = await Task.WhenAll(tasks);The SemaphoreSlim version gives you the most control — you can mix in retries, per-item timeouts, or conditional skipping inside the gated section — at the cost of writing the try/finally yourself. Parallel.ForEachAsync gives you the same concurrency cap with far less boilerplate when the shape fits. A bounded channel is the right choice when work arrives over time rather than as one known batch, since the channel itself applies backpressure to producers once it's full.
What interviewers look for: A decision framework, not just "I'd use a semaphore." Strong answers name the three options above and give a one-sentence reason to prefer each one for a specific scenario the interviewer describes.
Common mistakes: Forgetting try/finally around Release(), which leaks permits forever if an exception escapes the gated section; setting no cap at all and calling it "concurrent" when it's actually just an unbounded fan-out that can overwhelm a downstream dependency.
Follow-up questions:
- How would you add a per-item timeout to the
SemaphoreSlimpattern above? - When would a bounded channel be a better fit than a semaphore-gated
Task.WhenAll?
Q9 How do you choose between Task.Run, Parallel.For/ForEach, Parallel.ForEachAsync and PLINQ for a given workload?#
Short answer: Match the tool to the shape of the work: Task.Run for a one-off piece of CPU-bound work you want off the calling thread; Parallel.For/Parallel.ForEach for synchronous CPU-bound work spread across a known, in-memory collection; Parallel.ForEachAsync for an async body (I/O-bound or mixed) run across a collection with a concurrency cap; PLINQ for declarative, query-shaped CPU-bound transforms where you'd otherwise write LINQ.
| Tool | Body is | Best for |
|---|---|---|
Task.Run | Synchronous, one-off | Offloading a single CPU-bound operation |
Parallel.For / Parallel.ForEach | Synchronous | Data-parallel CPU-bound work over a collection |
Parallel.ForEachAsync | Asynchronous | I/O-bound or mixed work over a collection, with a concurrency cap |
PLINQ (AsParallel) | Synchronous, query-shaped | CPU-bound filter/transform/aggregate pipelines |
Bounded Channel<T> | Either | Ongoing producer/consumer pipelines with backpressure |
What interviewers look for: Fluency moving between these options without defaulting to the one the candidate happens to know best. Strong candidates immediately ask "is the body sync or async, and is the data already in memory or streaming in?" before answering.
Common mistakes: Reaching for Parallel.ForEach and blocking (.Result/.Wait()) inside the delegate to call an async API, instead of using Parallel.ForEachAsync directly — a classic sync-over-async trap that both wastes a thread and risks the deadlocks covered in Deadlocks and Race Conditions Interview Questions.
Follow-up questions:
- Why is
Parallel.ForEacha poor fit for I/O-bound work even though it does run concurrently? - How would this decision change if the data source were an
IAsyncEnumerable<T>streaming from a database cursor instead of an in-memoryList<T>?
Q10 How would you aggregate results and handle partial failure across a large batch of independent async operations processed with limited concurrency?#
Short answer: Run the batch through Parallel.ForEachAsync (or a semaphore-gated Task.WhenAll), write each item's outcome — success or a captured exception — into a thread-safe collection like ConcurrentBag<T> from inside the body instead of letting one failure abort the whole batch, then inspect the collected results after the loop to decide what "partial success" means for your use case.
var results = new ConcurrentBag<(string Id, bool Success, Exception? Error)>();
await Parallel.ForEachAsync(orderIds, new ParallelOptions { MaxDegreeOfParallelism = 8 }, async (id, ct) =>
{
try
{
await _orders.ProcessAsync(id, ct);
results.Add((id, true, null));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
results.Add((id, false, ex));
}
});
var failures = results.Where(r => !r.Success).ToList();Catching inside the body converts "one bad item aborts the batch" into "the batch always finishes and tells you exactly what failed" — the right default for most bulk operations, like a nightly reconciliation job or a bulk notification send. If you instead need fail-fast semantics (stop as soon as anything goes wrong), let the exception propagate out of the body and let Parallel.ForEachAsync stop scheduling new iterations and surface the failures once in-flight work drains.
What interviewers look for: A concrete pattern for turning "N independent operations" into a report of successes and failures, plus the judgment to know when fail-fast is actually the right choice instead of best-effort. Mentioning ConcurrentBag<T> or an equivalent thread-safe sink, rather than a plain List<T> mutated from multiple bodies, is a basic correctness check interviewers listen for.
Common mistakes: Writing to a non-thread-safe List<T> from inside a concurrent body, a race condition that corrupts the list or throws intermittently under load; swallowing OperationCanceledException into the failure bucket instead of treating cancellation as a distinct, expected outcome.
Follow-up questions:
- How would you add a retry policy for transient failures inside the per-item body without breaking the concurrency cap?
- How would you surface progress (say, "120 of 500 processed") while the batch is still running?
Quick-Fire Round#
| Question | Answer |
|---|---|
Should you wrap HttpClient.GetAsync in Task.Run? | No — it's already asynchronous I/O; wrapping it wastes a thread. |
What does ParallelOptions.MaxDegreeOfParallelism default to? | Environment.ProcessorCount if not set explicitly. |
Does Parallel.ForEach accept an async delegate? | Not meaningfully — use Parallel.ForEachAsync for async bodies. |
What's the risk of TaskCompletionSource without RunContinuationsAsynchronously? | Continuations run synchronously on the completing thread, risking reentrancy or long callback stalls. |
What does Task.WhenAny return? | The first completed Task from the set, which you still must await to observe its result. |
Which .NET version added Task.WhenEach? | .NET 9. |
Is .AsParallel() a guarantee of parallel execution? | No — PLINQ may still run sequentially if it decides that's faster. |
What's the main risk of an ungated Task.WhenAll over thousands of items? | Overwhelming a downstream dependency with unbounded concurrent calls. |
Does Parallel.For guarantee ordered execution? | No — iterations can run in any order across worker threads. |
| What should you use instead of a semaphore for a streaming producer/consumer pipeline? | A bounded Channel<T>. |
How to Prepare#
- Practice classifying a code sample as CPU-bound or I/O-bound in one glance, and naming the right tool for each.
- Be ready to explain, precisely, why
Task.Runinside an ASP.NET Core handler adds overhead instead of parallelism. - Know the exact signature shape of
Parallel.ForEachAsyncand howParallelOptionscontrols concurrency and cancellation together. - Rehearse the
TaskCompletionSource+RunContinuationsAsynchronouslygotcha with a concrete example, not just the name of the flag. - Be able to compare
WhenAll,WhenAnyandWhenEachwith a one-line reason to choose each. - Have a ready answer for "how would you process 10,000 items with bounded concurrency and report partial failure" — this pattern comes up constantly in system design rounds too.