Skip to main content

Threading: Parallel Report Cards and Race Conditions – C# | Lesson 54

Published / updated

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.ForEach and Parallel.For.
  • Recognise a race condition, and explain why count++ is not safe.
  • Fix shared updates with Interlocked and lock.
  • 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​

TermMeaning
CoreOne part of the processor that runs code; this laptop has 16
ThreadOne path of execution through the code; many threads can share a core
Thread poolThreads that .NET keeps ready and reuses, instead of creating new ones
TaskA 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.

TimeThread AThread Bcount
1reads 4141
2reads 4141
3writes 4242
4writes 4242 (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:

src/SchoolApp.Console/ReportCards.cs
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
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}");
Output
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​

FixHowUse for
Interlocked.Increment(ref count)The processor does read, add, write as one uninterruptible stepA single number: counters, totals of int or long
lock (gate) { ... }Only one thread at a time may run the blockAnything 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
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​

  1. count++ or total += x on shared data from many threads. Updates are lost. Fix: Interlocked for numbers, lock for anything else.
  2. A normal Dictionary or List filled from parallel code. It can corrupt. Fix: a concurrent collection, or collect results per thread and combine afterwards.
  3. Large locked blocks. Threads queue up and parallel code runs slower than sequential. Fix: calculate outside the lock; only update shared data inside.
  4. Parallelising tiny work items. The overhead outweighs the work. Fix: measure; parallel pays off for thousands of items or heavy items.
  5. 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​

From 16 years of .NET 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 Parallel only for CPU-bound, independent work, and measure the gain.
  • Prefer designs where threads share nothing: each works on its own item.
  • Use Interlocked for shared numbers and lock for 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 Parallel work.
  • Parallel.ForEach built 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.
  • Interlocked and lock gave 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:

Output
Expected total: 139950
Safe total: 139950
Unsafe total is correct: False
Show the solution
library-assignment/054-library-threading.cs
// 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​

  1. Which kind of work does Parallel.ForEach speed up?

    1. Waiting for the database
    2. CPU-bound, independent items
    3. Items that depend on each other
    4. Any work at all
    Show answer

    Answer: B. CPU-bound, independent items Waiting is for async; parallel is for busy processors and independent items.

  2. Why does count++ lose updates when many threads run it?

    1. int is too small
    2. It is three steps, and threads can interleave between them
    3. Parallel.For is broken
    4. The thread pool skips items
    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.

  3. Which fix suits a shared decimal total?

    1. Interlocked.Increment
    2. lock
    3. async
    4. volatile
    Show answer

    Answer: B. lock Interlocked has no decimal methods; a lock protects any update.

  4. Why did 16 cores give only about a threefold speed-up?

    1. A bug
    2. Splitting work, shared memory, and single-threaded parts all cost time
    3. The thread pool uses one core
    4. Hashing cannot run in parallel
    Show answer

    Answer: B. Splitting work, shared memory, and single-threaded parts all cost time Real parallel code always falls short of the ideal.

  5. Which collection is safe for many threads adding at once?

    1. Dictionary
    2. List
    3. ConcurrentDictionary
    4. Array
    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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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

See the example · Permalink

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.