Picking the wrong synchronization primitive is rarely a compile error — it's a production incident that shows up under load as a stall, a deadlock, or corrupted shared state that only reproduces on a multi-core machine under real concurrency. Interviewers use this topic to check whether a candidate with 10 to 20 years of experience actually understands what each primitive guarantees, not just its method names. Expect questions that push past "use a lock" into precisely which lock, why SemaphoreSlim exists alongside Monitor, what volatile does and does not guarantee, and when fine-grained locking is worth its added complexity. The questions below cover lock/Monitor, the System.Threading.Lock type introduced alongside C# 13, SemaphoreSlim for async code, ReaderWriterLockSlim, Interlocked, the .NET memory model, SpinLock/SpinWait, and the judgment calls around choosing a primitive and setting lock granularity.
Q1 What does the lock statement actually compile to, and how does Monitor work under the hood?#
Short answer: lock (obj) { } compiles to a Monitor.Enter(obj, ref bool lockTaken) call wrapped in a try/finally that calls Monitor.Exit(obj) only if the lock was actually acquired; Monitor implements a reentrant mutual-exclusion lock — the same thread can re-enter a lock it already holds — plus a condition-variable mechanism (Wait/Pulse/PulseAll) for coordinating threads around a shared condition.
object gate = new();
lock (gate)
{
DoWork();
}
// Compiles to roughly:
bool lockTaken = false;
try
{
Monitor.Enter(gate, ref lockTaken);
DoWork();
}
finally
{
if (lockTaken)
{
Monitor.Exit(gate);
}
}The ref bool lockTaken overload exists so acquisition and the flag that says "I acquired it" happen as one atomic step from the caller's point of view — if anything goes wrong between attempting the acquire and reaching the finally, lockTaken accurately reflects whether Exit is safe to call. Reentrancy means nested lock statements on the same object from the same thread don't deadlock — each Enter increments an internal recursion count, and only the outermost Exit actually releases the lock. lock only accepts reference types, because it needs stable object identity to associate with the underlying synchronization state.
What interviewers look for: Fluency with the try/finally expansion and why it's written that way, not just "lock uses Monitor." Bonus points for knowing Monitor.Wait/Pulse exist and roughly what they're for, even though most modern code reaches for SemaphoreSlim or channels instead.
Common mistakes: Locking on this, a boxed value type, or an interned string literal — all of these risk unrelated code accidentally sharing (and contending on, or deadlocking through) the same lock object; not knowing why lock rejects value types at compile time.
Follow-up questions:
- Why is locking on a publicly accessible object considered bad practice?
- What's the difference between
Monitor.EnterandMonitor.TryEnter?
Q2 What is System.Threading.Lock, and how is it different from locking on a plain object?#
Short answer: System.Threading.Lock, introduced with the .NET 9 runtime, is a dedicated, purpose-built mutual-exclusion type with its own API — Enter(), TryEnter(), Exit(), and EnterScope(), which returns a disposable ref struct for use in a using block. The C# lock statement recognizes when its target is a Lock and automatically compiles to the new EnterScope()/Dispose() pattern instead of Monitor.Enter/Exit, so existing lock (field) { } code gets the benefit just by changing the field's declared type.
private readonly Lock _gate = new();
private decimal _balance;
// The 'lock' statement itself recognizes Lock and uses EnterScope/Dispose —
// no other code changes.
public void Deposit(decimal amount)
{
lock (_gate)
{
_balance += amount;
}
}
// You can also use the API explicitly for more control.
public void Withdraw(decimal amount)
{
using (_gate.EnterScope())
{
_balance -= amount;
}
}Unlike a plain object, where every instance carries the potential for a Monitor-based sync block allocated lazily in a side table the runtime maintains, Lock is a dedicated object designed purely for locking, which lets the runtime implement it more efficiently — particularly under light contention. It also exposes IsHeldByCurrentThread for assertions and diagnostics, something plain Monitor-based locking doesn't surface as directly. The compiler flags conversions of a Lock instance to another type in a lock statement, since that would silently fall back to slower, Monitor-based locking.
What interviewers look for: Awareness that this is a genuinely new, current runtime feature (not just sugar) and that it's opt-in via the field's declared type. Candidates who've adopted it in real code stand out.
Common mistakes: Assuming Lock is a drop-in replacement that requires code changes beyond the field declaration; not knowing it's specific to .NET 9 and later, which matters when a codebase still targets .NET 8 or earlier.
Follow-up questions:
- What does
Lock.IsHeldByCurrentThreadlet you do that plainMonitor-based locking doesn't? - Why would the compiler warn about converting a
Locktoobjectinside alockstatement?
Q3 How do you achieve mutual exclusion around code that awaits, since lock can't wrap an await?#
Short answer: SemaphoreSlim with WaitAsync/Release is the standard async-compatible mutual exclusion primitive — constructed as new SemaphoreSlim(1, 1) it behaves like an async mutex. Unlike Monitor/lock, which is thread-affine (only the thread that entered can exit, and the compiler forbids await inside lock for exactly this reason), SemaphoreSlim.Release() can be called from any thread, and WaitAsync suspends the logical operation without blocking an OS thread while it waits for the slot.
private readonly SemaphoreSlim _gate = new(initialCount: 1, maxCount: 1);
public async Task UpdateAsync(CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
await PersistAsync(ct);
}
finally
{
_gate.Release();
}
}With maxCount greater than one, the same type becomes a counting semaphore for limiting concurrency — the same primitive covered for fan-out throttling in Task Parallel Library and Parallelism Interview Questions. The trade-off versus lock is cost: SemaphoreSlim is heavier than a bare Lock/Monitor for purely synchronous critical sections, so use it specifically when the protected section needs to await, not as a universal replacement for lock.
What interviewers look for: Clear understanding that this isn't just "the async version of lock" cosmetically — the lack of thread affinity is a real, usable difference, and the interviewer wants to hear you explain why lock can't do this job at all (CS1996: await inside a lock body is a compile error).
Common mistakes: Forgetting try/finally around Release(), which permanently leaks a permit if an exception escapes the protected section; using SemaphoreSlim(1,1) for purely synchronous code where a cheaper lock/Lock would do.
Follow-up questions:
- What happens if
Release()is called more times thanWaitAsync()was — is that safe? - Why doesn't
Monitorsupport an async-friendlyWaitAsyncequivalent directly?
Q4 When would you use ReaderWriterLockSlim instead of a plain lock, and what are its pitfalls?#
Short answer: ReaderWriterLockSlim lets any number of readers run concurrently while writers get exclusive access, which pays off when reads vastly outnumber writes on data that's genuinely expensive to read under a full mutual-exclusion lock; its pitfalls are the bookkeeping overhead (which can make it slower than a plain lock under light contention), the default LockRecursionPolicy.NoRecursion throwing if you nest lock acquisitions on the same thread, and the need to use the upgradeable read lock — not a plain read lock — when a thread might need to promote from reading to writing.
private readonly ReaderWriterLockSlim _rw = new(LockRecursionPolicy.NoRecursion);
private readonly Dictionary<string, decimal> _prices = new();
public decimal GetPrice(string symbol)
{
_rw.EnterReadLock();
try { return _prices[symbol]; }
finally { _rw.ExitReadLock(); }
}
public void SetPrice(string symbol, decimal price)
{
_rw.EnterWriteLock();
try { _prices[symbol] = price; }
finally { _rw.ExitWriteLock(); }
}Only one thread may hold the upgradeable read lock at a time, even though many threads can hold plain read locks simultaneously — that restriction exists precisely so a thread can check a condition under a read-like lock and then safely promote to a write lock without another writer sneaking in between the check and the promotion.
What interviewers look for: A candidate who treats ReaderWriterLockSlim as a targeted optimization backed by a read/write ratio and measured contention, not a default upgrade from lock. Knowing the upgradeable-lock rule specifically is a strong signal.
Common mistakes: Reaching for ReaderWriterLockSlim "because it sounds more scalable" without measuring; acquiring a plain read lock and then trying to acquire a write lock from the same thread without going through the upgradeable path, which reliably deadlocks.
Follow-up questions:
- Why can
ReaderWriterLockSlimbe slower than a plain lock under light or no contention? - What would you use instead if reads are frequent but writes are rare and the data is small enough to copy cheaply?
Q5 How do Interlocked operations work, and what can you build with them?#
Short answer: Interlocked provides atomic, lock-free operations — Increment, Decrement, Add, Exchange, and CompareExchange — implemented with hardware-level atomic instructions rather than an OS lock, making them the cheapest form of synchronization available for simple counters, flags, and reference swaps, and the fundamental building block behind compare-and-swap (CAS) retry loops used to implement lock-free algorithms.
private long _requestCount;
public void RecordRequest() => Interlocked.Increment(ref _requestCount);
// A CAS retry loop for an operation Interlocked has no dedicated method
// for: only update the high-water mark if the new value is larger.
private int _highWaterMark;
public void ReportDepth(int depth)
{
int current;
do
{
current = _highWaterMark;
if (depth <= current)
{
return;
}
}
while (Interlocked.CompareExchange(ref _highWaterMark, depth, current) != current);
}The retry loop pattern above — read the current value, compute the new one, attempt to swap it in only if nothing else changed it in the meantime, retry on failure — is exactly how the BCL's concurrent collections (ConcurrentDictionary, ConcurrentQueue) implement much of their internal lock-free behavior. Interlocked operations still require a full memory fence at the hardware level, so they're cheaper than a lock but not literally free.
What interviewers look for: The ability to write a correct CAS loop from memory, and the judgment to know when Interlocked is enough (simple counters and flags) versus when you actually need a real lock (multi-field invariants that must change together).
Common mistakes: Using Interlocked.Increment on a field but then reading it elsewhere without any synchronization, assuming the increment alone makes the whole surrounding logic thread-safe; trying to protect multiple related fields with several independent Interlocked calls instead of one lock, which doesn't prevent another thread from observing them in an inconsistent combination.
Follow-up questions:
- Why does a
CompareExchangeretry loop need to re-read the current value on every attempt instead of caching it? - What's the generic
Interlocked.CompareExchange<T>overload for, and what constraint doesTneed?
Q6 What does volatile actually guarantee, and how does it relate to the .NET memory model?#
Short answer: volatile prevents the compiler and JIT from reordering or caching a field's accesses relative to other volatile accesses — giving reads acquire semantics and writes release semantics — and ensures the value is read from and written to memory rather than kept only in a register; it does not make compound operations like count++ atomic, and it is not a substitute for a lock when multiple related fields must change together consistently.
private volatile bool _isReady;
// volatile guarantees this write becomes visible to other threads promptly
// and isn't reordered relative to other volatile accesses...
public void Publish() => _isReady = true;
// ...but it does NOT make this safe — it's still a read-modify-write race.
private volatile int _counter;
public void Increment() => _counter++;.NET's memory model on real x86/x64 hardware is already fairly strong — most loads and stores are naturally ordered — which is precisely why memory-ordering bugs are easy to miss in testing on that hardware and then surface on ARM, where the hardware reorders more aggressively and the JIT's optimizations have more room to move things around. Volatile.Read/Volatile.Write offer the same guarantee at a single access site without marking the whole field volatile, and Interlocked operations, lock/Monitor, and Lock.EnterScope/Exit all imply full memory barriers on their own — code correctly protected by one of those doesn't need volatile in addition.
What interviewers look for: Precise vocabulary — "acquire/release semantics" and "instruction reordering," not a vague "it makes things thread-safe." The clearest signal is a candidate who immediately says volatile doesn't fix count++.
Common mistakes: Believing volatile alone makes a field safe for any concurrent access pattern; sprinkling volatile everywhere out of superstition instead of identifying the specific reordering being prevented.
Follow-up questions:
- Why might a memory-ordering bug reproduce on ARM but never on x64 in the same codebase?
- When would you reach for
Volatile.Read/Writeinstead of thevolatilefield modifier?
Q7 When would you reach for SpinLock or SpinWait instead of a regular lock?#
Short answer: SpinLock busy-waits instead of blocking on an OS wait handle, which can pay off only for extremely short critical sections on a multi-core machine, where the cost of spinning briefly is genuinely lower than the cost of a context switch; SpinWait is the lower-level building block that spins for a few iterations and then progressively backs off to Thread.Yield() and Thread.Sleep(), used to implement custom backoff logic and internally by several BCL primitives.
private SpinLock _spinLock = new(enableThreadOwnerTracking: false);
private int _counter;
public void Increment()
{
bool lockTaken = false;
try
{
_spinLock.Enter(ref lockTaken);
_counter++;
}
finally
{
if (lockTaken)
{
_spinLock.Exit();
}
}
}SpinLock is a struct, which means copying it (passing by value, storing it in a non-readonly property that gets re-read) duplicates its internal state — a classic, hard-to-spot bug; always store one in a private field and never copy it. Spinning also actively wastes CPU if the thread holding the lock isn't currently scheduled — on an oversubscribed or single-core environment, other threads spin uselessly while making zero progress. In practice, SpinLock rarely outperforms a regular lock/Lock outside of narrow, measured hot paths; it should be a deliberate, benchmarked choice, not a default "sounds faster" pick.
What interviewers look for: Healthy skepticism — a strong answer explains when spinning is not worth it as clearly as when it is, and flags the struct-copying footgun unprompted.
Common mistakes: Reaching for SpinLock because "lock-free sounds fast" without measuring; holding a SpinLock across anything that could block (I/O, a GC-triggering allocation), which defeats its entire premise.
Follow-up questions:
- Why is
SpinLockdeclared as a mutable struct instead of a class, and why does that matter for how you store it? - What does
SpinWait's backoff behavior look like as contention increases?
Q8 How would you choose among lock, SemaphoreSlim, ReaderWriterLockSlim, Interlocked and SpinLock for a given scenario?#
Short answer: Match the primitive to the actual requirement, starting from the simplest option that fits and only moving to something more specialized when a specific, measured need justifies it:
| Need | Use | Why |
|---|---|---|
| Simple mutual exclusion in synchronous code | lock (Lock on .NET 9+, else Monitor) | Cheapest general-purpose option, reentrant, well understood |
Mutual exclusion around an await | SemaphoreSlim(1, 1) with WaitAsync | lock cannot wrap an await (CS1996) |
| Limiting concurrency to N operations | SemaphoreSlim(N, N) | Async-friendly counting semaphore |
| Many readers, few writers, expensive shared data | ReaderWriterLockSlim | Lets reads proceed concurrently |
| A single counter, flag, or reference swap | Interlocked | Lock-free, cheapest possible synchronization |
| A measured, extremely short critical section under real contention | SpinLock/SpinWait | Avoids a context switch, but easy to misuse |
What interviewers look for: Fluency moving between these instead of defaulting to whichever one the candidate happens to know best, and the discipline to say "I'd start with lock and only change if profiling shows a specific bottleneck" rather than reaching for something exotic up front.
Common mistakes: Treating this as a performance ranking to apply everywhere instead of a decision tree driven by the actual access pattern; premature lock-free optimization that adds complexity without a measured problem to justify it.
Follow-up questions:
- What would make you swap a
lockforInterlockedin a real code review — what evidence would you want to see first? - How would this table change for code that also needs to work correctly across process boundaries, not just threads?
Q9 What's the trade-off between coarse-grained and fine-grained locking, and how does lock striping help?#
Short answer: Coarse-grained locking — one lock guarding an entire object, service, or large data structure — is simple to reason about and hard to get wrong, but it serializes unrelated operations and can become a throughput bottleneck as concurrency increases; fine-grained locking (per-item or per-bucket locks) unlocks more real concurrency but multiplies the number of lock objects and the risk of deadlock through inconsistent acquisition order across multiple locks, so it should only be adopted once contention on a coarse lock is measured and confirmed as the bottleneck.
// Lock striping: 16 independent locks instead of one, so unrelated keys
// don't contend with each other — the same idea ConcurrentDictionary uses
// internally to scale writes across buckets.
private readonly Lock[] _stripes = [.. Enumerable.Range(0, 16).Select(_ => new Lock())];
private readonly long[] _counters = new long[16];
public void Increment(string key)
{
var index = (uint)key.GetHashCode() % _stripes.Length;
using (_stripes[(int)index].EnterScope())
{
_counters[index]++;
}
}Lock striping is the classic middle ground: it reduces contention versus one global lock without going all the way to a full lock-free design, at the cost of needing a consistent rule for which stripe a given key maps to and, if any operation ever needs to touch more than one stripe at once, a fixed acquisition order to avoid deadlock.
What interviewers look for: A cost-benefit framing rather than "fine-grained is always better." Strong candidates explicitly connect finer granularity to increased deadlock risk from lock ordering, tying this back to the material in Deadlocks and Race Conditions Interview Questions.
Common mistakes: Splitting a lock into many fine-grained locks without a consistent acquisition order when an operation needs more than one, introducing exactly the kind of deadlock the change was meant to avoid; fine-graining a lock that was never actually contended, adding complexity for no measured benefit.
Follow-up questions:
- How does
ConcurrentDictionaryavoid needing a single global lock for arbitrary key access? - What rule would you enforce in code review to keep striped-lock acquisition order consistent?
Q10 What is double-checked locking, why is it error-prone, and what should you use instead?#
Short answer: Double-checked locking is a pattern for lazily initializing a shared value without paying a lock's cost on every access afterward: check the field, and only if it's still unset, acquire a lock and check again before creating the value once. It's error-prone because, without the right memory ordering, another thread can observe a non-null reference to an object that isn't fully constructed yet; in modern C#, prefer Lazy<T> (or LazyInitializer.EnsureInitialized), which implements the correct ordering for you instead of asking every engineer to get it right by hand.
// Fragile hand-rolled version: needs the field to be volatile (or read via
// Volatile.Read) to be correct — otherwise a reader on another thread could
// observe a reference to a not-yet-fully-initialized Configuration.
private static volatile Configuration? _instance;
private static readonly Lock _gate = new();
public static Configuration Instance
{
get
{
if (_instance is null)
{
using (_gate.EnterScope())
{
_instance ??= LoadConfiguration();
}
}
return _instance;
}
}
// Preferred: Lazy<T> handles correct publication for you.
private static readonly Lazy<Configuration> LazyInstance = new(LoadConfiguration);
public static Configuration Instance2 => LazyInstance.Value;Lazy<T> accepts a LazyThreadSafetyMode when you need to tune the trade-off: the default behaves like correct double-checked locking, None skips synchronization entirely for single-threaded scenarios, and PublicationOnly allows multiple threads to race the factory but guarantees only one winning result is published. Check the exact retry-on-failure behavior for your chosen mode before relying on it if the factory can throw.
What interviewers look for: Recognition that this is a well-known trap most engineers get subtly wrong at least once, and the instinct to reach for Lazy<T> instead of hand-writing the pattern in new code. Naming LazyThreadSafetyMode is a strong bonus signal.
Common mistakes: Omitting volatile (or Volatile.Read) on the hand-rolled version's field — the exact bug that made this pattern infamous; assuming Lazy<T>'s behavior on a failed initialization without checking the chosen LazyThreadSafetyMode's documented retry semantics.
Follow-up questions:
- What does
LazyThreadSafetyMode.PublicationOnlytrade away compared with the default mode? - Why is locking on
typeof(SomeType)for a static double-checked-locking pattern discouraged, even though it compiles and usually works?
Quick-Fire Round#
| Question | Answer |
|---|---|
Is Monitor-based locking reentrant? | Yes — the same thread can re-enter a lock it already holds. |
Which .NET version introduced System.Threading.Lock? | .NET 9, alongside C# 13. |
Can a different thread call Release() than the one that called WaitAsync()? | Yes — SemaphoreSlim has no thread affinity, unlike Monitor. |
Can you nest EnterReadLock calls by default on ReaderWriterLockSlim? | No — the default LockRecursionPolicy.NoRecursion throws. |
Does Interlocked.Increment make surrounding unrelated code thread-safe? | No — it only makes that single operation atomic. |
Does volatile make counter++ atomic? | No — that's still a read-modify-write race. |
Should SpinLock be stored in a readonly field? | No — and never copy it; store it in a plain mutable field and pass by reference. |
What does lock compile to for a plain object? | Monitor.Enter/Exit wrapped in try/finally. |
Why can't you await inside a lock block? | Thread affinity — you can't guarantee the same thread resumes to release it. |
| What's the safer alternative to hand-rolled double-checked locking? | Lazy<T> or LazyInitializer.EnsureInitialized. |
How to Prepare#
- Be able to write the
try/finallyexpansion oflockfrom memory, including why theref bool lockTakenoverload exists. - Know
System.Threading.Lock's API —EnterScope,TryEnter,IsHeldByCurrentThread— and why it's faster than locking on a plain object. - Practice explaining, precisely, why
volatiledoesn't make compound operations atomic. - Have a working CAS retry loop for
Interlocked.CompareExchangeready to write on a whiteboard. - Rehearse the coarse-grained vs. fine-grained locking trade-off with a lock-striping example.
- Be ready to name the exact bug in a hand-rolled double-checked locking implementation missing
volatile.