The async post was about not wasting threads. This one is about what goes wrong when several threads touch the same data. Concurrency bugs are the worst kind: they do not reproduce, they pass every test, and they appear only under production load. The good news is that the whole subject reduces to one rule, and most of this post is about the several ways to satisfy it.
One rule
Shared mutable state accessed by more than one thread, without synchronisation, is a bug.
Three conditions, all required. Remove any one and you are safe:
- Not shared: a local variable per thread is always fine.
- Not mutable: immutable data can be read by any number of threads forever.
- Synchronised: if it must be shared and mutable, coordinate access.
Most of the good advice in concurrent programming is really about removing the first two, so you rarely have to get the third one right.
The race condition
public class BookCounter
{
private int _count;
public void Increment() => _count++;
public int Count => _count;
}
var counter = new BookCounter();
Parallel.For(0, 100_000, _ => counter.Increment());
Console.WriteLine(counter.Count); // 98,412. Or 99,777. Never reliably 100,000.
_count++ looks like one operation. It is three:
- read
_countinto a register - add 1
- write the register back
Two threads can both read 41, both compute 42, and both write 42. One increment vanished. That is a race condition: the result depends on the interleaving of threads, which is decided by the OS scheduler and is different every run.
This is why the bug is so painful. It happens on maybe one in ten thousand increments, so it never shows up on your machine, and in production it manifests as a total that is slightly wrong.
lock
The standard fix is mutual exclusion:
public class BookCounter
{
private readonly object _gate = new();
private int _count;
public void Increment()
{
lock (_gate)
{
_count++; // only one thread can be inside at a time
}
}
}
lock compiles into Monitor.Enter/Monitor.Exit in a try/finally, so the lock is released even if the body throws.
The conventions matter:
Lock on a private, readonly object. Never lock(this), never lock(typeof(X)), never on a string: all of those are visible to outside code, which could take the same lock and deadlock you in a way you cannot see from your own source. (In .NET 9 there is a dedicated System.Threading.Lock type that is slightly faster and harder to misuse; the guidance is the same.)
Every access must take the lock, reads included. One unsynchronised read can observe a half-updated object, or a stale cached value.
Keep the body short. Everything inside is serialised, so a lock around slow work destroys the concurrency you were trying to exploit.
Never await inside a lock. It does not compile with lock, and for good reason: a Monitor lock is owned by a thread, and after an await you may be on a different one. When you need mutual exclusion around async work, use SemaphoreSlim:
private readonly SemaphoreSlim _gate = new(1, 1);
await _gate.WaitAsync(ct);
try { await SaveAsync(); }
finally { _gate.Release(); }
Interlocked, for the simple cases
A full lock is overkill for a single counter. Interlocked uses a CPU instruction that performs the whole read-modify-write atomically:
private int _count;
public void Increment() => Interlocked.Increment(ref _count);
Faster than a lock (no kernel involvement, no contention overhead) and impossible to get wrong. The family is small and useful:
Interlocked.Increment(ref x);
Interlocked.Add(ref x, 5);
Interlocked.Exchange(ref x, newValue); // set, return the old value
Interlocked.CompareExchange(ref x, newValue, expected); // set only if unchanged
CompareExchange is the foundation of lock-free algorithms, and is how ConcurrentDictionary avoids locking on reads.
Deadlock
Two locks, two threads, opposite order:
// Thread A
lock (_booksLock) { lock (_ordersLock) { ... } }
// Thread B
lock (_ordersLock) { lock (_booksLock) { ... } }
A holds books and wants orders; B holds orders and wants books. Neither will ever release. The process hangs with no exception and no log entry, the hardest failure mode to diagnose, because there is nothing to see.
Three defences:
- Take multiple locks in a consistent global order, everywhere in the codebase. Document it.
- Do not call unknown code while holding a lock, an event handler, a virtual method, a delegate the caller supplied. It might take another lock, and now the ordering is out of your hands.
- Best of all, hold only one lock. If you need two, that is a design smell worth fixing.
Visibility, and why volatile exists
Beyond interleaving, there is a subtler problem: a write by one thread may not be visible to another. The compiler, the JIT and the CPU are all allowed to reorder and cache reads and writes, because they only guarantee correctness for a single thread.
private bool _stop; // set by another thread
while (!_stop) { DoWork(); } // may loop forever, the read can be hoisted out
The JIT may cache _stop in a register once, and the loop never sees the update.
volatile prevents that caching and reordering for a single field:
private volatile bool _stop;
But volatile fixes only visibility, not atomicity: volatile int x; x++; is still a race. In practice you rarely want it: a lock provides both visibility and atomicity, Interlocked gives both for a single variable, and for cancellation you should be using a CancellationToken anyway, which handles all of this correctly.
Know what volatile does for interviews; reach for the higher-level tools in code.
Concurrent collections
As the collections post said, a plain Dictionary<K,V> written from two threads can corrupt its internal buckets, historically producing reads that never terminate. Use the concurrent versions:
var cache = new ConcurrentDictionary<int, Book>();
var book = cache.GetOrAdd(id, key => _repository.Get(key)); // atomic
cache.AddOrUpdate(id, book, (k, existing) => book);
cache.TryRemove(id, out _);
One caveat that catches people: GetOrAdd’s factory delegate is not guaranteed to run only once for a key under contention: two threads may both run it, and one result is discarded. If the factory is expensive or has side effects, wrap the value in a Lazy<T>:
var cache = new ConcurrentDictionary<int, Lazy<Book>>();
var book = cache.GetOrAdd(id, k => new Lazy<Book>(() => _repository.Get(k))).Value;
ConcurrentQueue<T>, ConcurrentBag<T> and BlockingCollection<T> cover producer/consumer patterns, and Channel<T> is the modern async-friendly version.
Immutability, the real answer
Everything above is mechanism. The reliable strategy is to have less shared mutable state.
Immutable data needs no synchronisation. A record with init properties, an ImmutableArray<T>, a string: any number of threads can read them safely forever, because there is nothing to race on. This is the practical payoff of the records post, and the reason functional languages have an easier time with concurrency.
Confine state to one thread or one scope. ASP.NET Core does this by default: scoped services get one instance per request, so most application code is never touched concurrently. Concurrency bugs in web apps almost always come from something registered as a singleton holding mutable state, or a static field. Look there first.
Pass messages instead of sharing memory. A Channel<T> or a queue moves data between threads with no shared mutable object at all.
A useful checklist for reviewing a class: is it a singleton? Does it have mutable fields? If both, it needs synchronisation or it needs redesigning.
Threads, tasks and parallelism
Three related things people conflate:
Thread: an OS thread. Expensive (about 1 MB of stack), and you should essentially never create one directly in modern code.Task: the promise from the async post. May or may not involve a thread.Parallel.For/Parallel.ForEach/ PLINQ: data parallelism: split a CPU-bound workload across cores.
Parallel.ForEach(books, book => Process(book)); // CPU-bound, uses the pool
var total = books.AsParallel().Sum(b => Score(b)); // PLINQ
These are for CPU-bound work over many items. Do not use them for I/O: Task.WhenAll with async calls is the right tool there, and Parallel.ForEachAsync is the modern option when you need both concurrency limits and async.
And note that any lambda you pass to Parallel.ForEach runs on many threads at once, so anything it touches outside itself falls straight back under the one rule at the top of this post.
The same idea inside Java, Go and Rust
Java (Netflix, Android). synchronized, volatile with the same semantics, AtomicInteger as Interlocked, and ConcurrentHashMap. Java formalised the memory model first, in JSR-133, and .NET’s is descended from the same thinking. Java’s virtual threads (Project Loom) are now doing what Go does, cheap threads that make blocking acceptable again.
Go (Google). The slogan is “do not communicate by sharing memory; share memory by communicating.” Goroutines plus channels make message passing the default, and the race detector (go test -race) is built into the toolchain, genuinely the best tool in this space, and worth mentioning as something .NET lacks an equivalent for.
Rust. The strongest position: data races are a compile error. Ownership means one mutable reference or many immutable ones, never both, enforced by the borrow checker, and the Send/Sync traits decide what may cross a thread boundary. “Fearless concurrency” is the marketing phrase and it is largely accurate.
JavaScript (Meta). Single-threaded by design, so there are no data races at all in normal code: Web Workers communicate by copying messages. It is the ultimate application of “do not share mutable state”, achieved by making sharing impossible.
Python. The Global Interpreter Lock serialises bytecode execution, which prevents some races and prevents CPU parallelism at the same time. multiprocessing sidesteps it with separate processes, which is again message passing.
The convergence: every modern answer is either immutability, message passing, or compile-time enforcement. Manual locking is the oldest tool and the one everyone is trying to use less.
Summary
- The rule: shared, mutable, concurrent, unsynchronised: remove any one of those and you are safe.
_count++is read, modify, write. Two threads interleaving lose updates, unpredictably and rarely, which is what makes race conditions so hard to find.lockgives mutual exclusion. Lock on a private readonly object, take it on reads as well as writes, keep the body short, and neverawaitinside it: useSemaphoreSlimfor async.Interlockedperforms atomic read-modify-write on a single variable, faster than a lock and hard to misuse.- Deadlock comes from taking two locks in different orders. Use a consistent order, do not call unknown code while holding a lock, and prefer holding only one.
volatilefixes visibility, not atomicity, and is rarely the right tool.- Plain
Dictionarycan corrupt under concurrent writes; useConcurrentDictionary, and wrap expensiveGetOrAddfactories inLazy<T>. - The durable answers are immutability, thread confinement (scoped DI, no mutable statics) and message passing.
- In a web app, suspect singletons and statics first.
Interview questions and answers
1. What is a race condition?
A defect where the result depends on the relative timing of operations on different threads. The canonical case is two threads performing a read-modify-write on the same variable: both read the old value, both compute the same new value, and one update is lost. It is intermittent by nature, since the scheduler decides the interleaving.
2. Why is i++ not atomic?
Because it compiles to three separate operations (load the value, increment it, store it back), and a thread can be preempted between any of them. Another thread reading in between sees the stale value. Interlocked.Increment performs the whole sequence as a single atomic CPU instruction.
3. What does the lock statement do?
It acquires a monitor on the given object, blocking other threads that try to lock the same object until it is released, and compiles into Monitor.Enter followed by a try/finally calling Monitor.Exit. It provides both mutual exclusion and memory visibility for everything inside the block.
4. Why should you not lock on this or on a string?
Because both are reachable from outside the class. Any other code can lock on the same instance, or on an interned string literal shared process-wide, creating contention or a deadlock that is invisible from your own source. Lock on a private readonly object owned solely by the class.
5. What causes a deadlock and how do you avoid it?
Two or more threads each holding a lock the other needs, in a cycle, so none can proceed. Avoid it by acquiring multiple locks in a single consistent order throughout the codebase, by never invoking caller-supplied or virtual code while holding a lock, and by designing so that only one lock is ever needed.
6. What does volatile do?
It prevents the compiler, JIT and CPU from caching or reordering reads and writes of that field, so a write on one thread becomes visible to others. It does not make compound operations atomic: a volatile increment is still a race. A lock or Interlocked is usually the correct tool.
7. What is the difference between Interlocked and lock?
Interlocked performs a single atomic operation on one variable using a hardware instruction, with no blocking and very low cost. lock protects an arbitrary block of code that may touch many variables, at the price of blocking other threads. Use Interlocked for counters and flags, lock for multi-step invariants.
8. Why can’t you await inside a lock?
A monitor lock is owned by the thread that acquired it, and the continuation after an await may resume on a different thread, which could not legally release it. The compiler forbids it. Use SemaphoreSlim with WaitAsync and a finally calling Release when async work needs mutual exclusion.
9. What is thread-safe about ConcurrentDictionary that Dictionary lacks?
ConcurrentDictionary uses fine-grained locking for writes and lock-free reads, so concurrent access is safe and atomic operations such as GetOrAdd and AddOrUpdate are available. A plain Dictionary can have its internal bucket structure corrupted by concurrent writes, producing lost data or reads that never terminate.
10. What is the most reliable way to write thread-safe code?
Avoid shared mutable state. Use immutable types and immutable collections, confine mutable state to a single thread or a request scope, and pass data between threads with channels or queues rather than sharing objects. Locking is the fallback for when none of that is possible, and it is the part that is easiest to get wrong.