Deadlocks

Intro

A deadlock happens when two or more execution paths wait forever on resources held by each other. In .NET systems, deadlocks appear both in classic lock-based code and in async flows that block on tasks. They are high-severity failures because throughput can drop to zero without obvious crashes — the process stays alive but stops making progress.

How Deadlocks Form — Coffman Conditions

Deadlocks require all four Coffman conditions simultaneously:

  1. Mutual exclusion — a resource cannot be shared (e.g., a lock/monitor). Protects correctness but introduces contention risk.
  2. Hold and wait — a thread holds one resource while waiting for another. The common trigger in nested locking.
  3. No preemption — a resource cannot be forcibly taken; the owner must release it. Blocked threads can wait forever without a timeout or cancellation path.
  4. Circular wait — the wait graph has a cycle (A waits for B, B waits for A). The easiest condition to break with deterministic lock ordering.

Break any one condition and the deadlock cannot happen.

Classic Lock Deadlock

private static readonly object LockA = new();
private static readonly object LockB = new();

public void First()
{
    lock (LockA)
    {
        Thread.Sleep(10); // simulate work while holding A
        lock (LockB)      // then acquire B
        {
            // critical section using A + B
        }
    }
}

public void Second()
{
    lock (LockB)
    {
        Thread.Sleep(10); // simulate work while holding B
        lock (LockA)      // then acquire A — reverse order!
        {
            // critical section using B + A
        }
    }
}

How the deadlock forms:

  1. Thread T1 enters First, acquires LockA.
  2. Thread T2 enters Second, acquires LockB.
  3. T1 tries to acquire LockB — blocked (owned by T2).
  4. T2 tries to acquire LockA — blocked (owned by T1).
  5. Neither thread can continue: circular wait.

Async Deadlock (Sync-Over-Async)

A subtler deadlock pattern in async code: blocking on a Task inside a SynchronizationContext.

// In a UI event handler or legacy ASP.NET action:
public void OnLoad()
{
    // DEADLOCK: blocks the UI thread, which the continuation needs to resume
    var data = LoadDataAsync().Result;
    Display(data);
}

private async Task<string> LoadDataAsync()
{
    // Default await captures the SynchronizationContext (UI thread).
    // The continuation needs the UI thread to resume — but it's blocked by .Result.
    return await _http.GetStringAsync("https://api.example.com/data");
}

Why it deadlocks:

Prevention Patterns

1. Consistent lock ordering
Always acquire locks in the same global order across all code paths. This breaks circular wait.

// Both methods acquire in the same order: LockA → LockB
public void First()
{
    lock (LockA) { lock (LockB) { /* ... */ } }
}

public void Second()
{
    lock (LockA) { lock (LockB) { /* ... */ } }
}

2. Monitor.TryEnter with timeout
Use a timeout to break the "no preemption" condition — if you can't acquire within the deadline, back off and retry.

bool acquired = Monitor.TryEnter(LockA, TimeSpan.FromMilliseconds(500));
if (!acquired)
{
    // Log, retry, or throw — don't wait forever
    throw new TimeoutException("Could not acquire LockA within 500ms");
}
try { /* critical section */ }
finally { Monitor.Exit(LockA); }

3. Async all the way — never block on tasks
The async deadlock is eliminated by never calling .Result or .Wait() on tasks in a context-aware environment.

// Correct: await all the way up
public async Task OnLoadAsync()
{
    var data = await LoadDataAsync();
    Display(data);
}

If you must call async code from sync code (e.g., in a constructor), use ConfigureAwait(false) in the async method to prevent context capture, or restructure to avoid the sync boundary.

4. Minimize lock scope
Hold locks for the shortest possible time. Don't perform I/O, blocking calls, or complex computation while holding a lock.

// Bad: I/O inside lock
lock (_lock) { var result = _http.GetStringAsync(url).Result; }

// Good: I/O outside lock
var result = await _http.GetStringAsync(url);
lock (_lock) { _cache[key] = result; }

Pitfalls

Async deadlock is invisible in logs
The process stays alive and healthy from the outside. No exception is thrown. The only signal is a hung request or frozen UI. Use thread dump analysis or dotnet-dump to identify blocked threads.

lock inside async method
lock cannot span an await — the compiler rejects it. Use SemaphoreSlim for async-compatible mutual exclusion.

private readonly SemaphoreSlim _gate = new(1, 1);

public async Task UpdateAsync()
{
    await _gate.WaitAsync();
    try { /* critical section */ }
    finally { _gate.Release(); }
}

Nested locks in library code
Third-party libraries may acquire internal locks. Calling library methods while holding your own lock can create unexpected lock ordering dependencies you cannot control.

Questions

References


Whats next