Monitor.Enter/Exit, TryEnter with timeouts, Wait/Pulse/PulseAll condition signalling, thread affinity and the object header thin lock and sync block.
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.
"
Monitoris the class behindlockfor ordinary objects. BeyondEnter/Exitit gives three useful things.TryEnterwith 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 withWait,PulseandPulseAll:Waitatomically 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,Waitmust always sit in awhileloop that rechecks the condition. Monitor locks are thread-affine:Wait,PulseandExitmust be called by the owning thread, or you getSynchronizationLockException. A pulse with no waiters is lost: it's not stored. In new code I rarely writeWait/Pulseby hand;Channel<T>,BlockingCollection<T>,SemaphoreSlimand the event types do it for you, and they can be async."
| 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+) |
| 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.
// 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);
}
| 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 |
| 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.
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.
Checks deeper synchronisation knowledge.
Green flags
TryEnter, Wait, Pulse correctly.while loop.Red flags
if around Wait.Pulse is queued for later.Wait really doesMonitor.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.
while is mandatoryT1 (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.
PulseWith 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.
TryEnter for diagnosable locksbool 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.
| 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.
InterruptMonitor.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.
| 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) |
while to if and run with three consumers and 10,000 items to reproduce failures.PulseAll with Pulse in both methods and look for hangs under stress.Monitor.Pulse(_gate) outside the lock and observe SynchronizationLockException.Wait with a timeout, and confirm the recursion count is restored (you need two Exit calls afterwards).Channel.CreateBounded<int>(2) and compare.syncblk.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);
}
}
}
Monitor.Wait inside if instead of whileWhat 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.
Wait/Pulse without holding the lockWhat happens: Monitor.Pulse(_gate) called outside lock (_gate).
Why it is wrong: throws SynchronizationLockException.
Do instead: call them inside the same lock block.
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.
Pulse with multiple conditions on one monitorWhat 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.
Wait returns without the lock on timeoutWhat 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.
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>.
awaitWhat 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.
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.
TryEnter timeouts for speedWhat 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).
System.Threading.Lock as object to use WaitWhat 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.
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 |
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.
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.
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.
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.
Last updated 31 Oct 2026.