Lesson 8 of 45 advanced

Threading and Concurrency

Two things at once means two things can go wrong at once

Guess first

Pick an answer. You will see if you were right at the end of the lesson.

Why can count++ from two threads lose updates?

Open interactive version (quiz + challenge)

Real-world analogy

Think of two clerks updating one paper ledger. Both read the balance as 100, both add 10 in their heads, and both write 110. One update is lost, even though both clerks did their job. Here the ledger is shared memory and the clerks are threads. A lock is a rule that only one clerk may hold the pen at a time.

What is it?

Concurrency means several pieces of work make progress at the same time. It is safe when they do not share changing data, and risky when they do. Shared mutable state needs protection: a lock, an atomic operation, a concurrent collection or, best of all, no sharing.

Real-world relevance

A batch job processes 10,000 invoices with Parallel.ForEachAsync and adds each result to a shared Dictionary. It passes every test on a laptop, then in production it throws or silently loses entries. Switching to ConcurrentDictionary, or to returning results and combining them once, fixes it.

Key points

Quick check

Answer from memory — scrolling up first makes it stick less.

Why can you not use await inside a lock block?

What causes a deadlock?

Code example

int unsafeCount = 0;
int safeCount = 0;
int lockedCount = 0;
var gate = new object();

Parallel.For(0, 100_000, _ =>
{
    // Read, add, write: threads can overwrite each other
    unsafeCount++;

    // One atomic step: always correct and cheap
    Interlocked.Increment(ref safeCount);

    // Only one thread inside at a time
    lock (gate)
    {
        lockedCount++;
    }
});

Console.WriteLine($"Unsafe: {unsafeCount}");   // usually less than 100000
Console.WriteLine($"Interlocked: {safeCount}"); // always 100000
Console.WriteLine($"Locked: {lockedCount}");    // always 100000

Line-by-line walkthrough

  1. 1. Three counters start at zero. gate is an object used only as the lock.
  2. 2. Parallel.For runs the body 100,000 times on many threads at once.
  3. 3. unsafeCount++ is read, add, write. Two threads can read the same value, so increments are lost.
  4. 4. Interlocked.Increment does the whole update as one indivisible step.
  5. 5. The lock block lets one thread in at a time, so lockedCount is also correct. It costs more than Interlocked.
  6. 6. The prints show the unsafe total below 100,000 most runs, while the other two are always exact.

Spot the bug

var cache = new Dictionary<int, string>();

await Parallel.ForEachAsync(ids, async (id, ct) =>
{
    var name = await LoadNameAsync(id, ct);
    cache[id] = name;
});
Need a hint?
How many threads write to cache at the same time, and is Dictionary built for that?
Show answer
Dictionary is not thread-safe. Parallel writes can corrupt its internal buckets, causing exceptions, lost entries or an endless loop. Fix: use <code>ConcurrentDictionary&lt;int, string&gt;</code>, or collect the results with <code>Task.WhenAll</code> and build the dictionary once on a single thread.

Explain like I'm 5

Two kids reach into the same jar to count the sweets. Both write down the wrong number because they change it at the same time. A rule that only one kid touches the jar at a time fixes it.

Fun fact

Race conditions are hard to test because they depend on timing. A bug that shows once in a thousand runs on your machine can show every minute on a busy server.

Hands-on challenge

Say it out loud in 30 seconds: "What is a race condition, and how would you fix a shared counter or a shared dictionary?" Then check: did you mention read-add-write interleaving, Interlocked or lock, and ConcurrentDictionary?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook