📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What does the Monitor class provide beyond the lock statement? Explain TryEnter, Wait and Pulse.

Monitor.Enter/Exit, TryEnter with timeouts, Wait/Pulse/PulseAll condition signalling, thread affinity and the object header thin lock and sync block.

Senior Theory Advanced lock monitor synchronization wait-pulse

🎯 Key points

  • Monitor implements lock for ordinary objects
  • TryEnter with a timeout avoids waiting forever
  • Wait releases the lock, waits for a pulse and reacquires it
  • Always call Wait in a while loop that rechecks the condition
  • Pulse with no waiters is lost; PulseAll for multiple conditions
  • Monitor is thread-affine and re-entrant; uses thin locks and sync blocks
  • Prefer Channel<T> or BlockingCollection<T> for producer/consumer

⏱ 30-second answer

Monitor is the class behind lock for ordinary objects. Beyond Enter/Exit it offers TryEnter with a timeout, so you can give up instead of waiting forever, and condition signalling with Wait, Pulse and PulseAll. Wait releases the lock and blocks until another thread pulses and the lock is free again; you must call it in a while loop that rechecks the condition. Monitor locks are thread-affine and re-entrant, use a thin lock in the object header and inflate to a sync block under contention. For new code, Channel<T>, SemaphoreSlim or BlockingCollection<T> usually replace hand-written Wait/Pulse.

🗣 Full answer (senior)

How I would answer this in an interview

"Monitor is the class behind lock for ordinary objects. Beyond Enter/Exit it gives three useful things. TryEnter with a timeout, so code can give up, log and recover instead of hanging forever. IsEntered, to assert that the current thread holds the lock. And condition signalling with Wait, Pulse and PulseAll: Wait atomically releases the lock, blocks until another thread pulses, and then reacquires the lock before returning. Because another thread can change the state between the pulse and the reacquire, Wait must always sit in a while loop that rechecks the condition. Monitor locks are thread-affine: Wait, Pulse and Exit must be called by the owning thread, or you get SynchronizationLockException. A pulse with no waiters is lost: it's not stored. In new code I rarely write Wait/Pulse by hand; Channel<T>, BlockingCollection<T>, SemaphoreSlim and the event types do it for you, and they can be async."


1. The API

Member Purpose
Enter(obj, ref lockTaken) / Exit(obj) What lock compiles to
TryEnter(obj, timeout, ref lockTaken) Acquire or give up after a timeout
IsEntered(obj) Does the current thread hold it? (debug asserts)
Wait(obj[, timeout]) Release lock → wait for pulse/timeout → reacquire → return (false on timeout)
Pulse(obj) Move one waiting thread to the ready queue
PulseAll(obj) Move all waiting threads to the ready queue
LockContentionCount Process-wide contention counter (.NET Core 3.0+)

2. Two queues per monitor

Queue Contains Leaves when
Ready queue Threads waiting to acquire the lock Lock released and they win it
Waiting queue Threads that called Wait Pulse/PulseAll or timeout moves them to the ready queue

A pulsed thread still has to win the lock before Wait returns. Meanwhile other threads may run and change the state.

3. The condition-variable pattern

// consumer
lock (_gate)
{
    while (_queue.Count == 0)        // ALWAYS a loop
        Monitor.Wait(_gate);         // releases _gate while waiting
    item = _queue.Dequeue();
    Monitor.PulseAll(_gate);         // e.g. wake producers waiting for space
}

// producer
lock (_gate)
{
    while (_queue.Count == _capacity)
        Monitor.Wait(_gate);
    _queue.Enqueue(x);
    Monitor.PulseAll(_gate);
}

4. Rules

Rule If you break it
Call Wait/Pulse/Exit only while holding the lock SynchronizationLockException
Wait in a while, never an if Two consumers wake for one item → empty-queue crash, or work on a wrong state
Decide based on state, not on "I was pulsed" A pulse without waiters is lost
Use PulseAll when waiters wait on different conditions (not-full vs not-empty) Pulse may wake the wrong kind of waiter → everyone sleeps (lost wake-up)
Prefer timeouts in production waits A bug otherwise becomes a silent hang

5. Higher-level replacements

Need Prefer
Producer/consumer (sync) BlockingCollection<T>
Producer/consumer (async, bounded) Channel<T>
One-off signal ManualResetEventSlim, TaskCompletionSource
Count down N events CountdownEvent
Async mutual exclusion / throttling SemaphoreSlim.WaitAsync

System.Threading.Lock (.NET 9) has no Wait/Pulse; if you need condition signalling with a lock, Monitor on an object is still the tool.

Production example

Our [legacy job scheduler] had [six] worker threads waiting for jobs with if (_jobs.Count == 0) Monitor.Wait(_gate); and occasionally crashed under load with InvalidOperationException: Queue empty. I wrote a stress test with many short jobs and reproduced it: a PulseAll woke two workers, the first took the only job, and the second returned from Wait and dequeued from an empty queue because the if didn't recheck. Changing if to while fixed the crash. Later we replaced the custom queue with a bounded Channel<Job>, which cut [80] lines of hand-written signalling to a few and made the workers async. The stress test stayed in CI, and hand-written Wait/Pulse is now banned in new code.

🔍 What the interviewer is checking

Checks deeper synchronisation knowledge.

Green flags

  • Explains TryEnter, Wait, Pulse correctly.
  • Insists on the while loop.
  • Knows thread affinity and the lost-pulse problem.
  • Suggests channels for producer/consumer.

Red flags

  • Uses if around Wait.
  • Thinks Pulse is queued for later.

📚 Learn in depth

1. What Wait really does

Monitor.Wait(_gate):
  1. verify current thread owns _gate (else SynchronizationLockException)
  2. remember recursion count (e.g. 2 if entered twice), fully release the lock
  3. add thread to _gate's waiting queue and block
  4. on Pulse/PulseAll (or timeout): move to ready queue
  5. reacquire _gate (compete with others), restore recursion count
  6. return true (pulsed) or false (timed out); the lock is held either way

Note step 6: even on timeout, Wait returns with the lock held.

2. Why while is mandatory

T1 (consumer): lock; count==0 -> Wait (releases)
T2 (consumer): lock; count==0 -> Wait (releases)
P  (producer): lock; Enqueue(x); PulseAll; unlock
T1 wakes, reacquires: Dequeue -> x     (count 0)
T2 wakes, reacquires: with `if` -> Dequeue on empty -> crash
                      with `while` -> recheck count==0 -> Wait again (correct)

The loop also protects against wake-ups caused by PulseAll for an unrelated condition.

3. Lost wake-ups with Pulse

With one monitor and two conditions (not-full, not-empty), Pulse might wake a producer when a consumer should run. If that producer just waits again, nobody wakes the consumer: a hang. PulseAll (or separate monitor objects per condition) avoids this at the cost of more wake-ups.

4. TryEnter for diagnosable locks

bool taken = false;
try
{
    Monitor.TryEnter(_gate, TimeSpan.FromSeconds(5), ref taken);
    if (!taken)
    {
        _log.LogError("Could not acquire ledger lock within 5s; possible deadlock");
        throw new TimeoutException("Ledger lock timeout");
    }
    UpdateLedger();
}
finally
{
    if (taken) Monitor.Exit(_gate);
}

This turns an invisible hang into a logged, alertable error. Very short timeouts as a "performance trick" just add failure paths.

5. Thin locks and sync blocks

State Where the lock lives Triggered by
Thin lock Object header bits: owner thread id + recursion count First uncontended Enter
Sync block Entry in the runtime's sync block table (with wait queues, an event) Contention, Wait, or the header already holding a hash code

This is why any reference type can be a lock, and why Monitor.Wait on an object always inflates it to a sync block. dotnet-dump's syncblk command lists sync blocks with their owners and waiters.

6. Monitor waits and Interrupt

Monitor.Wait and contended Enter are managed blocking waits, so Thread.Interrupt() can break them with ThreadInterruptedException, and they're visible as WaitSleepJoin in thread state.

7. Version notes

Version Change
.NET Framework 1.0 Monitor, Wait/Pulse
.NET Framework 4.0 Enter(obj, ref lockTaken), BlockingCollection<T>
.NET Framework 4.5 IsEntered
.NET Core 3.0 LockContentionCount, System.Threading.Channels
.NET 9 System.Threading.Lock (no condition signalling)

8. Try it yourself

  1. Run the code example; then change while to if and run with three consumers and 10,000 items to reproduce failures.
  2. Replace PulseAll with Pulse in both methods and look for hangs under stress.
  3. Call Monitor.Pulse(_gate) outside the lock and observe SynchronizationLockException.
  4. Enter the lock twice, call Wait with a timeout, and confirm the recursion count is restored (you need two Exit calls afterwards).
  5. Rewrite the buffer with Channel.CreateBounded<int>(2) and compare.
  6. Take a dump while threads are waiting and run syncblk.

💻 Code

using System.Threading;

var buffer = new BoundedBuffer<int>(capacity: 2);
var consumer = Task.Run(() =>
{
    for (int i = 0; i < 5; i++) Console.WriteLine($"Took {buffer.Take()}");
});
for (int i = 1; i <= 5; i++) buffer.Add(i);
await consumer;

public sealed class BoundedBuffer<T>(int capacity)
{
    private readonly object _gate = new();
    private readonly Queue<T> _items = new();

    public void Add(T item)
    {
        lock (_gate)
        {
            while (_items.Count == capacity) Monitor.Wait(_gate); // releases _gate while waiting
            _items.Enqueue(item);
            Monitor.PulseAll(_gate); // wake consumers (and producers)
        }
    }

    public T Take()
    {
        lock (_gate)
        {
            while (_items.Count == 0) Monitor.Wait(_gate);
            T item = _items.Dequeue();
            Monitor.PulseAll(_gate);
            return item;
        }
    }

    public bool TryAddWithin(T item, TimeSpan timeout)
    {
        bool taken = false;
        try
        {
            Monitor.TryEnter(_gate, timeout, ref taken);
            if (!taken || _items.Count == capacity) return false;
            _items.Enqueue(item);
            Monitor.PulseAll(_gate);
            return true;
        }
        finally
        {
            if (taken) Monitor.Exit(_gate);
        }
    }
}

⚠ Common mistakes

1. Monitor.Wait inside if instead of while

What happens: a woken thread assumes the condition is true. Why it is wrong: another thread can change state between the pulse and the reacquire. Do instead: always loop and recheck after Wait returns.

2. Wait/Pulse without holding the lock

What happens: Monitor.Pulse(_gate) called outside lock (_gate). Why it is wrong: throws SynchronizationLockException. Do instead: call them inside the same lock block.

3. Treating a pulse as a stored signal

What happens: a producer pulses before the consumer starts waiting. Why it is wrong: the pulse is lost; the consumer waits forever. Do instead: wait on shared state; or use ManualResetEventSlim/channels that remember.

4. Pulse with multiple conditions on one monitor

What happens: producers and consumers share one monitor and use Pulse. Why it is wrong: the wrong waiter can be woken → lost wake-up → hang. Do instead: PulseAll, or separate monitors per condition.

5. Assuming Wait returns without the lock on timeout

What happens: code after a timed-out Wait behaves as if unlocked. Why it is wrong: Wait always reacquires before returning. Do instead: handle the false return while still inside the lock.

6. Writing custom Wait/Pulse queues in new code

What happens: hand-rolled producer/consumer. Why it is wrong: subtle bugs, can't be awaited, hard to test. Do instead: Channel<T> or BlockingCollection<T>.

7. Using Monitor across await

What happens: Monitor.Enter then await, then Monitor.Exit. Why it is wrong: the continuation may run on another thread → SynchronizationLockException and a lock held forever. Do instead: SemaphoreSlim.WaitAsync.

8. No timeouts anywhere

What happens: every Enter/Wait is unbounded. Why it is wrong: a single bug becomes a silent production hang. Do instead: TryEnter/Wait with timeouts on critical paths, with logging.

9. Very short TryEnter timeouts for speed

What happens: TryEnter(_gate, 1) used to "avoid waiting". Why it is wrong: adds failure paths and retries; doesn't fix contention. Do instead: reduce contention (shorter critical sections, less sharing).

10. Locking on a System.Threading.Lock as object to use Wait

What happens: Monitor.Wait((object)_lock). Why it is wrong: mixes two different lock mechanisms on the same instance. Do instead: use a plain object with Monitor if you need condition signalling.

🏛 Senior insight: trade-offs

Knowing Wait/Pulse shows you understand condition variables, which every concurrency primitive is built on. In production code I almost always reach for a channel instead, and I use TryEnter with a timeout mainly to turn silent deadlocks into logged errors.

Monitor feature When I still use it
TryEnter with timeout Diagnosing or preventing hangs
IsEntered Debug asserts that a lock is held
Wait/Pulse Legacy code, very custom coordination
Plain Enter/Exit Through lock only

🔗 Follow-ups the interviewer may ask

  1. When: Candidate compares with the lock statement Why: Checks lock lowering and System.Threading.Lock
    How to answer

    lock (x) { ... } gives mutual exclusion: only one thread at a time runs the block for the same lock object, and entering and leaving act as memory barriers. For a normal object the compiler emits Monitor.Enter(x, ref lockTaken) in a try/finally with Monitor.Exit. Since .NET 9 and C# 13, if x is a System.Threading.Lock, the compiler instead uses x.EnterScope() and disposes the scope, which is a dedicated, slightly faster lock type. Locks are re-entrant on the same thread, you cannot await inside one (CS1996), and you should lock on a private, readonly object and keep the block short.

  2. When: Candidate mentions thin locks and sync blocks Why: Probes object header internals
    How to answer

    Each object has a pointer-sized header before its MethodTable pointer. The header can hold a thin lock (owning thread id and recursion count), the object's identity hash code (from RuntimeHelpers.GetHashCode or the default object.GetHashCode), or an index into the sync block table. When an object needs more than the header can hold (contention on a lock, a hash code and a lock at the same time, or other per-object data), the runtime inflates it to a sync block that holds a full monitor. lock (obj) uses Monitor, which uses this mechanism; .NET 9's System.Threading.Lock is a separate type that does not use the object header.

  3. When: Candidate mentions thread affinity Why: Checks OS-level Mutex
    How to answer

    A Mutex is an operating-system mutual-exclusion object. Unlike lock, it can be named and shared between processes on the same machine, which is its main use: single-instance applications or protecting a shared file. It is much slower than lock because every acquire involves the kernel, it is thread-affine (the owning thread must release it) and re-entrant. If the owner exits without releasing, the next waiter gets AbandonedMutexException, which tells you the protected state might be inconsistent. It is not suitable for async code.

🛠 Real-world problems: how you handle them

Crash from Monitor.Wait without a loop
Situation
A [legacy job scheduler] with [six] worker threads used `Monitor.Wait` inside an `if`.
Signals
Occasional `InvalidOperationException: Queue empty` crashes under load.
Investigation
Reproduced with a stress test: two workers woke from `PulseAll` and both tried to dequeue one job.
Resolution
Replaced `if` with `while`, then later replaced the custom queue with `Channel<Job>`.
Prevention
Kept the stress test in CI and banned hand-written Wait/Pulse in new code.

How to tell it (STAR)

Our [scheduler] crashed now and then with an empty-queue error. I wrote a stress test and showed two workers waking for one job, because `Wait` was inside an `if`. A `while` loop fixed it, and later we swapped the custom queue for a channel.

↩ Usually asked after

More in Synchronization

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me