← Home
.NETAsync and threadsReported by candidates

What is the difference between a Task and a Thread, what is the Task Parallel Library, and when do you use Parallel.ForEach?

9 reports in the question index (all linked below), from first and second technical rounds: UST, MPRDC, Celebal Technologies, FinStack, Publicis Sapient, Goldman Sachs, TCS, Capgemini and Hexaware. Usually asked as "Task vs Thread", then "what is the TPL", then "foreach vs Parallel.ForEach".

Say this first · about 30 seconds
Make it yours · Navistar Financial excess-mileage fees (invented example)
The code, simplified

Invented to show the shape, in C#. Tap Next to walk one month-end run: 90,000 trucks, each fee in its own .

Navistar.sln · where these 2 files sit
  • Mileage/
ExcessMileageJob.cs · line 1 · 1 of 14The class the month-end timer calls.

Fix later in your repo Example written for this card: invented, not from a real codebase.

Say it out loud, then check

Show the 30-second answer
  • A thread is a real the operating system schedules. It has its own stack (a private work area for the method it runs), it costs memory, and switching between threads costs time. You can create one yourself, but in a web app you rarely should, because each request already arrives on a thread from the pool (the team of reusable threads .NET keeps).
  • A Task is a promise for work that finishes later. It is not a thread. For a database call or an HTTP call, the Task waits while no thread is busy; when the answer arrives, a thread from the pool picks the work up again. For processor-bound work, CPU work, Task.Run puts it on a pool thread.
  • The Task Parallel Library is the .NET set of tools built on Task: Task.Run, Task.WhenAll (waits for several Tasks together), Parallel.For and Parallel.ForEach, cancellation and exception handling.
  • Parallel.ForEach is for CPU-bound work over a big collection. It splits the collection into ranges (chunks handed to one thread), runs the loop body (the code inside the loop) on several pool threads, and does not return until all of them finish. The order is not preserved, the body must be thread-safe (many threads can use it at once without losing data), and one exception comes back inside an AggregateException, a wrapper that holds every failure.
  • On our BMW FS residual-value run, a sequential loop took three hours forty. Parallel.ForEach cut it to thirty-eight minutes, but only after we stopped writing to one shared list and one shared DbContext, the database object that is not thread-safe. We did the maths in parallel with no shared state, wrote each result into its own , then saved once.
  • The rule I use: async and await, which wait without blocking a thread, for input or output; Parallel.ForEach or Task.Run for CPU-bound work; and a plain foreach when the work is small or must stay in order.
Check your answer: tick what you said

You said 0 of 9

Then add your example: Navistar Financial excess-mileage fees (invented example)
  • Situation: a Navistar Financial truck lease allows a set number of miles; above it, the customer pays an excess-mileage fee when the truck comes back. The month-end fee run covered 90,000 returned trucks. One did the maths: extra miles times the per-mile rate times a regional factor. It took 52 minutes, and the job window was 30 minutes, so the dealer statements were late.
  • What I did: split the CPU work with , the indexed form of , over a pre-sized array. Every iteration reads one truck and writes fees[i], its own , so two threads never share memory and no is needed. I capped it at one fewer than the machine's processor count with , the setting that limits how many iterations run at once, so the API on the same box still had a core. After the loop, one thread adds the fees and the total is written in one save.
  • Result: the run fell from 52 minutes to 9, the total matched the old sequential run to the cent, and no fee went missing. A test with 10,000 trucks checks that every has a value and that the total equals the sequential version.
  • What I'd say: a Task is for waiting, a Thread is an OS , and the TPL is how you spread CPU work over the pool. I use or only when the body is pure and big enough, and I keep every shared object out of the parallel body.

See it

1/7 runs to its first and returns a : a promise, not a thread.

In plain words

The senior answer · about 2 minutes
The difference, in my words
  • A thread is an OS . The OS scheduler decides when it runs; it has its own stack, and creating and switching threads has a real cost. I can create one with new Thread, but in a web app each request already has a thread-pool thread, so adding threads usually makes things worse.
  • A Task is a promise for work that finishes later. It is not a thread. For a database or HTTP call, the Task waits with no thread busy; when the answer arrives, a thread from the pool continues the method. For CPU-bound work, queues the work to the pool. A Task also carries a result, an exception and cancellation, which a raw thread does not.
Where the TPL fits
  • The Task Parallel Library is the set of types under that model work: and , , , continuations (the code after an await), cancellation and the data-parallel loops and .
  • is for CPU-bound work over a collection. It splits the collection into ranges, runs the body on pool threads, and returns only when all ranges are done. The calling thread blocks and can also run iterations. Order is not preserved.
  • The body must be self-contained. If every iteration writes to one or one , the loop corrupts state or throws. I collect into an array per index, or a when order does not matter.
Exceptions and limits
  • Any exception in a body stops new iterations from starting; the ones already running finish, and then the loop throws one whose has every failure. I log each one with the row id and decide whether the batch is safe to retry.
  • is the async version. The body is async, the await waits for every call, and limits how many are in flight. The await rethrows only the first failure, so if I need every failure I keep the Task and read task.Exception.InnerExceptions, or catch inside the body and collect them.
  • The exception rule is also the async rule: an async lambda passed to becomes . The loop cannot await it, so it returns early and the job logs success while saves are still running. That is the second bug below.
The run that taught me
  • Our BMW FS residual-value job revalued 240,000 open leases one at a time and took 3 hours 40 minutes. The dealer feed at 05:30 needed today's values, so being late meant dealers opened yesterday's book.
  • We switched to and the run got faster, but we first wrote into one shared and saved through one . A loses adds under concurrency; is not thread-safe. One night 3,200 leases kept last month's value and there was no exception in the log.
  • The fix was structural: pure maths in the parallel body, one array per lease, one sequential save after the loop, and a cap of one fewer than the processor count. The run is 38 minutes, and a count test fails the build if any is empty.
How I choose, and what I left behind
  • I ask two questions: is the work waiting or computing, and is the collection big enough to pay the split cost (the time to cut up the list and hand it out)? Waiting means async and await; computing means for one item or for many. A small loop stays a .
  • After the incident I added a review rule: no shared mutable state inside a parallel body, and every parallel loop has a count-and-total test that runs more than once. I also added a job-duration alert in , so a run that is late is visible before the dealer feed.

One valuation run, every idea

The BMW FS month-end run, in time order: an awaited Task for the load, pool threads for the CPU work, and Parallel.ForEach to split the list.

1/7The job loads leases with an async query: it awaits a Task, and no thread waits on SQL.

The story

  1. Before

    A residual value is the predicted worth of a car at the end of its lease, the rental contract BMW FS signs with a dealer's customer. The risk team revalues every open lease once a month. The job was one loop: 240,000 leases, one after another on one thread (one the operating system runs). Each value was a small calculation over the lease's age, miles and model. The run took 3 hours 40 minutes, started at 02:00 ET, and had to finish before the dealer feed at 05:30 ET, the file that sends each dealer their book value.

    1/2Before: one thread values 240,000 leases, one lease after another.

  2. Why it had to change

    is the data-parallel loop of the TPL (Task Parallel Library), .NET's set of tools built on a Task, a promise of work that finishes later. The loop takes a collection, splits it into ranges (chunks of the list, one per thread), and asks the thread pool (the team of reusable threads .NET keeps) to run the body (the code inside the loop) on several threads at once. It is synchronous: the calling thread waits until every range is done. That is right for CPU-bound work (maths the processor must do) and wrong for I/O-bound work (waiting on SQL, HTTP or a file), where you should await a Task: await waits for it without blocking the thread. It is also unsafe when every iteration writes to one shared object.

  3. How it works now

    The fix had three rules. First, the body is pure (it reads its input and returns a result without touching shared state): a method reads one lease and returns one number. Second, results go into an array sized before the loop, and each iteration writes its own index; two threads never touch the same , so no is needed (a lock lets one thread at a time run a block). Third, the database stays out of the parallel loop: one (the database object from Entity Framework Core, .NET's database library; it is not thread-safe, meaning many threads cannot use it at once without losing data) saves the finished array in batches afterwards. , the setting that caps how many iterations run at once, keeps one core free for the API.

    1/5The job splits 240,000 leases into ranges and hands them to the thread pool.

  4. What we gained

    Once you know which tool owns the work, the choice is quick: await a Task for waiting, split CPU work with the TPL, and keep every shared object out of the parallel body. The rest of the card shows the code, the two run-time bugs that cost us a night, the interview follow-ups, and three live tasks.

In the lending API

Three tests, three tools. Each marked line is one of them; "See it" shows where the work runs.

Only the key lines are shown; Deep walks the whole file.

Lending.sln · where this file sits
  • tests/Concurrency.UnitTests/
⋯ 7 lines in Deep⋯ 13 lines in Deep
WorkerTests.cs · line 9 · 1 of 4 is a promise of an int later. The call runs the method to its first await, then returns this Task. 36 is a term in months.

The code, file by file

Three xUnit tests (xUnit is a .NET test tool) that show a Task, a Thread and a side by side. Tap Next to walk the diagram with the code.

Lending.sln · where this file sits
  • tests/Concurrency.UnitTests/
WorkerTests.cs · line 1 · 1 of 24The namespace that holds thread-safe collections such as . using only imports names; it runs nothing.

What they ask next

The follow-ups interviews really ask. Answer out loud, then tap to check.

Follow-up tree

Answer, then open the next level. That is the order an interviewer digs in.

Reporteda candidate was asked Intervieweran interviewer asks it

Production bugs, and how to tell them

Each one: what you see, why, the fix in code, and the line to say.

Production incident: Parallel valuation dropped 3,200 leases

P2 · serious, has a workaroundBMW FSonly under loadResidual-value valuation job (a .NET worker on Azure Container Apps)

Composed, not a real event at BMW FS. The mechanism is real C#, checked by runs on .NET 10: a List loses adds under Parallel.ForEach, a results array by index is safe, and an async lambda in Parallel.ForEach becomes async void. Times, counts, names, ticket and PR numbers are composed.

The story in 5 lines
  1. On Tue 12 May, BMW FS's risk team saw 3,200 open leases still on last month's value, out of 240,000. The dealer feed was due at 05:30 ET.
  2. Release 4.2 had replaced a sequential foreach with Parallel.ForEach to cut a 3 h 40 run. The new body added each valuation to one shared List.
  3. A List is not thread-safe: concurrent adds overwrote each other, so 236,800 made it. There was no exception in the job log that night.
  4. The fix wrote each result into its own array by index, summed and saved after the loop, and capped the threads. The 12 May feed went out with 3,200 stale values; the 13 May 05:30 feed read complete values.
  5. Afterwards: a review rule against shared state in a parallel body, a count-and-total test, and a job-duration alert in .
Who's who (4 people)
You
.NET developer on the vendor team. Finds the cause, writes the fix.
Tech lead
Vendor senior. Reviews the change, runs the postmortem.
Risk data pod
Vendor support. Checks the valuation table and reruns the job.
BMW FS risk lead
Owns the monthly book values and the dealer feed. Decides what the dealers are told.

Tap a step. The map lights the place where it happens.

1/4 Detect

Detect: 3,200 leases still show last month's value. The feed is due at 05:30.

Tue 12 May, 13:00 IST (03:30 ET)Risk data pod, then the BMW FS risk lead

  • What: the pod's post-run check compared leases due for valuation with rows written for the month: 240,000 due, 236,800 written. 3,200 leases still carried last month's value, and the dealer feed was due at 05:30 ET.
  • Why it hid: the job finished early and logged "240,000 leases read", the number it read, not the number it wrote. No exception was logged that night.
  • How we saw it: a count query by valuation date, run because the new release had changed the loop. The feed job had not started yet.
Azure SQL: leases due vs values written
SELECT
  (SELECT COUNT(*) FROM Leases
   WHERE Status = 'Open') AS Due,
  (SELECT COUNT(*) FROM Valuations
   WHERE ValueDate = '2026-05-12') AS Written;

-- Due: 240000   Written: 236800
In theory

A post-run check that the rows written equal the leases due, with an alert when they differ.

In practice

The job logged the leases it read, so its log looked perfect.

Try it yourself · 10 minutes, on your Mac
  1. dotnet new console -n ParallelLab. Make a , then .
  2. Print list.Count: in five runs on this Mac it was between roughly 16,000 and 66,000, never 200,000. Nothing threw.
  3. Change the List to a : the count is 200,000 every run, but order is not kept.
  4. Change to int[] slots = new int[200_000] and : every is filled, and no lock is needed.
You should see

A List loses adds under a parallel loop. Give each iteration its own ; keep the database outside the loop.

The senior move

"A List and a DbContext are not thread-safe, so neither belongs inside a Parallel.ForEach body. I give each iteration its own array , keep the database for one sequential save, cap the threads, and prove it with a count-and-total test that runs more than once."

Sources Sourced: a List is not thread-safe and concurrent adds can lose items; Parallel.ForEach runs the body on pool threads and throws one AggregateException; Parallel.ForEachAsync takes an async body and MaxDegreeOfParallelism (Microsoft .NET docs, "Task Parallel Library", "Parallel.ForEach", "Parallel.ForEachAsync"). Checked with real runs on .NET 10: 200,000 concurrent List adds lost roughly 134,000 to 184,000 items in five runs; a ConcurrentBag kept all; an async lambda in Parallel.ForEach returned with 0 of 5 saves done. Composed: BMW FS systems, dates, counts, names, ticket and numbers, the SQL and the ticket text.

Traps

Traps

What should you say instead? Tap to check.

Live coding

Easy first, then harder. Set a timer and type it before you look.

Task 1

Reportedeasy · 10 minutes

Predict what this program prints, in order, before you run it. Then change the last loop to and explain why the three numbers can now print in any order.

Question index: what is a thread in C# (MPRDC)
You are given

One file. and are the raw OS ; waits for it, and IsBackground = true means the will not keep the app alive after the main method returns.

Lending.sln · where this file sits
Stuck? Hints
  1. Start returns at once; the new thread may not have run yet.
  2. Join blocks until the ends, so total is set before the read.
  3. A Parallel loop has no promised order; the values are the same, the order is not.
Show a solution
Lending.sln · where these 2 files sit
Output (real run) · line 1 · 1 of 3Start returned; the test thread printed this before the finished.
What the interviewer checks
  • Prints main, then 42: Join waits for the before the read.
  • The sequential loop prints 1, 2, 3 in order.
  • With Parallel.ForEach the same three numbers can appear in any order; each body runs on a pool thread.

Task 2

Reportedmedium · 20 minutes

Parallelise this CPU-bound total with , keeping every fee and the exact total. Run it against the sequential version and compare the answers.

Question index: foreach vs Parallel.ForEach (UST)
You are given

The sequential method is the reference. is pure: same truck, same fee.

Lending.sln · where these 2 files sit
  • Fees/
Stuck? Hints
  1. The body is pure, so the only shared thing you need is the place results go.
  2. A ConcurrentBag accepts adds from many threads; a List does not.
  3. Add the fees on one thread after the loop, so the total is not a shared accumulator.
Show a solution
Lending.sln · where this file sits
  • Fees/
FeeRun.cs · line 4 · 1 of 4A thread-safe bag: many pool threads can add at once without losing a fee.
What the interviewer checks
  • Uses Parallel.ForEach, not a new Thread per truck.
  • Every truck contributes exactly one fee.
  • The total equals the sequential total, and the code says why summing after the loop is safe.

Task 3

Question index: Musthard · 35 minutes

Call a slow valuation API for 5,000 leases with at most 8 calls in flight. Collect every failure, stop after 30 seconds, and return a summary. Use the async TPL, not one thread per call.

Question index: analyse a huge file with multithreading (FinStack, derived)
You are given

The API is already async. TimeSpan is a length of time, and CancellationTokenSource (the object that fires cancellation after that time) stops the run at 30 seconds. The skeleton awaits one call at a time, so 5,000 calls take far too long.

Lending.sln · where these 2 files sit
  • Valuation/
Stuck? Hints
  1. Parallel.ForEachAsync has an async body and a MaxDegreeOfParallelism option.
  2. Use a ConcurrentBag for values and failures; use Interlocked for the call count.
  3. A CancellationTokenSource with a time limit makes the loop stop; catch OperationCanceledException around the await.
Show a solution
Lending.sln · where this file sits
  • Valuation/
Program.cs · line 1 · 1 of 17using var disposes the source when the method ends. The source cancels every call.
What the interviewer checks
  • At most 8 calls are in flight.
  • Every lease is attempted, or the loop stops at 30 seconds.
  • Failures are counted and kept, not swallowed.
  • No thread is created per call; the loop is awaited.
With AI tools

When an assistant writes a parallel loop, I check three things before accepting it. Does the body share a , a or a total updated with +=? Is the body async, which makes a lambda ? And is the work CPU-bound, or is it just I/O dressed up as parallelism? I ask for a run with a count and a total, not a claim that it is faster, and I keep the sequential version as the reference. The assistant is good at the shape of the code; the thread-safety and the choice of tool are still my review.

Glossary: every name, one line each →

My notes

Paste the NOTES block from your doubt chat here. The grader never reads notes; the coach reads "Still unclear".

Read moreWhere it was asked