Back to Blog
C#

C# Monitor vs Lock: When to Use Each

c# monitor vs lock: The lock statement compiles to Monitor.Enter and Monitor.Exit. Learn when the higher-level syntax is enough and when you need Monitor's advanced fe...

C#threadingconcurrencyMonitorlock statementsynchronization
A diagram showing the C# lock statement expanding into Monitor.Enter and Monitor.Exit calls around a critical section.

c# monitor vs lock requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

What the lock Statement Actually Compiles To

The lock statement in C# is not a separate synchronization primitive. When you write:

private readonly object _sync = new object(); public void Update() { lock (_sync) { // critical section } }

the compiler translates it into a Monitor.Enter call followed by a Monitor.Exit call in a finally block. Since C# 4, the generated code uses the lockTaken overload so that Exit is only called when the lock was actually acquired:

public void Update() { object sync = _sync; bool lockTaken = false; try { Monitor.Enter(sync, ref lockTaken); // critical section } finally { if (lockTaken) { Monitor.Exit(sync); } } }

The lockTaken parameter matters because Monitor.Enter can throw before the lock is acquired — for example, an ArgumentNullException when the object reference is null. Without the flag, a finally block that unconditionally calls Monitor.Exit would throw a SynchronizationLockException when the lock was never acquired.

So the practical answer to c# monitor vs lock is that lock is Monitor with a safer default shape. The runtime behavior is identical: both use the same monitor entry and exit mechanism.

When Manual Monitor.Enter and Monitor.Exit Are Necessary

The lock statement always blocks until the monitor is acquired. There is no built-in way to express a timeout or a non-blocking attempt with lock. That is where Monitor's additional API surface matters.

Monitor.TryEnter accepts a timeout and returns false when the lock could not be acquired within the given time:

if (Monitor.TryEnter(_sync, TimeSpan.FromSeconds(2))) { try { // critical section } finally { Monitor.Exit(_sync); } } else { // The lock was not acquired within 2 seconds. // Handle the contention case explicitly. }

This pattern is useful in operations that must not stall indefinitely, such as a background worker that should give up and retry later, or a UI-triggered operation that should surface a timeout to the user instead of freezing the thread.

Monitor.TryEnter also has an overload that takes an integer milliseconds value, and both overloads have variants with the ref bool lockTaken parameter. Use the lockTaken variant whenever the body of the try block can throw before the lock is acquired.

Signaling Between Threads with Monitor.Wait and Monitor.Pulse

The lock statement gives you mutual exclusion, but it gives you no way to coordinate threads beyond that. Monitor.Wait, Monitor.Pulse, and Monitor.PulseAll provide a basic signaling mechanism that lock cannot express.

Monitor.Wait releases the monitor and blocks the calling thread until it is pulsed. Monitor.Pulse wakes one waiting thread; Monitor.PulseAll wakes all of them. Both must be called from inside the monitor.

A typical producer-consumer handoff looks like this:

private readonly object _sync = new object(); private bool _ready; public void Produce() { lock (_sync) { _ready = true; Monitor.Pulse(_sync); } } public void Consume() { lock (_sync) { while (!_ready) { Monitor.Wait(_sync); } // _ready is true here. } }

The while loop matters. Monitor.Wait can return even when the condition is still false, because another thread may have pulsed and then changed the state before the waiting thread reacquired the monitor. Rechecking the condition in a loop is the standard way to handle that.

Reentrancy and Nested Locking

Both lock and Monitor are reentrant. The same thread can enter the same monitor multiple times, and each entry must be balanced by a corresponding exit. The lock statement handles that balance automatically because each lock block emits its own Monitor.Exit in its finally block.

Manual Monitor.Enter/Monitor.Exit calls are where reentrancy becomes a maintenance risk. If a method acquires the monitor and then calls another method that also acquires it, you now have two Enter calls and need two Exit calls:

public void Outer() { Monitor.Enter(_sync); try { Inner(); } finally { Monitor.Exit(_sync); } } public void Inner() { Monitor.Enter(_sync); DoWork(); // if this throws, the monitor stays held Monitor.Exit(_sync); }

If DoWork throws, Inner never reaches its Monitor.Exit, and the monitor remains held by the current thread. Every other thread that tries to enter the monitor blocks indefinitely. The lock statement prevents this class of bug because the compiler emits the Exit in a finally block.

Runtime Cost and Contention Behavior

The lock statement does not add meaningful overhead compared to calling Monitor.Enter and Monitor.Exit directly, because it compiles to those exact calls. The performance difference between lock and Monitor is therefore not about the locking mechanism itself but about which Monitor methods you use.

Monitor.Enter blocks the thread when the monitor is held by another thread. Blocking involves a kernel transition, which is significantly more expensive than the uncontended fast path where the lock is acquired with an atomic compare-and-exchange. Monitor.TryEnter with a timeout behaves the same way when it blocks, but it gives you the option to avoid blocking entirely by passing a zero timeout.

The cost of Monitor.Wait and Monitor.Pulse is higher than plain enter/exit because they involve releasing the monitor, putting the thread to sleep, and later reacquiring the monitor when pulsed. If you only need mutual exclusion, using Wait/Pulse adds complexity and runtime cost without benefit.

One operational detail worth knowing: Monitor is not safe to use across await boundaries. The monitor is held by a thread, and an await can resume on a different thread, so the Exit call would throw a SynchronizationLockException. The compiler prevents await inside a lock block, but manual Monitor.Enter/Exit across an await is a runtime failure waiting to happen.

Choosing Between lock and Monitor

Use lock as the default for any critical section that only needs mutual exclusion. It is shorter, it generates the correct try/finally shape, and it eliminates the class of bugs where Monitor.Exit is skipped on an exception path.

Reach for Monitor directly when you need one of these specific behaviors:

  • Monitor.TryEnter with a timeout or a non-blocking attempt
  • Monitor.Wait and Monitor.Pulse for thread signaling
  • Monitor.Enter with the lockTaken overload in code that must handle the case where the lock acquisition itself throws

If none of those apply, lock is the safer and more readable choice. The lock statement is not a simplified version of Monitor with less capability; it is the correct default shape for the most common synchronization need, and Monitor's extra methods exist for the cases where that default is not enough.

c# monitor vs lock: Practical Usage and Code Examples | RYUSLOG DEV