Concurrency & Synchronization
Races and deadlocks, lock and Monitor, SemaphoreSlim, volatile and Interlocked, thread-pool starvation, Channels, and concurrent collections.
18 questions
JuniorTheoryVery commonWhat is a deadlock, and how do two threads taking two locks produce one?
What is a deadlock, and how do two threads taking two locks produce one?
A deadlock is when threads wait forever on locks the others already hold, so none can proceed. Thread A takes lock 1 then wants lock 2; thread B takes lock 2 then wants lock 1. Each holds what the other needs, so both block forever.
Common mistakes
- ✗Confusing a deadlock with starvation, where a thread is merely slow to win a lock
- ✗Expecting the CLR to detect the cycle and throw, instead of hanging silently
- ✗Believing
lockreentrancy protects you when the locks are taken in opposite orders
Follow-up questions
- →How would you confirm a hung process is deadlocked rather than merely slow?
- →Why does a single
locktaken by every thread never deadlock on its own?
JuniorTheoryVery commonWhich object should you pass to lock, and why are lock(this) and lock("key") wrong?
Which object should you pass to lock, and why are lock(this) and lock("key") wrong?
Lock on a private readonly object that only your class can see. lock(this) publishes the lock, so outside code holding your instance can take it and deadlock you. lock("key") is worse: literals are interned, so that lock is process-wide.
Common mistakes
- ✗Locking on
thisor on a public field, letting outside code take the same monitor - ✗Locking on a string literal, which is interned and therefore shared process-wide
- ✗Locking on a value type or a fresh object, so each thread takes a different monitor
Follow-up questions
- →Why does locking on a value type never protect anything?
- →How does the
System.Threading.Locktype in .NET 9 change the guidance here?
JuniorTheoryVery commonWhat is a race condition, and why can two threads running count++ lose an update?
What is a race condition, and why can two threads running count++ lose an update?
A race condition means the result depends on the unsynchronized interleaving of threads. count++ is not one operation — it reads, adds one and writes back. Two threads can read the same value, both add one and both write back, losing an increment.
Common mistakes
- ✗Believing
count++is a single atomic instruction rather than a read-modify-write - ✗Assuming races only appear on multi-core hardware, not on a preempted single core
- ✗Thinking that marking the field
volatilealone makescount++safe
Follow-up questions
- →Which primitive makes an integer increment atomic without taking a
lock? - →How would you make a lost update reproduce reliably in a test?
JuniorTheoryCommonWhat is the .NET thread pool, and why prefer Task.Run to creating a new Thread?
What is the .NET thread pool, and why prefer Task.Run to creating a new Thread?
The thread pool is a runtime-managed set of reusable worker threads. Task.Run queues work onto it, so you skip thread creation and the pool bounds concurrency. Each new Thread costs about 1 MB of stack plus OS setup.
Common mistakes
- ✗Spawning a
new Threadper request instead of queueing work on the pool - ✗Believing a managed thread is cheap — it costs a committed stack plus OS bookkeeping
- ✗Assuming the pool size is fixed at the core count and never grows
Follow-up questions
- →What kind of work should never be queued to the pool, and why?
- →How does the pool decide to inject an extra worker thread?
MiddleTheoryCommonWhen does ConcurrentDictionary beat a plain Dictionary guarded by a lock?
When does ConcurrentDictionary beat a plain Dictionary guarded by a lock?
ConcurrentDictionary stripes locks across buckets, so writers on different keys rarely contend and reads take no lock, while a locked Dictionary serializes all access. But GetOrAdd's factory runs outside the lock and can run twice for one key.
Common mistakes
- ✗Assuming
GetOrAdd's value factory is invoked exactly once per key - ✗Composing two
ConcurrentDictionarycalls and expecting the pair to be atomic - ✗Believing a single
lockaround aDictionaryscales the same under heavy read load
Follow-up questions
- →How does wrapping the value in
Lazy<T>make an expensiveGetOrAddfactory run only once? - →Why is the
Countproperty of aConcurrentDictionarymore expensive than it looks?
MiddleTheoryCommonHow do lock, Monitor, and Mutex differ, and when do you actually need Mutex?
How do lock, Monitor, and Mutex differ, and when do you actually need Mutex?
lock is sugar for Monitor.Enter/Monitor.Exit in a try/finally; both are in-process and reentrant for the same thread. Monitor adds TryEnter with a timeout plus Wait/Pulse. Mutex is a slower kernel object, shared across processes.
Common mistakes
- ✗Reaching for
Mutexinside one process, paying a kernel transition for no reason - ✗Not knowing
lockis justMonitor.Enter/Monitor.Exitin atry/finally - ✗Missing
Monitor.TryEnterwith a timeout as the way to avoid blocking forever
Follow-up questions
- →Why must a
Mutexbe released by the very thread that acquired it? - →What does
Monitor.Waitrelease, and why must you already hold the monitor to call it?
MiddleTheoryCommonWhen does Parallel.ForEach beat task-based async, and when is it the wrong tool?
When does Parallel.ForEach beat task-based async, and when is it the wrong tool?
Parallel.ForEach partitions CPU-bound work across pool threads and blocks the caller until it finishes — right for crunching data, wrong for I/O, where it burns a pool thread per operation while await holds none. Its body must be thread-safe.
Common mistakes
- ✗Using
Parallel.ForEachover I/O calls and burning a pool thread per operation - ✗Believing
Parallel.ForEachis asynchronous — it blocks the caller until the loop completes - ✗Mutating a shared collection or counter from the loop body without synchronization
Follow-up questions
- →What does
MaxDegreeOfParallelismchange, and when would you cap it? - →Which type replaces
Parallel.ForEachwhen every item needs an awaited I/O call?
MiddleTheoryOccasionalWhich four conditions must all hold for a deadlock, and how does lock ordering break one?
Which four conditions must all hold for a deadlock, and how does lock ordering break one?
All four must hold at once: mutual exclusion, hold-and-wait, no preemption, and circular wait. Break any single one and a deadlock cannot form. A global lock ordering removes circular wait; Monitor.TryEnter with a timeout removes hold-and-wait.
Common mistakes
- ✗Naming the four conditions but not seeing that breaking one is enough to prevent a deadlock
- ✗Expecting the runtime to detect the cycle and preempt a lock holder
- ✗Thinking a consistent lock order is cosmetic rather than the standard prevention technique
Follow-up questions
- →How do you define a stable lock order over objects that have no natural ordering?
- →What must a thread do after
Monitor.TryEnterreturnsfalseto avoid a livelock?
MiddleTheoryOccasionalHow does Interlocked.Increment differ from incrementing the same field inside a lock?
How does Interlocked.Increment differ from incrementing the same field inside a lock?
Both make the increment atomic. Interlocked.Increment is one lock-free CPU instruction with a full memory barrier — no blocking, no context switch — so it is far cheaper under contention. But it covers one variable only, where a lock covers many.
Common mistakes
- ✗Thinking
Interlockedonly adds a barrier and still needs alockfor atomicity - ✗Using
Interlockedon two fields separately and expecting the pair to be atomic - ✗Reaching for a
lockon a hot single counter where an interlocked add would do
Follow-up questions
- →How would you build a lock-free update of a computed value with
Interlocked.CompareExchange? - →Why is a plain 64-bit read not atomic on a 32-bit runtime, and what fixes it?
MiddleTheoryOccasionalWhen do you choose SemaphoreSlim over lock, and what can it do that lock cannot?
When do you choose SemaphoreSlim over lock, and what can it do that lock cannot?
SemaphoreSlim admits up to N holders, throttling concurrency instead of serializing it to one. Its WaitAsync yields the thread rather than blocking, so it can be held across an await — which lock forbids. It is not reentrant.
Common mistakes
- ✗Trying to hold a
lockacross anawait, which the compiler rejects outright - ✗Assuming
SemaphoreSlimis reentrant, then deadlocking a thread against itself - ✗Calling
Wait()instead ofWaitAsync()in async code and blocking a pool thread
Follow-up questions
- →Why must a
SemaphoreSlimrelease always sit in afinallyblock? - →How does
SemaphoreSlimdiffer from the kernel-backedSemaphoreclass?
SeniorTheoryOccasionalHow do the producer-consumer queues Channel<T> and BlockingCollection<T> differ?
How do the producer-consumer queues Channel<T> and BlockingCollection<T> differ?
BlockingCollection<T> wraps a concurrent collection and blocks a thread in Take() when empty or Add() when full. Channel<T> is the async equivalent: ReadAsync/WriteAsync yield the thread, and a bounded channel makes writers await.
Common mistakes
- ✗Using
BlockingCollection<T>from async code, blocking a pool thread on everyTake() - ✗Leaving a channel unbounded, so a slow consumer lets the queue grow until memory runs out
- ✗Forgetting to complete the writer, leaving consumers awaiting a channel that never ends
Follow-up questions
- →What does completing a channel's writer do to a consumer sitting in
ReadAllAsync? - →Which
BoundedChannelFullModewould you pick for telemetry that may be dropped under load?
SeniorTheoryOccasionalWhy does library code call ConfigureAwait(false), and what does the continuation scheduler SynchronizationContext do?
Why does library code call ConfigureAwait(false), and what does the continuation scheduler SynchronizationContext do?
SynchronizationContext is captured at an await, and the continuation is posted back to a UI message loop or ASP.NET's request context. ConfigureAwait(false) skips that capture, so the continuation resumes on a pool thread: no hop, no deadlock.
Common mistakes
- ✗Thinking
ConfigureAwait(false)forces the continuation back onto the original context - ✗Believing it changes which thread runs the awaited operation rather than its continuation
- ✗Adding it in application code, where the captured context is exactly what you want
Follow-up questions
- →Why does ASP.NET Core no longer need
ConfigureAwait(false)in application code? - →How does
TaskSchedulerdiffer fromSynchronizationContextin deciding where a continuation runs?
SeniorDebuggingOccasionalA WPF button handler freezes forever on .Result. Find and fix the deadlock.
A WPF button handler freezes forever on .Result. Find and fix the deadlock.
.Result blocks the UI thread, while the await in FetchAsync captured the dispatcher's SynchronizationContext and posts its continuation back to that blocked loop. Fix it by going async all the way: await the task in an async handler.
Common mistakes
- ✗Blaming the thread pool instead of the captured
SynchronizationContextthe continuation is posted to - ✗Swapping
.Resultfor.Wait()orGetAwaiter().GetResult()— all three block the same thread - ✗Adding
ConfigureAwait(false)to the handler rather than to the awaits inside the async method
Follow-up questions
- →Why would
ConfigureAwait(false)insideFetchAsyncalso unblock this, and why is it still the weaker fix? - →Why does the same code not deadlock in an ASP.NET Core controller?
SeniorTheoryOccasionalWhat causes thread-pool starvation, and why does blocking inside an async method trigger it?
What causes thread-pool starvation, and why does blocking inside an async method trigger it?
Starvation is work queued faster than the pool has threads to run it. Blocking calls — .Result, SemaphoreSlim.Wait(), sync I/O, a blocking Parallel.ForEach body — park pool threads. Past its minimum it adds threads slowly and latency collapses.
Common mistakes
- ✗Blocking on
.Resultor.Wait()inside an async call chain running on pool threads - ✗Believing an
asyncmethod never occupies a thread even when its body blocks - ✗Expecting the pool to inject threads instantly rather than at its slow hill-climbing rate
Follow-up questions
- →How would you tell starvation apart from a genuine CPU bottleneck in a production dump?
- →When is raising
ThreadPool.SetMinThreadsa legitimate fix rather than a band-aid?
MiddleTheoryRareHow do [ThreadStatic] and ThreadLocal<T> differ, and why is either risky on pool threads?
How do [ThreadStatic] and ThreadLocal<T> differ, and why is either risky on pool threads?
Both give every thread its own copy of a value. [ThreadStatic] has no per-thread initializer and starts at default; ThreadLocal<T> takes a factory and initializes lazily. Pool threads are reused, so a stale value leaks into the next work item.
Common mistakes
- ✗Expecting a
[ThreadStatic]field initializer to run on every thread — it runs only on the first - ✗Using thread-local state to carry context across an
await, which resumes on another thread - ✗Forgetting that pool threads are reused, so a value left behind leaks into the next work item
Follow-up questions
- →Which type carries context across an
awaitwhen the continuation resumes on another thread? - →Why must a
ThreadLocal<T>holding disposables be disposed, and what leaks when it is not?
SeniorTheoryRareWhere does an unhandled exception go when thrown on a new Thread versus inside a Task?
Where does an unhandled exception go when thrown on a new Thread versus inside a Task?
On a new Thread or pool callback an unhandled exception is fatal — the CLR kills the process. Inside a Task it is captured: .Wait()/.Result rethrow it in an AggregateException, await unwraps it, and an unobserved task drops it.
Common mistakes
- ✗Wrapping
Thread.Start()orTask.Run()in atry/catchand expecting it to catch the body - ✗Assuming an unhandled exception on a background thread just kills that thread
- ✗Thinking
awaitgives you anAggregateExceptionthe way.Resultdoes
Follow-up questions
- →What does the
TaskScheduler.UnobservedTaskExceptionevent let you do about a dropped exception? - →Why can an exception from an
async voidmethod never be caught by its caller?
SeniorTheoryRareWhat does volatile guarantee in C#, and what does it explicitly not guarantee?
What does volatile guarantee in C#, and what does it explicitly not guarantee?
volatile gives ordering, not atomicity: reads have acquire semantics and writes release semantics, so no access moves across them and a write becomes visible to other threads. It does not make count++ atomic — use Interlocked or lock.
Common mistakes
- ✗Believing
volatilemakescount++or any compound update atomic - ✗Treating
volatileas a cheap replacement for alockon multi-field invariants - ✗Thinking it is a compiler-only hint with no effect on CPU-level reordering or visibility
Follow-up questions
- →Why does a volatile write followed by a volatile read of another field still allow store-load reordering?
- →When is
Volatile.Read/Volatile.Writepreferable to marking the fieldvolatile?