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
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.
Make it yours · 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.
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 repoExample 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
Your codethe test
Taska promise
ThreadsOS workers
CPU coresrun at once
call
a Task
1/7 runs to its first and returns a : a promise, not a thread. (see below) waits on a timer; no thread is busy with this Task.new Thread builds an OS thread with its own stack. Nothing runs until . runs the lambda on that thread. makes the test wait until it ends. splits 400 rows into ranges and runs the body on pool threads.Each body adds to a , a thread-safe bag. The caller waits for all 400.Recap: a Task is work you await; a Thread is an OS ; the TPL splits CPU work.
In plain words
A thread is one the computer runs. The operating system decides when it gets a turn, and each thread has its own stack: a private work area for the method it is running. Creating a thread is not free: it reserves that area and the OS has to switch between threads, which costs time. A web app already runs every request on a thread from a pool, so making extra threads for one request is usually a step backwards.
A thread pool is a team of kept on standby. Instead of hiring and firing a for every small job, .NET reuses a small set of threads. and hand work to this pool; that is why they do not create a new thread per item.
A is an order slip, not a . When you call an async method, it starts, reaches its first , and hands you a Task: "your result is coming". For a database or HTTP call, no stands watching; the slip sits there until the answer arrives. Then a pool thread continues the method. A Task also carries the result, the error, or a cancellation.
async and await are how you use that slip. marks a method that may wait; says "pause me here, I have nothing to do until this finishes". The thread is released, not blocked. For a CPU-bound job, waiting does not help: the processor has to do the maths, so moves it to a pool thread.
The TPL, the Task Parallel Library, is the set of tools built on those slips: ,,,, cancellation and error handling. In an interview, TPL is the name for the .NET classes that schedule and combine Tasks.
is a different tool from . It is for CPU-bound work: maths the processor must do. It cuts a big pile into ranges (chunks of the list handed to one thread), gives the ranges to pool threads, and waits until every range is finished. It is data parallelism: the same operation on many pieces of data at once. A plain does one piece at a time, in order; does many at once, in no promised order.
The catch is shared state. If four all write to one , they read the same count, write to the same , and one update vanishes. That is a race condition: the result depends on who got there first. A makes one thread at a time run a block, which fixes correctness but can make the loop slower. The better fix is to give each iteration its own : an array index, or a thread-safe when order does not matter. Thread-safe means many threads can use it at once without losing data.
Concurrency and parallelism are not the same word. Concurrency means several jobs are in progress at overlapping times; one can switch between them. Parallelism means several really run at the same instant on several cores. Async I/O is concurrency without parallelism: one thread starts 100 HTTP calls and waits. is parallelism: several cores compute at once.
So I choose by the work. Waiting on SQL, HTTP or a file: async and await, no extra thread. A big CPU-bound collection: , with pure per-item work and no shared object. A small loop or one that must keep order: a plain .
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.
Valuation job240,000 leases
Task: the await
Azure SQLfull array
1/7The job loads leases with an async query: it awaits a Task, and no thread waits on SQL.Parallel.ForEach splits 240,000 leases into ranges and gives them to the pool.Three pool threads run the pure value method. No thread owns the whole loop.Each iteration writes its own array . A shared List would lose rows.One sequential save writes the full array. The DbContext is never shared.At 05:30 the feed reads complete values. The job took 38 minutes.Recap: Task for waiting, pool threads for CPU, Parallel.ForEach to split, no shared state.
The story
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.
Valuation job240,000 leases
One threadone lease at a time
1/2Before: one thread values 240,000 leases, one lease after another.It ends at 05:40, ten minutes late. The feed sends yesterday's values.
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.
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.
Valuation jobone run
Thread poolreused workers
Results arrayone slot each
Azure SQLone writer
240,000 leases
1/5The job splits 240,000 leases into ranges and hands them to the thread pool.Pool threads take ranges. Each runs the pure value method: no shared object.Lease i writes values[i]. Two threads never write the same , so no lock.The loop waits for every range, then one thread saves the full array.At 05:30 the feed reads today's values: 38 minutes, not 3 hours 40.
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.
ReportedWhat is the difference between a Task and a Thread?
A Thread is an OS-level : you create it, it has its own stack, and it runs until its method returns. A Task is a promise for work that finishes later. It does not own a thread: an async I/O Task waits with no thread, and a CPU-bound Task runs on a thread-pool thread. A Task also carries a result, an exception and cancellation, and you can await it or combine it with . In a web app I use Tasks and the pool, not a new Thread per request.
// Work and CalcAsync are your own methodsThread t = newThread(Work);
t.Start();
Task<int> task = CalcAsync();
int value = await task;
The TPL is the set of types in that model work: and ,,, continuations (the code after an await), cancellation with , and the data-parallel loops and . It schedules work on the thread pool, carries results and exceptions, and lets the runtime decide how many threads to use. Before it, we created threads and waited on them by hand; the TPL replaced that with tasks.
ReportedWhat is the difference between a foreach loop and Parallel.ForEach?
A foreach runs the body once at a time on the calling thread, in order. Parallel.ForEach splits the collection into ranges and runs the body on several thread-pool threads at once. It is synchronous: it returns only when every range is done. The order is not preserved, the body must be thread-safe, and exceptions come back together in an . It helps only when the body is CPU-bound and big enough to pay the splitting cost. For SQL or HTTP calls, use async with or .
Answer, then open the next level. That is the order an interviewer digs in.
Reporteda candidate was asked Intervieweran interviewer asks it
ReportedWhat is the difference between a Task and a Thread?
A Thread is an OS-level : you create it, it has its own stack, and it runs until its method returns. A Task is a promise for work that finishes later. It does not own a thread: an async I/O Task waits with no thread, and a CPU-bound Task runs on a thread-pool thread. A Task also carries a result, an exception and cancellation, and you can await it or combine it with . In a web app I use Tasks and the pool, not a new Thread per request.
// Work and CalcAsync are your own methodsThread t = newThread(Work);
t.Start();
Task<int> task = CalcAsync();
int value = await task;
Only for a long-running dedicated loop that must not compete with pool work, and then with IsBackground = true so it does not keep the app alive. For a normal request or a short job, or is the better tool.
Is a Task always running on a thread?
No. A Task from an async I/O call waits on the OS; no thread is busy. A Task from does occupy a pool thread while its code runs.
ReportedWhat is the Task Parallel Library?
The TPL is the set of types in that model work: and ,,, continuations (the code after an await), cancellation with , and the data-parallel loops and . It schedules work on the thread pool, carries results and exceptions, and lets the runtime decide how many threads to use. Before it, we created threads and waited on them by hand; the TPL replaced that with tasks.
The ThreadPool is the set of reusable threads. A Thread is one you create yourself. A Task is a unit of work that the TPL schedules, usually on the pool. Most modern .NET code uses Tasks and leaves the pool alone.
What does Task.Run do?
It queues a delegate to the thread pool and returns a Task for it. Use it for CPU-bound work that would block the current thread. It does not make I/O faster; for I/O, call the async API directly.
ReportedWhat is the difference between a foreach loop and Parallel.ForEach?
A foreach runs the body once at a time on the calling thread, in order. Parallel.ForEach splits the collection into ranges and runs the body on several thread-pool threads at once. It is synchronous: it returns only when every range is done. The order is not preserved, the body must be thread-safe, and exceptions come back together in an . It helps only when the body is CPU-bound and big enough to pay the splitting cost. For SQL or HTTP calls, use async with or .
When the collection is small, the body is tiny, or every iteration takes the same . Splitting and synchronising cost more than the work saves. I measure with a on the real collection before changing a loop.
How do you stop a parallel loop early?
The body receives a (the object passed to the body, with and ) and can call (finish the iterations with lower indices) or (end as soon as it can). The returned (the loop's completion report) tells you whether it completed and where it stopped.
How do you limit how many run at once?
. I set it to one fewer than the processor count when the API shares the machine, and to a small number like 8 for I/O calls, so the database is not flooded.
ReportedWhat is the difference between concurrency and parallelism?
Concurrency is handling several things in overlapping time; parallelism is running them at the same instant on different cores. Async I/O is concurrency without parallelism: one thread starts 100 HTTP calls and awaits them, and no thread is busy while they wait. is parallelism: several threads compute at once. A single-core machine can be concurrent but not parallel.
// concurrency: one thread, many waitsTask a = api.GetAsync(1);
Task b = api.GetAsync(2);
await Task.WhenAll(a, b);
// parallelism: many cores computevar fees = newConcurrentBag<decimal>();
Parallel.ForEach(rows, row =>
fees.Add(Cost(row)));
Does async/await create a new thread?
No. marks a method that can wait; frees the current thread while the I/O is pending. When the answer arrives, a thread from the pool continues the method. For CPU-bound work, await alone does nothing; moves that work to a pool thread.
Each thread reserves its own stack and costs the OS time to create and switch (that save-and-load is a context switch: the OS saves one thread's place and loads another's). A busy API with 500 requests would create 500 threads instead of reusing a small pool; memory and context switching explode. ASP.NET Core already runs each request on a pool thread; for waiting, async frees that thread; for CPU work, queues to the same pool.
What is thread pool starvation?
The pool has a limited number of threads and grows slowly. If request threads block on .Result or .Wait() (both block the thread until the Task ends) or a long synchronous call, the pool runs out and new requests queue. Latency climbs even though the CPU is idle. The fix is async all the way, not more threads.
ReportedHow do you handle exceptions in Parallel.ForEach?
Any exception from a body is caught by the loop; it stops starting new iterations, lets the running ones finish, then throws one , and holds every failure. I catch it, log each inner exception with the lease id, and decide whether the batch can be retried. is different: 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.
ReportedCan a DbContext be used inside Parallel.ForEach?
No. A is not thread-safe and tracks one unit of work. Parallel bodies would share it and throw "A second operation was started on this context instance before a previous operation completed." I load the rows first, do the CPU work in parallel with no context, then save once; or I create one context per iteration with a factory (, which makes a fresh context on each call).
List<Lease> leases =
await context.Leases.ToListAsync();
// CPU work only: no DbContext here
Parallel.ForEach(leases, lease =>
Value(lease));
What about Parallel.ForEachAsync?
It is the async version, added in .NET 6. The body is async, the loop awaits every call, and limits how many are in flight. Use it for many independent I/O calls, not for CPU work. The await rethrows only the first failure; to get them all, keep the Task and read task.Exception.InnerExceptions, or catch inside the body and collect them.
Run it many times with a count and a checksum, not once. Assert that every input produced exactly one result, that the total matches the sequential version, and that no result is null. To expose a race, add a test-only delay inside the body to widen the window, or run a stress loop. A single green run proves very little.
for (int run = 0; run < 20; run++)
{
var values = Job.ValueAll(leases);
Assert.Equal(10_000, values.Length);
Assert.All(values, v =>
Assert.NotNull(v));
}
Production bugs, and how to tell them
Each one: what you see, why, the fix in code, and the line to say.
Parallel.ForEach lost 3,200 valuations
BMW FS risk report, first night after release 4.2:
"Open leases: 240,000. Valued this month: 236,800."
(no exception in the job log on that run)
Cause
The body added to one . A is not thread-safe: two threads can read the same internal size, both write to the same , and one add is lost. When the list grows, two threads can also resize the same hidden array; on a different night that threw ArgumentException: Source array was not long enough inside an .
Fix
Size a Valuation[] before the loop and let each iteration write its own index. No two threads write the same , so no lock is needed. Sum and save after the loop, on one thread. A test runs 10,000 leases and asserts that every has a value.
Before · ValuationJob.cs · line 1 · 1 of 4A growable list of valuations; the <Valuation> part says what it holds. It is not thread-safe, and the parallel body below writes to it.
Say it
A List is not thread-safe, so Parallel.ForEach lost adds. We write each result into its own array by index, then save once after the loop. That removes the race and the lock.
The async lambda returned before the saves finished
The worker log:
"Valuation job finished in 03:10"
Then, twenty minutes later:
Unhandled exception. System.ObjectDisposedException: Cannot access a disposed object.
Process exited with code 134.
Cause
takes an , a method that returns nothing. An lambda passed there becomes : the loop calls it, it runs to its first , and returns to the loop at once. thinks the iteration is finished. The loop returns, the job logs success, the host disposes the writer created for this job, and the saves still in flight fail. An exception from is raised on the pool, not on the caller, so it crashes the process.
Fix
Use await Parallel.ForEachAsync(...). It takes an async body, awaits every call, and returns only when all of them finish. limits how many saves are in flight, and the scoped writer stays alive until the loop returns. A failure comes back at the await.
Diagram · step 1Parallel.ForEach gets an async lambda. It expects a method that returns nothing.
The jobone run
Thread poolreused workers
The writersaves rows
Azure SQLvaluation table
async lambda
Before · ValuationJob.cs · line 1 · 1 of 3The lambda is async, but Parallel.ForEach wants an Action: a method that returns nothing. The compiler makes it async void.
Say it
Parallel.ForEach cannot await an async lambda; it becomes async void, so the loop returns before the saves finish. We moved to Parallel.ForEachAsync with a max degree, so the await covers every save and a failure reaches the caller.
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
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.
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.
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.
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.
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.
Valuation job02:00 ET
240,000 leasesthe input
Shared List236,800 rows
Azure SQL236,800 of 240,000
Dealer feed05:30 ET
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
dotnet new console -n ParallelLab. Make a , then .
Print list.Count: in five runs on this Mac it was between roughly 16,000 and 66,000, never 200,000. Nothing threw.
Change the List to a : the count is 200,000 every run, but order is not kept.
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."
SourcesSourced: 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
Don't sayA Task is a thread.SayA Task is a promise. It may use a pool thread, or no thread at all while an I/O wait is pending.
Don't sayParallel.ForEach makes every loop faster.SayOnly CPU-bound work with a body big enough to pay the split cost (the time to cut up the list and hand it out). I/O, small collections and shared locks make it slower.
Don't sayasync makes the method run on a new thread.SayIt frees the current thread at an await; the continuation runs on a pool thread later. No thread is created for the wait.
Don't sayParallel.ForEach stops at the first exception.SayIt stops starting new iterations, lets the running ones finish, then throws one AggregateException holding every failure that happened.
Traps
What should you say instead? Tap to check.
Don't sayA Task is a thread.
Say instead
A Task is a promise. It may use a pool thread, or no thread at all while an I/O wait is pending.
Don't sayParallel.ForEach makes every loop faster.
Say instead
Only CPU-bound work with a body big enough to pay the split cost (the time to cut up the list and hand it out). I/O, small collections and shared locks make it slower.
Don't sayasync makes the method run on a new thread.
Say instead
It frees the current thread at an await; the continuation runs on a pool thread later. No thread is created for the wait.
Don't sayParallel.ForEach stops at the first exception.
Say instead
It stops starting new iterations, lets the running ones finish, then throws one AggregateException holding every failure that happened.
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.
The sequential method is the reference. is pure: same truck, same fee.
Lending.sln · where these 2 files sit
Fees/
Stuck? Hints
The body is pure, so the only shared thing you need is the place results go.
A ConcurrentBag accepts adds from many threads; a List does not.
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.
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
Parallel.ForEachAsync has an async body and a MaxDegreeOfParallelism option.
Use a ConcurrentBag for values and failures; use Interlocked for the call count.
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.