Concurrency in C#
Concurrency in C# is not a set of classes from System.Threading — it is a model of what the CPU and the JIT are allowed to do to your code. Two threads reading and writing one field are not "two instruction streams taking turns": the compiler reorders accesses, cores hold values in their caches and registers, and the OS scheduler preempts a thread at an arbitrary point, including in the middle of count++. Everything the synchronization primitives do is constrain that freedom exactly where it hurts you.
Hence the shape of the topic. First the problem: a race condition as the result of unsynchronized interleaving. Then the tools, each with its price: lock gives mutual exclusion and barriers, but is reentrant and cannot survive an await; SemaphoreSlim admits N holders and can wait asynchronously, but is not reentrant and can block a thread against itself; volatile gives ordering but not atomicity; Interlocked gives atomicity without a lock, but only for a single variable. Then the cost of getting it wrong: a deadlock the CLR will neither detect nor break, and the thread-pool starvation any service falls into once it blocks on .Result inside an async chain. The layer-by-layer breakdown is below.
Topic map
- Race conditions — why
count++is not atomic and how unsynchronized interleaving loses updates. - Locking primitives — what
lockexpands into, howMonitorandMutexdiffer from it, and which object to lock on. - Deadlocks — the four Coffman conditions, the classic two-lock cycle, and the
.Resulthang. - Semaphores —
SemaphoreSlimas a concurrency limiter and the only way to "hold a lock" across anawait. - Memory visibility — reordering and caches, the acquire/release semantics of
volatile, and why it does not replacelock. - Interlocked and atomic operations — a lock-free increment on the CPU's CAS instruction, and where its applicability ends.
- The thread pool — reusable worker threads, slow injection beyond the minimum, and pool starvation.
- Thread-local state —
[ThreadStatic],ThreadLocal<T>, and why a value leaks into someone else's work item on pool threads. - Concurrent collections — the striped locks of
ConcurrentDictionary, theGetOrAddtrap,Channel<T>versusBlockingCollection<T>. - Parallel loops —
Parallel.For/ForEachfor CPU-bound work, and why it is the wrong tool for I/O.
Common Mistakes and Traps
| Mistake | Consequence |
|---|---|
Treating count++ as one atomic instruction | It is a read-modify-write — two threads read the same value, both write +1, one increment is lost |
| Assuming races need multiple cores | Preemption in the middle of a read-modify-write breaks the code on a single core too |
Locking on this, on a public field, or on a string literal | The monitor becomes public — or, for an interned literal, process-wide — and foreign code can block you |
Expecting lock to protect the object you pass it | lock protects a block of code; a field mutated outside the lock stays unprotected |
Trying to hold a lock across an await | Does not compile (CS1996) — async serialization needs SemaphoreSlim.WaitAsync |
Treating SemaphoreSlim as reentrant | A second Wait from the same thread with a count of 1 blocks that thread against itself forever |
Believing volatile makes x++ atomic | volatile only gives acquire/release ordering; a compound update remains a race |
| Developing and testing races on x86/x64 only | Intel/AMD's strong memory model hides the bug — it surfaces on ARM |
Applying Interlocked to two fields separately | Each operation is atomic, the pair is not — a two-field invariant needs a lock |
| Taking locks in different orders in different methods | Circular wait — the CLR neither detects the cycle nor throws; the process simply hangs |
Calling .Result / .Wait() on a task in a UI handler | The captured SynchronizationContext cannot run the continuation — a permanent hang |
| Blocking inside an async chain on pool threads | Thread-pool starvation — beyond the minimum the pool adds roughly one thread per second and latency explodes |
Assuming the GetOrAdd factory runs exactly once per key | The factory runs outside the lock and may run twice under contention — side effects are duplicated |
| Composing two calls on a concurrent collection and expecting the pair to be atomic | Each operation is atomic, the sequence is not — ContainsKey + TryAdd is still a race |
Storing request context in [ThreadStatic] | The value does not survive an await and leaks into the next work item on a reused pool thread — you need AsyncLocal<T> |
Driving I/O through Parallel.ForEach | Every operation occupies a blocked pool thread — a straight road to starvation; use await Task.WhenAll |
Interview relevance
Concurrency is the traditional divide between middle and senior on a C# interview. The question is never the API, it is the model: what exactly lock does beyond mutual exclusion, why volatile does not save a counter, where the .Result deadlock comes from. A candidate who answers the count++ question with "that is a read-modify-write, three steps" is an order of magnitude more convincing than one who says "you need a lock, that's the rule".
Typical checks:
- What a race condition is and why
count++loses increments. - What the compiler expands
lockinto, whether it is reentrant, and which object to lock on. - Why
Interlockedis cheaper thanlockand where its applicability ends. - What
volatileguarantees (ordering) and what it does not (atomicity). - The four deadlock conditions and which one a fixed lock order breaks.
- Why
SemaphoreSlimis the only way to "hold a lock" across anawait. - What thread-pool starvation is and how a blocking call inside an
asyncmethod causes it. - Why the
ConcurrentDictionary.GetOrAddfactory may run twice.
Common wrong answer: "I marked the field volatile, so the counter is thread-safe." That opens the central conversation of the topic: volatile governs visibility and ordering, not atomicity; the increment is still a read-modify-write, and only Interlocked.Increment or a lock can protect it.