Skip to main content

📝 Lesson 2.2: Thread Safety and Synchronization

When multiple threads touch the same data, subtle and devastating bugs appear. This lesson shows how race conditions corrupt state, and the tools — lock, Interlocked, concurrent collections — that keep shared data safe.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain race conditions and why count++ isn't atomic
  • Protect a critical section with the lock statement
  • Use Interlocked for fast, lock-free atomic operations
  • Choose the right concurrent collection
  • Recognize and avoid deadlocks

Estimated Time: 75 minutes

Project: Fix a broken concurrent counter and make shared state thread-safe.

In This Lesson

The Race Condition

Threads share memory (Lesson 2.1). When two threads modify the same variable without coordination, the result depends on unpredictable timing — a race condition. The classic example is a shared counter:

int count = 0;

// 4 tasks each incrementing 1,000,000 times → we expect 4,000,000
Task[] tasks = Enumerable.Range(0, 4)
    .Select(_ => Task.Run(() =>
    {
        for (int i = 0; i < 1_000_000; i++)
        {
            count++;          // ❌ NOT thread-safe
        }
    }))
    .ToArray();

await Task.WhenAll(tasks);
Console.WriteLine(count);     // ⚠️ Some number LESS than 4,000,000 — and different each run

⚠️ Why count++ loses updates

count++ looks atomic but is really three steps: read count, add one, write it back. Two threads can read the same value, both add one, and both write back the same result — so two increments become one. That lost update is the race.

graph TD A["count = 41"] --> B["Thread 1 reads 41"] A --> C["Thread 2 reads 41"] B --> D["Thread 1 writes 42"] C --> E["Thread 2 writes 42"] D --> F["Final: 42
(should be 43!)"] E --> F style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style F fill:#ffebee,stroke:#c62828,stroke-width:2px

📖 Definition

Critical section: a piece of code that accesses shared state and must not be run by more than one thread at a time. Synchronization is coordinating threads so critical sections don't overlap.

The lock Statement

The simplest fix is lock. It ensures only one thread at a time can enter the guarded block; others wait their turn. You lock on a private reference-type object dedicated to that purpose:

private static readonly object _gate = new object();
private static int _count = 0;

void Increment()
{
    lock (_gate)          // only one thread inside at a time
    {
        _count++;         // now safe — read/add/write can't be interleaved
    }
}

Applied to the counter example, the total is now exactly 4,000,000 every run, because the increments can no longer interleave.

⚠️ Lock on a private, dedicated object

Use a private readonly object as the lock. Don't lock (this), lock (typeof(X)), or lock on a string — external code could lock on the same reference and cause surprising contention or deadlocks. A dedicated private gate can only be locked by your own code.

💡 Keep critical sections tiny

A lock serializes threads — while one holds it, the rest wait, losing parallelism. Do the minimum inside the lock (just the shared-state update) and keep slow work (I/O, big computations) outside it. Never await inside a lock — it's not allowed and signals a design problem.

Interlocked (Lock-Free)

For simple numeric operations, taking a lock is heavier than needed. The Interlocked class performs atomic operations directly — a single, uninterruptible CPU instruction — with no lock overhead:

using System.Threading;

long _count = 0;

void Increment()
{
    Interlocked.Increment(ref _count);   // atomic ++ — thread-safe, lock-free
}

Common atomic operations:

MethodDoes (atomically)
Interlocked.Increment(ref x)x++
Interlocked.Decrement(ref x)x--
Interlocked.Add(ref x, n)x += n
Interlocked.Exchange(ref x, v)set x = v, return old value
Interlocked.CompareExchange(ref x, v, c)if x == c, set x = v (the building block of lock-free algorithms)

✅ lock vs. Interlocked

Use Interlocked for a single atomic numeric update — it's faster than a lock. Use lock when a critical section spans multiple operations that must happen together as a unit (e.g. "check then update," or modifying several fields consistently). Interlocked can't guard a multi-step invariant.

Concurrent Collections

Standard collections like List<T> and Dictionary<TKey,TValue> are not thread-safe — concurrent writes can corrupt them or throw. Rather than wrapping every access in a lock, use the purpose-built types in System.Collections.Concurrent:

Concurrent typeReplaces / use for
ConcurrentDictionary<K,V>Dictionary — safe keyed access, with atomic AddOrUpdate/GetOrAdd
ConcurrentQueue<T>FIFO producer/consumer
ConcurrentStack<T>LIFO shared stack
ConcurrentBag<T>unordered set of items, optimized for each thread
BlockingCollection<T>producer/consumer with blocking/bounding
using System.Collections.Concurrent;

var wordCounts = new ConcurrentDictionary<string, int>();

Parallel.ForEach(words, word =>
{
    // Atomically add or increment — no explicit lock needed
    wordCounts.AddOrUpdate(word, 1, (key, existing) => existing + 1);
});

💡 They handle the locking for you

Concurrent collections use fine-grained internal synchronization (and lock-free techniques) so many threads can access them safely and efficiently. Prefer them over a plain collection guarded by one big lock — they're correct and usually faster under contention.

Deadlocks

Locks solve races but introduce a new hazard. A deadlock is when two threads each hold a lock the other needs, so both wait forever:

// Thread 1: lock A, then B     Thread 2: lock B, then A
lock (_lockA)                    lock (_lockB)
{                                {
    lock (_lockB)                    lock (_lockA)
    {                                {
        // ...                            // ...
    }                                }
}                                }
// If timed just wrong: T1 holds A waiting for B, T2 holds B waiting for A → frozen.
graph LR T1["Thread 1
holds A, wants B"] -->|"waits for"| B["Lock B"] T2["Thread 2
holds B, wants A"] -->|"waits for"| A["Lock A"] B --> T2 A --> T1 style T1 fill:#ffebee,stroke:#c62828,stroke-width:2px style T2 fill:#ffebee,stroke:#c62828,stroke-width:2px

✅ How to avoid deadlocks

  • Consistent lock ordering: if all threads always acquire A before B, the cycle can't form.
  • Hold fewer locks and for less time; avoid nesting locks when you can.
  • Don't call unknown code (callbacks, events) while holding a lock — it might try to take another lock.
  • Prefer higher-level tools (concurrent collections, Interlocked) that avoid explicit locking altogether.

Immutability as a Strategy

The safest shared data is data that can't change. If a value is immutable, threads can read it freely with zero synchronization — there's no write to race against. This is a major reason immutable types matter in concurrent code.

// A record is immutable by default (Intermediate 3.1) — safe to share across threads
public record Config(string Host, int Port, bool UseTls);

// To "change" it, create a new copy (with-expression) — the original is untouched
Config updated = current with { Port = 8080 };

💡 Design for concurrency

Where practical, prefer immutable data (records, readonly fields, ImmutableList<T> from System.Collections.Immutable) and confine mutable state to one place guarded by one clear synchronization strategy. Less shared mutable state means fewer opportunities for races — the best bugs are the ones you design out of existence.

💡 A note on volatile

You may encounter the volatile keyword, which affects how a field is read/written across threads (preventing certain caching/reordering). It's a low-level, easily-misused tool — for the vast majority of code, lock, Interlocked, and concurrent collections are the correct, clearer choices.

Exercise & Quiz

🏋️ Exercise: Fix the Broken Counter

Objective: Observe a race condition, then fix it three ways.

Instructions:

  1. Create a new project called ThreadSafety.
  2. Reproduce the race: 4 tasks each incrementing a shared int 1,000,000 times with count++. Run it a few times and note the total is wrong and varies.
  3. Fix A: guard the increment with a lock on a private object. Confirm the total is now 4,000,000.
  4. Fix B: replace the lock with Interlocked.Increment(ref count) (use a long). Confirm correctness.
  5. Fix C (bonus): use a ConcurrentDictionary<int, int> to count per-task, then sum the values.

Starter Code (the broken version):

int count = 0;
var tasks = Enumerable.Range(0, 4).Select(_ => Task.Run(() =>
{
    for (int i = 0; i < 1_000_000; i++) count++;   // race!
})).ToArray();
await Task.WhenAll(tasks);
Console.WriteLine($"Broken total: {count}");   // usually < 4,000,000

// TODO: Fix A (lock), Fix B (Interlocked)
💡 Hint

Fix A: object gate = new(); ... lock (gate) { count++; }. Fix B: declare long count = 0; and call Interlocked.Increment(ref count); instead of count++.

✅ Solution
using System.Threading;

// Fix A: lock
int countA = 0;
object gate = new();
await Task.WhenAll(Enumerable.Range(0, 4).Select(_ => Task.Run(() =>
{
    for (int i = 0; i < 1_000_000; i++)
    {
        lock (gate) { countA++; }
    }
})));
Console.WriteLine($"lock total: {countA}");        // 4000000

// Fix B: Interlocked
long countB = 0;
await Task.WhenAll(Enumerable.Range(0, 4).Select(_ => Task.Run(() =>
{
    for (int i = 0; i < 1_000_000; i++)
    {
        Interlocked.Increment(ref countB);
    }
})));
Console.WriteLine($"Interlocked total: {countB}"); // 4000000

Both print exactly 4000000. Interlocked is typically faster here because it avoids acquiring a lock a million times per thread.

🎯 Quick Quiz

Question 1: Why isn't count++ thread-safe?

Question 2: When should you use Interlocked instead of lock?

Question 3: What is a reliable way to prevent deadlocks between two locks?

Summary

🎉 Key Takeaways

  • A race condition arises when threads modify shared state without coordination; count++ is read-modify-write and loses updates.
  • lock (privateObject) { ... } serializes a critical section — keep it tiny, lock on a private object, never await inside.
  • Interlocked gives fast, lock-free atomic numeric operations for single updates.
  • Use concurrent collections (ConcurrentDictionary, ConcurrentQueue, …) instead of locking plain collections.
  • Avoid deadlocks with consistent lock ordering and fewer/shorter locks; prefer immutable shared data to sidestep synchronization entirely.

📚 Additional Resources

🚀 What's Next?

You can now make shared data safe. Next, we scale computation across cores with less manual work. In Lesson 2.3: Data Parallelism, you'll use Parallel and PLINQ to process large datasets in parallel.

🎉 Shared state, secured!

You can write correct multithreaded code. Next: effortless data parallelism.