Threading: Parallel Report Cards and Race Conditions – C# | Lesson 54
Introduction
Lesson 53 made waiting cheap. Some work, however, is not waiting: building 1,006 report cards keeps the processor busy the whole time. A modern computer has many processor cores, and by default a C# program uses only one of them for such work.
Parallel code splits the work across cores, so several report cards are built at the same moment. It is powerful, and it brings a new kind of bug: when two threads change the same data at once, results silently go wrong.
By the end of this lesson, you will be able to:
- Explain threads, cores, and the thread pool.
- Speed up CPU-bound work with
Parallel.ForEachandParallel.For. - Recognise a race condition, and explain why
count++is not safe. - Fix shared updates with
Interlockedandlock. - Choose thread-safe collections such as
ConcurrentDictionary.
Why this matters
At the end of term, the SMS generates report cards for every student, recalculates ranks, and exports data for the board. Done one at a time on one core, that keeps staff waiting. Spread across 16 cores, the same work finished almost three times faster in this lesson.
Race conditions matter even if you never write Parallel.For. A Web API serves many requests at
once, on many threads. Any data shared between requests, such as the static counter of Lesson 42,
is exposed to exactly the bug this lesson shows.
The concept
Example: threads, cores, and the pool
| Term | Meaning |
|---|---|
| Core | One part of the processor that runs code; this laptop has 16 |
| Thread | One path of execution through the code; many threads can share a core |
| Thread pool | Threads that .NET keeps ready and reuses, instead of creating new ones |
| Task | A unit of work; the thread pool runs tasks on its threads |
You rarely create threads by hand with new Thread(...). Tasks, Parallel, and async all use
the thread pool, which manages threads far better than hand-written code.
Example: when parallel helps
Think of the exam office filling report cards. One clerk fills 1,006 cards one after another. Sixteen clerks at sixteen desks each take a pile and finish much sooner, because each card is independent of the others.
Parallel code helps when the work is CPU-bound and the items are independent. It does not help
when each item depends on the previous one, or when the work is mostly waiting, which is a job for
async.
Example: what a race condition is
count++ looks like one step, but the processor does three: read the value, add 1, write it
back. Two threads can both read 41, both add 1, and both write 42. One update is lost.
| Time | Thread A | Thread B | count |
|---|---|---|---|
| 1 | reads 41 | 41 | |
| 2 | reads 41 | 41 | |
| 3 | writes 42 | 42 | |
| 4 | writes 42 | 42 (one update lost) |
Think of two clerks updating one tally sheet. Both read "41", both add their one, both write "42". The sheet should say 43.
Apply it to the SMS
Step 1: the CPU-bound work
Each report card is built from the student's real marks. "Rendering" it, which in a real system would mean producing a PDF, is simulated with 2,000 rounds of hashing, which keeps a core busy for about a millisecond:
using System.Security.Cryptography;
using System.Text;
// Lesson 54: CPU-bound work. Building one student's report card text is real work from real marks;
// "rendering" it is simulated with repeated hashing, about as heavy as producing a PDF page.
static class ReportCards
{
public static string Render(string admissionNumber, List<MarkRecord> marks)
{
StringBuilder text = new StringBuilder($"Report card {admissionNumber}\n");
foreach (MarkRecord mark in marks)
{
text.Append($"{mark.Exam} {mark.Subject}: {mark.MarksObtained?.ToString() ?? "absent"}\n");
}
byte[] data = Encoding.UTF8.GetBytes(text.ToString());
for (int i = 0; i < 2000; i++) // the simulated rendering cost
{
data = SHA256.HashData(data);
}
return Convert.ToHexString(data)[..12]; // a short fingerprint of the finished card
}
}
The fingerprint at the end proves that the sequential and parallel runs produced identical cards.
Step 2: the program
Show src/SchoolApp.Console/Program.cs
// Lesson 54: threading. Report cards for 1,006 students on every processor core, and race conditions.
using System.Collections.Concurrent;
using System.Diagnostics;
Console.WriteLine($"Processor cores: {Environment.ProcessorCount}");
Dictionary<string, List<MarkRecord>> marksByStudent = DataFiles.ReadMarks()
.GroupBy(m => m.AdmissionNumber)
.ToDictionary(g => g.Key, g => g.ToList());
// 1. One core: report cards one after another.
Stopwatch timer = Stopwatch.StartNew();
Dictionary<string, string> oneByOne = new Dictionary<string, string>();
foreach (KeyValuePair<string, List<MarkRecord>> pair in marksByStudent)
{
oneByOne[pair.Key] = ReportCards.Render(pair.Key, pair.Value);
}
Console.WriteLine($"Sequential: {oneByOne.Count} report cards in {timer.ElapsedMilliseconds} ms");
// 2. All cores: Parallel.ForEach, results in a thread-safe dictionary.
timer.Restart();
ConcurrentDictionary<string, string> inParallel = new ConcurrentDictionary<string, string>();
ConcurrentBag<int> threadsUsed = new ConcurrentBag<int>();
Parallel.ForEach(marksByStudent, pair =>
{
inParallel[pair.Key] = ReportCards.Render(pair.Key, pair.Value);
threadsUsed.Add(Environment.CurrentManagedThreadId);
});
Console.WriteLine($"Parallel: {inParallel.Count} report cards in {timer.ElapsedMilliseconds} ms on {threadsUsed.Distinct().Count()} threads");
Console.WriteLine($"Same results: {oneByOne.All(pair => inParallel[pair.Key] == pair.Value)}");
// 3. A race condition: many threads add 1 to one shared int.
int unsafeCount = 0;
Parallel.For(0, 1_000_000, _ => unsafeCount++);
Console.WriteLine($"count++ from many threads: {unsafeCount} (should be 1000000)");
// 4. Two fixes: Interlocked for a single number, lock for anything bigger.
int interlockedCount = 0;
Parallel.For(0, 1_000_000, _ => Interlocked.Increment(ref interlockedCount));
int lockedCount = 0;
Lock gate = new Lock();
Parallel.For(0, 1_000_000, _ =>
{
lock (gate)
{
lockedCount++;
}
});
Console.WriteLine($"Interlocked: {interlockedCount}; lock: {lockedCount}");
// 5. Task.Run moves CPU work off the caller's thread, so a screen or request can stay responsive.
string aaravCard = await Task.Run(() => ReportCards.Render("ADM-2017-0001", marksByStudent["ADM-2017-0001"]));
Console.WriteLine($"Aarav's card fingerprint: {aaravCard}");
Processor cores: 16
Sequential: 1006 report cards in 1226 ms
Parallel: 1006 report cards in 387 ms on 17 threads
Same results: True
count++ from many threads: 62556 (should be 1000000)
Interlocked: 1000000; lock: 1000000
Aarav's card fingerprint: 244EA6DC2CC1
Your timings and the lost count will differ on every run; that randomness is the nature of threads. Three earlier runs took 1,162, 1,131, and 1,199 ms sequentially, and 429, 384, and 428 ms in parallel.
Example: about three times faster, not sixteen
Sixteen cores gave roughly a threefold speed-up, not sixteenfold. Real parallel code always falls short of the ideal, for three reasons:
- Splitting the work and collecting the results has a cost.
- The cores share memory, so they wait for each other.
- Part of every program, such as reading the file, runs on one thread only.
threadsUsed counted 17 distinct threads. The thread pool may use more threads than cores; it
adjusts the number while the work runs. Same results: True confirms the parallel cards match the
sequential ones exactly.
Example: a race condition, measured
One million count++ operations from many threads produced 62,556, not 1,000,000. More than
90% of the updates were lost to the race shown in the table above. Each run gives a different wrong
number, which is what makes race conditions so hard to find in real systems.
Example: Interlocked and lock
| Fix | How | Use for |
|---|---|---|
Interlocked.Increment(ref count) | The processor does read, add, write as one uninterruptible step | A single number: counters, totals of int or long |
lock (gate) { ... } | Only one thread at a time may run the block | Anything larger: several fields, a decimal, a list |
Both produced exactly 1,000,000. Lock is a dedicated lock type added in .NET 9; older code locks
on a private object. Keep the locked block as small as possible: every other thread waits while
one is inside.
Example: thread-safe collections
A normal Dictionary is not safe when several threads add to it at once; it can lose items or
corrupt itself. ConcurrentDictionary and ConcurrentBag, from
System.Collections.Concurrent, handle that internally.
The parallel report cards go into a ConcurrentDictionary, and the thread Ids into a
ConcurrentBag. Each thread works only on its own student's card, so the only shared things are
the two collections, and both are built for sharing.
Example: the Lesson 53 counter, fixed
TextMessageGateway.Sent from Lesson 53 used Sent++, and the "together" sends could finish on
several threads at once. The gateway now keeps a private field and calls
Interlocked.Increment(ref _sent), the same fix as above.
Show src/SchoolApp.Console/TextMessageGateway.cs
// Lesson 53: a stand-in for a text-message provider. A real provider is called over the network,
// so each message takes time; Task.Delay pretends to be that wait without blocking a thread.
// Lesson 54: the counter is now thread-safe, because sends can finish on several threads at once.
class TextMessageGateway
{
private int _sent;
public int Sent => _sent;
public async Task<string> SendAsync(string phone, string text, CancellationToken cancellationToken = default)
{
if (phone.Length != 10)
{
throw new ArgumentException($"Invalid phone number: {phone}", nameof(phone));
}
await Task.Delay(250, cancellationToken); // the "network" wait
Interlocked.Increment(ref _sent);
return $"sent to {phone}";
}
}
Example: Task.Run for CPU work
await Task.Run(() => ...) runs CPU-bound work on a thread-pool thread while the caller awaits it.
In a desktop app, that keeps the window responsive. In a Web API, it brings little: the request
thread would only switch places with another pool thread, so heavy work there belongs in a
background job (Module 12).
Common mistakes
count++ortotal += xon shared data from many threads. Updates are lost. Fix:Interlockedfor numbers,lockfor anything else.- A normal
DictionaryorListfilled from parallel code. It can corrupt. Fix: a concurrent collection, or collect results per thread and combine afterwards. - Large locked blocks. Threads queue up and parallel code runs slower than sequential. Fix: calculate outside the lock; only update shared data inside.
- Parallelising tiny work items. The overhead outweighs the work. Fix: measure; parallel pays off for thousands of items or heavy items.
- Two locks taken in different orders. Each thread waits for the other forever: a deadlock, as in Lesson 25. Fix: always take locks in the same order, or avoid nested locks.
In real projects
A school's term-end report ran in parallel to save time, and the total fees collected came out slightly different every time it ran. The finance team stopped trusting the report.
The code added each section's total to one shared decimal from many threads. Nobody had noticed
the race, because each run looked reasonable on its own.
We calculated each section's total independently and added the totals together at the end, with no shared variable at all. The best fix for shared state is often to stop sharing.
Best practices
- Use
Parallelonly for CPU-bound, independent work, and measure the gain. - Prefer designs where threads share nothing: each works on its own item.
- Use
Interlockedfor shared numbers andlockfor anything bigger; keep locks small. - Use concurrent collections when several threads add to one collection.
- Treat every static or shared field in a web application as accessed by many threads.
Recap
- Cores run threads; the thread pool reuses threads for tasks and
Parallelwork. Parallel.ForEachbuilt 1,006 report cards about three times faster on 16 cores.count++is read, add, write; from many threads it lost over 90% of the updates.Interlockedandlockgave exactly 1,000,000.- Concurrent collections are safe for many threads; normal ones are not.
- Avoid sharing state when you can; it removes races altogether.
Homework
Library module task: Total the fines of 10,000 returns in parallel, where return number i
is i % 15 days late and the fine is 2 per day. Compute the expected total with LINQ, then add the
fines in Parallel.For twice: once with a plain +=, and once with a lock.
A correct result looks like:
Expected total: 139950
Safe total: 139950
Unsafe total is correct: False
Show the solution
// Library homework, Lesson 54: total the fines of 10,000 returns on all cores, safely.
// Run with: dotnet run library-assignment/054-library-threading.cs
// Return number i is (i % 15) days late, and the fine is 2 per day.
const int Returns = 10_000;
decimal expected = Enumerable.Range(0, Returns).Sum(i => (i % 15) * 2m);
decimal unsafeTotal = 0;
Parallel.For(0, Returns, i => unsafeTotal += (i % 15) * 2m); // a race: += is read, add, write
decimal safeTotal = 0;
Lock gate = new Lock();
Parallel.For(0, Returns, i =>
{
decimal fine = (i % 15) * 2m; // calculate outside the lock
lock (gate)
{
safeTotal += fine; // only the shared update is inside
}
});
Console.WriteLine($"Expected total: {expected}");
Console.WriteLine($"Safe total: {safeTotal}");
Console.WriteLine($"Unsafe total is correct: {unsafeTotal == expected}");
Interlocked has no method for decimal, so a lock protects the total. The fine is calculated
outside the lock, so threads hold it only for the one addition. The unsafe total was wrong in every
test run; on a rare lucky run it could match, which is exactly why races escape testing.
Quiz
Which kind of work does Parallel.ForEach speed up?
Show answer
Answer: B. CPU-bound, independent items Waiting is for async; parallel is for busy processors and independent items.
Why does count++ lose updates when many threads run it?
Show answer
Answer: B. It is three steps, and threads can interleave between them Two threads can read the same value and both write the same result.
Which fix suits a shared decimal total?
Show answer
Answer: B. lock Interlocked has no decimal methods; a lock protects any update.
Why did 16 cores give only about a threefold speed-up?
Show answer
Answer: B. Splitting work, shared memory, and single-threaded parts all cost time Real parallel code always falls short of the ideal.
Which collection is safe for many threads adding at once?
Show answer
Answer: C. ConcurrentDictionary Concurrent collections handle simultaneous updates internally.
Interview questions
Questions are grouped by experience level. Click a question to see a model answer. All questions: Advanced C# interview questions.
Fresher (0-2 yrs)
F What is the difference between a thread and a task? concept
A thread is a path of execution managed by the operating system. A task is a unit of work that
.NET schedules, usually on a reusable thread-pool thread. Modern code uses tasks, Parallel, and
async instead of creating threads by hand.
F What is a race condition? concept
A bug where the result depends on the timing of threads that share data. Two threads doing
count++ can both read the same value and write the same result, losing an update; one million
increments gave about 62,000.
F What does Parallel.ForEach do? concept
It processes the items of a collection on several thread-pool threads at once, using multiple cores. It suits CPU-bound, independent work, such as building 1,006 report cards about three times faster on 16 cores.
Mid (3-5 yrs)
M When would you use Interlocked instead of lock? concept
Interlocked performs a single update on one number, such as increment or add for int and
long, as one uninterruptible step, without blocking. Use lock when several fields change
together, for types like decimal, or for collections.
M Why might parallel code be much less than N times faster on N cores? concept
Splitting and combining work costs time, cores share memory bandwidth, locks make threads wait, and parts of the program run on one thread only (Amdahl's law). Very small work items can even be slower in parallel.
M Is Dictionary thread-safe? What do you use instead? concept
No: adding from several threads at once can lose items or corrupt it. Use ConcurrentDictionary
(or other System.Collections.Concurrent types), or let each thread build its own results and
combine them afterwards.
Senior (6-9 yrs)
S A parallel term-end report gives a slightly different fee total each run. What is wrong, and how do you fix it? scenario
Threads add to one shared total without protection, so updates are lost in a race. Fix it by computing independent partial totals per section or thread and adding them at the end, or by protecting the shared total with a lock. Then test repeatedly with production-sized data.
How to answer: Recognise the symptom of nondeterminism, name the race, give the share-nothing fix first and the lock as an alternative, and mention why such bugs pass normal tests.
S What is a deadlock with locks, and how do you avoid one? concept
Two threads each hold one lock and wait for the lock the other holds, so both wait forever. Avoid it by taking locks in a fixed order, keeping locked sections small, avoiding nested locks, and never awaiting or calling unknown code while holding a lock.
How to answer: Explain the circular wait, prevention rules, and diagnosis with a debugger or dump showing thread stacks. Relate it to the SQL deadlocks of Lesson 25.
Architect / Lead (10+ yrs)
A How do you design a web application so that concurrent requests do not corrupt shared state? design
Keep request handling stateless: per-request objects, no mutable static fields, data in the database with transactions and constraints. Where in-memory sharing is needed, such as caches, use thread-safe types and immutable data, and keep heavy CPU work in background jobs.
How to answer: Cover the request model (many threads), where state should live, synchronization choices, and service lifetimes in dependency injection (singleton services must be thread-safe).
Resources
- Code for this lesson: git tag
lesson-54-threading(code repository coming soon) - Files:
src/SchoolApp.Console/ReportCards.cs,src/SchoolApp.Console/Program.cs,src/SchoolApp.Console/TextMessageGateway.cs,library-assignment/054-library-threading.cs - Video: coming soon
What's next
Every object created in this module took memory, and none of it was freed by hand. Next: how
.NET manages memory, the garbage collector, IDisposable, and how events can leak memory.
Lesson 55: Memory and garbage collection
Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.