Deadlocks and race conditions are the concurrency bugs that survive code review, pass every unit test, and then take down a production system under real load at 2 a.m. — which is exactly why senior and architect interviews spend so much time here. This topic tests whether a candidate can reason about timing-dependent failures that don't show up in a debugger running one step at a time, can read a hung process's memory dump instead of guessing, and can design systems that avoid the problem category entirely rather than defending against it with more locks. Expect a mix of "explain the mechanism," "find the bug in this code," and "design this without shared mutable state" — often in the same interview loop. The questions below cover sync-over-async deadlocks, lock ordering, the theory behind why deadlocks happen at all, diagnosing one from a dump, concrete race condition and check-then-act (TOCTOU) examples, testing strategies that actually reproduce these bugs, and designing state ownership so entire classes of races become impossible.
Q1 How does a sync-over-async deadlock actually happen, step by step?#
Short answer: Blocking on .Result or .Wait() from a thread that owns a captured SynchronizationContext deadlocks when the awaited method's continuation needs that exact thread to resume — but the thread is stuck blocked, waiting for a result that can't arrive until its own continuation runs. It's a single-threaded circular wait between the calling thread and the continuation it's preventing from executing.
// In a WPF button-click handler (a UI SynchronizationContext is active):
private void OnClick(object sender, RoutedEventArgs e)
{
var result = LoadDataAsync().Result; // deadlocks the UI thread
}
private async Task<string> LoadDataAsync()
{
await Task.Delay(100); // by default, resumes on the captured context
return "done";
}Walking through it: OnClick blocks the UI thread inside .Result. LoadDataAsync starts, hits await Task.Delay(100), and — because the default ConfigureAwait(true) captured the UI SynchronizationContext — its continuation is scheduled to run back on that same UI thread once the delay elapses. But the UI thread is already blocked inside .Result, so the continuation can never run, LoadDataAsync never completes, and .Result waits forever. The real fix is "async all the way" — await instead of .Result up through the whole call chain; adding ConfigureAwait(false) inside LoadDataAsync also breaks this specific deadlock, but it's a workaround for the symptom, not a fix for the sync-over-async anti-pattern itself. ASP.NET Core doesn't install a SynchronizationContext, so this exact deadlock mechanism doesn't occur there — but blocking a pool thread while it waits on another pool-scheduled continuation still removes capacity from the pool, which causes throughput collapse under load instead of an instant hang.
What interviewers look for: A precise, thread-by-thread walkthrough, not a memorized "don't call .Result" rule. The strongest answers explicitly connect this to SynchronizationContext and TaskScheduler and correctly distinguish the ASP.NET Core failure mode (starvation) from the classic UI/legacy-ASP.NET failure mode (instant deadlock).
Common mistakes: Reciting "add ConfigureAwait(false)" as a cargo-culted fix without being able to explain why it works; assuming ASP.NET Core is immune to sync-over-async problems entirely.
Follow-up questions:
- Why doesn't this deadlock happen the same way in a console application?
- What's the actual failure mode of sync-over-async under load in ASP.NET Core, if not a per-request deadlock?
Q2 Walk through a lock-ordering deadlock and how you'd fix it.#
Short answer: A lock-ordering deadlock happens when two threads acquire the same set of locks in different orders — thread A holds lock 1 and waits for lock 2 while thread B holds lock 2 and waits for lock 1 — so neither can ever proceed. The fix is to always acquire locks in one consistent, globally agreed order, typically by comparing a stable identifier, so a circular wait can never form in the first place.
// Deadlock-prone: the two call paths acquire the same two locks in
// opposite order when called concurrently on the same accounts.
public void TransferA(Account from, Account to, decimal amount)
{
lock (from) { lock (to) { Move(from, to, amount); } }
}
public void TransferB(Account from, Account to, decimal amount)
{
lock (to) { lock (from) { Move(from, to, amount); } } // reversed order
}
// Fixed: always lock in a stable order, regardless of which parameter
// a caller passes as "from" or "to".
public void Transfer(Account a, Account b, decimal amount)
{
var (first, second) = a.Id < b.Id ? (a, b) : (b, a);
lock (first)
{
lock (second)
{
Move(a, b, amount);
}
}
}What interviewers look for: Immediate recognition of circular wait as the mechanism and the "order by a stable key" fix — this exact scenario is one of the most common whiteboard prompts at the senior level, so hesitation here is a bad sign.
Common mistakes: "Fixing" the symptom with a retry loop or a timeout instead of establishing a real ordering rule; applying the ordering fix in one method but missing another code path that acquires the same two locks without it.
Follow-up questions:
- What would you do if the two resources being locked have no natural, comparable stable key?
- How does
Monitor.TryEnterwith a timeout provide a safety net even when ordering is correct everywhere you know about?
Q3 What are the necessary conditions for deadlock, and how does that framework guide prevention?#
Short answer: A deadlock requires four conditions simultaneously — mutual exclusion (a resource can be held by only one thread at a time), hold-and-wait (a thread holds one resource while waiting for another), no preemption (a held resource can't be forcibly taken away), and circular wait (a cycle of threads each waiting on the next). Breaking any single one of the four makes deadlock structurally impossible, which is why real prevention strategies each target one specific condition rather than trying to eliminate all of them at once.
| Condition | Typical mitigation |
|---|---|
| Mutual exclusion | Use lock-free structures (Interlocked, concurrent collections) where the invariant allows it |
| Hold-and-wait | Acquire every lock a unit of work needs up front, or restructure to need only one at a time |
| No preemption | Use Monitor.TryEnter with a timeout and back off instead of waiting forever |
| Circular wait | Always acquire locks in one fixed, globally agreed order |
What interviewers look for: Genuine theoretical grounding — the ability to say "this fix works because it breaks this specific condition," which shows deeper understanding than pattern-matching a memorized fix to a memorized problem.
Common mistakes: Listing the four conditions but being unable to map a concrete mitigation to each one; assuming mutual exclusion can always be designed away, when some invariants genuinely require exclusive access.
Follow-up questions:
- Why is removing mutual exclusion not always a realistic option?
- How does converting a blocking wait into
TryEnterwith a timeout change the failure mode from "hung forever" into something more recoverable?
Q4 Given a hung process, how would you confirm a deadlock from a memory dump?#
Short answer: Capture a dump while the process is hung — dotnet-dump collect -p <pid> or procdump — load it with dotnet-dump analyze, then use SOS commands: clrstack on each suspicious thread to see its managed call stack (looking for frames like Monitor.Enter/ReliableEnter, WaitHandle.WaitOne, or ReaderWriterLockSlim.EnterWriteLock), and syncblk to list active Monitor sync blocks with their owning thread and any threads waiting on them — then chase the ownership chain from each blocked thread until it cycles back to a thread already in the chain, confirming a genuine circular wait.
The key discipline is not assuming a hung process is automatically deadlocked: thread pool exhaustion and a slow downstream dependency both produce a similarly unresponsive system without any real wait cycle. Capturing two dumps a few seconds apart and comparing thread stacks is a quick way to distinguish "genuinely stuck in the same place" from "slow but still moving." Visual Studio's Threads window and Parallel Stacks view offer the same information graphically when you can attach a live debugger instead of working from a dump. See .NET Diagnostics Toolkit: Counters, Traces and Dumps for the broader toolchain this fits into.
What interviewers look for: Concrete, hands-on familiarity with real commands, not just "I'd look at a dump." A strong answer names clrstack and syncblk specifically and explains what each one shows.
Common mistakes: Treating every hang as a deadlock without confirming an actual wait cycle; being unable to name a single concrete diagnostic command beyond "use SOS" or "attach a debugger."
Follow-up questions:
- How would you distinguish a true deadlock from severe thread pool starvation using only a dump?
- What would you check next if
syncblkshows no owned sync blocks at all, yet the process is still unresponsive?
Q5 Walk through a realistic race condition and how you'd fix it.#
Short answer: A race condition is a bug whose outcome depends on uncontrolled timing — the textbook case is two threads incrementing a shared counter, or adding to a plain Dictionary<TKey, TValue>, without synchronization: reads and writes interleave unpredictably, updates get silently lost, and the collection's internal state can be corrupted outright.
// Two threads calling this concurrently can both read the same _count,
// both compute count + 1, and both write it back — one increment is lost.
private int _count;
public void Increment() => _count = _count + 1;
// Worse: Dictionary<TKey,TValue> isn't thread-safe, so concurrent access
// can corrupt internal state, not just lose an update.
private readonly Dictionary<string, int> _hits = new();
public void RecordHit(string key)
{
if (_hits.TryGetValue(key, out var count))
{
_hits[key] = count + 1;
}
else
{
_hits[key] = 1; // races with another thread doing the same thing
}
}The fix for the counter is Interlocked.Increment(ref _count). For the dictionary, switch to ConcurrentDictionary<TKey, TValue> and use its atomic AddOrUpdate so the read-then-write happens as one operation instead of two separate steps a second thread can interleave with:
private readonly ConcurrentDictionary<string, int> _hits = new();
public void RecordHit(string key) => _hits.AddOrUpdate(key, 1, (_, count) => count + 1);What interviewers look for: Recognition that "it usually works" is precisely the danger sign of a race condition — it can pass thousands of test runs and still fail under real production concurrency. Naming AddOrUpdate/GetOrAdd specifically, rather than just "wrap it in a lock," is a strong signal of hands-on experience.
Common mistakes: Fixing only half the read-modify-write sequence (locking the read but not the write, or vice versa); assuming a switch to ConcurrentDictionary alone makes every usage pattern automatically safe.
Follow-up questions:
- What would still be unsafe if a caller read a value from
ConcurrentDictionaryand then wrote it back with a plainAddin a separate call? - How would you detect this specific bug with a stress test rather than by inspection?
Q6 What's a check-then-act (TOCTOU) bug, and why is it more insidious than an obvious race?#
Short answer: Check-then-act — time-of-check to time-of-use — bugs happen when code checks a condition and then acts on it as two separate, non-atomic steps, even when each step is individually safe on its own; another thread can invalidate the checked condition in the gap between them. The fix is making the check and the act one indivisible operation, not making each step individually safer.
// TOCTOU: another thread can debit this account between the balance
// check and the debit, even though reading Balance and calling Debit are
// each "thread-safe" in isolation.
public bool Withdraw(Account account, decimal amount)
{
if (account.Balance >= amount)
{
account.Debit(amount); // another debit could have landed here too
return true;
}
return false;
}
// Fixed: check and act happen inside the same lock, as one unit.
public bool Withdraw(Account account, decimal amount)
{
lock (account.Gate)
{
if (account.Balance >= amount)
{
account.Debit(amount);
return true;
}
return false;
}
}The same shape shows up well beyond banking examples: if (!File.Exists(path)) File.Create(path) races with another process creating the file in the gap; if (!dict.ContainsKey(key)) dict.Add(key, value) races the same way against a plain dictionary. Even a thread-safe collection doesn't save you if you split the check and the act into two separate calls — ContainsKey followed by a separate Add on a ConcurrentDictionary reintroduces the exact same gap; the fix is still a single atomic call like TryAdd or GetOrAdd.
What interviewers look for: The insight that "built on a thread-safe collection" doesn't imply "thread-safe usage" — TOCTOU bugs frequently hide inside code that looks safe because each individual call is, in isolation, documented as thread-safe.
Common mistakes: Assuming ConcurrentDictionary eliminates every race just because it's "concurrent," when a two-call check-then-act sequence against it still isn't atomic.
Follow-up questions:
- How would you fix a file-system-level TOCTOU race, where there's no in-process lock that helps at all?
- Is
ConcurrentDictionary.GetOrAddfully atomic if the value factory delegate has side effects?
Q7 What's the difference between a deadlock, a livelock and starvation?#
Short answer: A deadlock is a set of threads permanently blocked, each waiting on a resource another one in the set holds, so nothing ever progresses. A livelock is similar in outcome but the threads aren't blocked — they stay actively busy responding to each other (for example, both repeatedly detecting contention and backing off in a way that keeps re-colliding) without ever making real forward progress. Starvation is when one specific thread is perpetually denied a resource it needs, not because of a cycle, but because other work keeps taking priority, so that one thread makes little or no progress even though the system as a whole is functioning normally.
A concrete livelock example: two threads each use Monitor.TryEnter and back off immediately on failure, retrying shortly after — if their retry timing stays synchronized, they can keep failing to acquire the lock at the same moments indefinitely, burning CPU while accomplishing nothing. A concrete starvation example: a steady, continuous stream of readers on a ReaderWriterLockSlim can delay a single waiting writer for a long time, even though the system overall keeps servicing read requests successfully.
What interviewers look for: Crisp, non-overlapping definitions plus a realistic example of each — this distinction matters operationally, since a production symptom described as "not fully hung, just making extremely slow progress" usually points to a livelock or starvation, not a deadlock, and the correct fix differs for each.
Common mistakes: Calling every concurrency stall a "deadlock" regardless of the actual mechanism, which leads to the wrong fix — for example, adding more locking to what's actually a livelock, making it worse.
Follow-up questions:
- How would you distinguish a livelock from a deadlock by observing CPU usage alone?
- What design change would you make to reduce the risk of writer starvation under heavy read load?
Q9 How do you reliably test for and reproduce concurrency bugs, instead of hoping code review catches them?#
Short answer: Because concurrency bugs are timing-dependent, the core strategy is to run the suspect code under real, high-volume concurrent load many times — a stress test with hundreds or thousands of parallel iterations, run repeatedly and in Release configuration, not trusted after a single pass — and to deliberately widen the race window (a small Task.Yield() or Thread.Sleep(1) inserted at the suspected race point in a test build) so a rare interleaving reproduces reliably instead of occasionally.
[Fact]
public async Task ConcurrentIncrements_DoNotLoseUpdates()
{
var counter = new SafeCounter();
var tasks = Enumerable.Range(0, 10_000).Select(_ => Task.Run(counter.Increment));
await Task.WhenAll(tasks);
Assert.Equal(10_000, counter.Value);
}Never "fix" a flaky concurrency test by adding a retry or a sleep inside the test itself — that hides the bug instead of reproducing it deterministically, and it's a signal the underlying code has a real race that will eventually surface in production. For systematic exploration of thread interleavings beyond brute-force stress testing, Microsoft's open-source Coyote framework can deliberately control scheduling to explore many possible interleavings of concurrent C# code, surfacing bugs that random stress testing might miss by chance. See Testing Strategy Interview Questions for Senior .NET Engineers for how this fits into a broader test strategy.
What interviewers look for: A concrete, repeatable strategy — stress test at real volume, deliberately widen the race window — instead of "I'd run it a few times and see." Treating a test that fails "1 in 1,000 runs" as a certainty waiting to happen in production, not as low priority, is exactly the judgment this question probes for.
Common mistakes: Adding sleeps or retries to make a flaky test "pass" instead of understanding and fixing the underlying race; only ever testing concurrency-sensitive code in Debug configuration, where JIT optimization differences can mask timing-dependent bugs that appear in Release.
Follow-up questions:
- How would you write a test that reliably reproduces a race that normally only shows up 1 in 100,000 runs?
- What's the risk of relying only on code review to catch concurrency bugs, without stress testing?
Q10 Implement a funds transfer between two accounts that is both deadlock-free and race-free.#
Short answer: A correct concurrent transfer needs a fixed, stable lock-acquisition order to avoid a circular-wait deadlock between concurrent transfers, and the entire check-then-debit-then-credit sequence executed as one atomic unit inside that locked region, so no other transfer can observe or act on an inconsistent intermediate state.
public sealed class Account
{
public required int Id { get; init; }
public Lock Gate { get; } = new();
public decimal Balance { get; set; }
}
public static class TransferService
{
public static bool Transfer(Account from, Account to, decimal amount)
{
if (from.Id == to.Id)
{
throw new ArgumentException("Cannot transfer to the same account.");
}
// A fixed, stable ordering by Id breaks circular wait regardless of
// which account a caller passes as "from" or "to".
var (first, second) = from.Id < to.Id ? (from, to) : (to, from);
using (first.Gate.EnterScope())
using (second.Gate.EnterScope())
{
// Check and act happen inside the same locked region as one
// atomic unit — no TOCTOU gap for a concurrent transfer to exploit.
if (from.Balance < amount)
{
return false;
}
from.Balance -= amount;
to.Balance += amount;
return true;
}
}
}Both accounts must be locked — not just from — because a concurrent transfer crediting to could otherwise race with this one; ordering by a stable Id (not object reference or hash code, neither of which is guaranteed comparable or stable in the way this logic needs) is what makes the ordering rule actually enforceable. At very high throughput, this coarse two-lock-per-transfer approach eventually becomes a bottleneck, at which point teams often move to an append-only event log — each transfer recorded as an immutable event, balances computed by folding events — which sidesteps shared mutable balance fields entirely.
What interviewers look for: This is the synthesis question: can the candidate combine lock ordering, atomic check-then-act, and the correct scope of what needs locking into one correct, from-scratch implementation under interview pressure. It's one of the most common "write real code" prompts at senior-to-staff level for exactly that reason.
Common mistakes: Locking only from and missing races on the credited side; ordering by object reference or hash code instead of a genuinely stable key; forgetting to guard against a self-transfer, which would attempt to enter the same lock twice.
Follow-up questions:
- How would this change if
Transferalso needed to write an audit record to a database mid-transfer? - At what throughput would you abandon this locking approach in favor of an event-sourced ledger, and why?
Quick-Fire Round#
| Question | Answer |
|---|---|
| Does ASP.NET Core suffer the classic sync-over-async deadlock? | Not the same way — no SynchronizationContext — but it can still cause thread pool starvation. |
| What breaks a circular-wait deadlock? | Acquiring locks in one consistent, stable order everywhere. |
| Name one of the four Coffman conditions. | Mutual exclusion, hold-and-wait, no preemption, or circular wait. |
| Which SOS command lists Monitor sync blocks and their owners? | syncblk. |
Is Dictionary<TKey,TValue> thread-safe for concurrent writes? | No — use ConcurrentDictionary<TKey,TValue> or a lock. |
Does ConcurrentDictionary.GetOrAdd's factory always run exactly once? | No — it can run more than once under contention; only one result is stored. |
| What distinguishes a livelock from a deadlock? | Threads stay actively running, not blocked, but still make no real progress. |
| What's the fix for a check-then-act race? | Make the check and the act one atomic operation, typically under the same lock. |
| Should a flaky concurrency test be fixed with a retry or sleep? | No — that hides the bug instead of fixing the underlying race. |
| What pattern removes the need for locks around a piece of state entirely? | Confining all mutation to one owner and communicating via message passing. |
How to Prepare#
- Be able to narrate the sync-over-async deadlock mechanism thread by thread, not just recite "don't block on async code."
- Practice the funds-transfer coding exercise until you can write a correct, ordered, atomic version without hesitation.
- Know at least two real
dotnet-dump/SOS commands for deadlock analysis, not just the tool's name. - Be ready to name the four Coffman conditions and map a concrete mitigation to each one.
- Have a TOCTOU example ready beyond the balance/debit one, in case that one has already been asked.
- Rehearse a design-level answer for "how would you avoid needing locks here at all" — immutability, confinement, message passing.