📝 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
lockstatement - Use
Interlockedfor 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.
(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:
| Method | Does (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 type | Replaces / 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.
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:
- Create a new project called
ThreadSafety. - Reproduce the race: 4 tasks each incrementing a shared
int1,000,000 times withcount++. Run it a few times and note the total is wrong and varies. - Fix A: guard the increment with a
lockon a private object. Confirm the total is now 4,000,000. - Fix B: replace the lock with
Interlocked.Increment(ref count)(use along). Confirm correctness. - 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, neverawaitinside.Interlockedgives 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.