Skip to main content

lock vs Monitor: Deep Dive

Overview

Understanding the relationship between lock and Monitor is essential for senior-level interviews. lock is syntactic sugar for Monitor, but Monitor provides more control.

What is lock?

Basic lock Syntax

private readonly object _lockObj = new();

public void ThreadSafeMethod()
{
lock (_lockObj)
{
// Critical section - only one thread at a time
_sharedResource++;
}
}

What lock Actually Does

// When you write this:
lock (_lockObj)
{
// Critical section
}

// The compiler generates this:
bool lockTaken = false;
try
{
Monitor.Enter(_lockObj, ref lockTaken);
// Critical section
}
finally
{
if (lockTaken)
Monitor.Exit(_lockObj);
}

Monitor Explained

Monitor.Enter and Monitor.Exit

private readonly object _lockObj = new();

public void ManualLocking()
{
Monitor.Enter(_lockObj);
try
{
// Critical section
_sharedResource++;
}
finally
{
Monitor.Exit(_lockObj); // Always release in finally!
}
}

Monitor.TryEnter - Timeout Support

private readonly object _lockObj = new();

public bool TryDoWork(int timeoutMs = 1000)
{
bool lockTaken = false;
try
{
Monitor.TryEnter(_lockObj, timeoutMs, ref lockTaken);

if (!lockTaken)
{
Console.WriteLine("Could not acquire lock");
return false;
}

// Critical section
DoWork();
return true;
}
finally
{
if (lockTaken)
Monitor.Exit(_lockObj);
}
}

Monitor.Wait and Monitor.Pulse

Producer-Consumer Pattern

public class ProducerConsumer\<T\>
{
private readonly Queue\<T\> _queue = new();
private readonly object _lock = new();
private readonly int _maxSize;

public ProducerConsumer(int maxSize = 10)
{
_maxSize = maxSize;
}

// Producer
public void Produce(T item)
{
lock (_lock)
{
// Wait while queue is full
while (_queue.Count >= _maxSize)
{
Monitor.Wait(_lock); // Release lock and wait
}

_queue.Enqueue(item);
Console.WriteLine($"Produced: {item}, Queue: {_queue.Count}");

Monitor.Pulse(_lock); // Wake up one waiting consumer
}
}

// Consumer
public T Consume()
{
lock (_lock)
{
// Wait while queue is empty
while (_queue.Count == 0)
{
Monitor.Wait(_lock); // Release lock and wait
}

T item = _queue.Dequeue();
Console.WriteLine($"Consumed: {item}, Queue: {_queue.Count}");

Monitor.Pulse(_lock); // Wake up one waiting producer
return item;
}
}
}

// Usage
var pc = new ProducerConsumer<int>(maxSize: 5);

// Producer thread
Task.Run(() =>
{
for (int i = 0; i < 10; i++)
{
pc.Produce(i);
Thread.Sleep(100);
}
});

// Consumer thread
Task.Run(() =>
{
for (int i = 0; i < 10; i++)
{
int item = pc.Consume();
Thread.Sleep(200);
}
});

Monitor.Wait Explained

// What Monitor.Wait does:
// 1. Releases the lock on the object
// 2. Blocks the current thread
// 3. Waits for Monitor.Pulse or Monitor.PulseAll
// 4. Re-acquires the lock before returning

lock (_lock)
{
while (!_condition)
{
Monitor.Wait(_lock);
// Thread is blocked here until pulsed
// When it wakes up, lock is re-acquired
}
// Lock is held here
}

Monitor.Pulse vs Monitor.PulseAll

public class WaitPulseExample
{
private readonly object _lock = new();
private bool _ready = false;

// Pulse - wakes ONE waiting thread
public void UsePulse()
{
lock (_lock)
{
_ready = true;
Monitor.Pulse(_lock); // Wakes ONE thread
}
}

// PulseAll - wakes ALL waiting threads
public void UsePulseAll()
{
lock (_lock)
{
_ready = true;
Monitor.PulseAll(_lock); // Wakes ALL threads
}
}

public void WaitForReady()
{
lock (_lock)
{
while (!_ready)
{
Monitor.Wait(_lock);
}
// Proceed when ready
}
}
}

When to Use Monitor Over lock

Use Case 1: Timeout Required

public class TimeoutExample
{
private readonly object _lock = new();

public bool TryProcessWithTimeout(int timeoutMs)
{
if (Monitor.TryEnter(_lock, timeoutMs))
{
try
{
// Process work
return true;
}
finally
{
Monitor.Exit(_lock);
}
}

return false; // Timeout
}
}

Use Case 2: Wait/Pulse for Coordination

public class Barrier
{
private readonly object _lock = new();
private int _count;
private readonly int _threshold;

public Barrier(int threshold)
{
_threshold = threshold;
}

public void Await()
{
lock (_lock)
{
_count++;

if (_count < _threshold)
{
// Wait for others
Monitor.Wait(_lock);
}
else
{
// All arrived, wake everyone up
_count = 0;
Monitor.PulseAll(_lock);
}
}
}
}

Use Case 3: Preventing Timer Overlap

public class NonOverlappingTimer
{
private readonly object _lock = new();
private readonly Timer _timer;

public NonOverlappingTimer(int intervalMs)
{
_timer = new Timer(Callback, null, 0, intervalMs);
}

private void Callback(object state)
{
// Try to acquire lock immediately, don't wait
if (Monitor.TryEnter(_lock, 0))
{
try
{
// Previous callback completed, safe to run
DoWork();
}
finally
{
Monitor.Exit(_lock);
}
}
else
{
Console.WriteLine("Previous callback still running, skipping");
}
}

private void DoWork()
{
// Long-running work
Thread.Sleep(2000);
}
}

Deadlock Prevention

The Deadlock Problem

// ❌ DEADLOCK!
public class DeadlockExample
{
private readonly object _lock1 = new();
private readonly object _lock2 = new();

public void Method1()
{
lock (_lock1)
{
Thread.Sleep(100); // Simulate work
lock (_lock2) // Thread 2 may have lock2!
{
// Deadlock possible here
}
}
}

public void Method2()
{
lock (_lock2)
{
Thread.Sleep(100); // Simulate work
lock (_lock1) // Thread 1 may have lock1!
{
// Deadlock possible here
}
}
}
}

Solution 1: Lock Ordering

// ✅ Always acquire locks in same order
public class LockOrderingSolution
{
private readonly object _lock1 = new();
private readonly object _lock2 = new();

public void Method1()
{
lock (_lock1) // Always lock1 first
{
lock (_lock2) // Then lock2
{
// Safe!
}
}
}

public void Method2()
{
lock (_lock1) // Always lock1 first
{
lock (_lock2) // Then lock2
{
// Safe!
}
}
}
}

Solution 2: Monitor.TryEnter with Timeout

// ✅ Use timeout to detect potential deadlock
public class TimeoutSolution
{
private readonly object _lock1 = new();
private readonly object _lock2 = new();

public bool TryDoWork()
{
bool lock1Taken = false;
bool lock2Taken = false;

try
{
Monitor.TryEnter(_lock1, 1000, ref lock1Taken);
if (!lock1Taken) return false;

Monitor.TryEnter(_lock2, 1000, ref lock2Taken);
if (!lock2Taken) return false;

// Both locks acquired
DoWork();
return true;
}
finally
{
if (lock2Taken) Monitor.Exit(_lock2);
if (lock1Taken) Monitor.Exit(_lock1);
}
}
}

Solution 3: Single Lock

// ✅ Use single lock when possible
public class SingleLockSolution
{
private readonly object _lock = new(); // One lock for all
private Resource1 _resource1;
private Resource2 _resource2;

public void Method1()
{
lock (_lock)
{
// Access both resources safely
_resource1.Update();
_resource2.Update();
}
}

public void Method2()
{
lock (_lock)
{
// Access both resources safely
_resource1.Read();
_resource2.Read();
}
}
}

Common Mistakes

Mistake 1: Locking on this

// ❌ BAD - external code can lock on your instance
public class Bad
{
public void Method()
{
lock (this) // Don't do this!
{
// External code could also lock(instance)
}
}
}

// ✅ GOOD - private lock object
public class Good
{
private readonly object _lock = new();

public void Method()
{
lock (_lock) // Only you can lock this
{
// Safe!
}
}
}

Mistake 2: Locking on string or Type

// ❌ BAD - strings are interned, shared across app
private string _lock = "MyLock"; // DON'T!

lock (_lock) // All code locking on "MyLock" shares same lock!
{
// Unintended coordination!
}

// ❌ BAD - Type objects are shared
lock (typeof(MyClass)) // Shared across all instances!
{
// Unintended coordination!
}

Mistake 3: Forgetting to Pulse

// ❌ BAD - threads wait forever
lock (_lock)
{
_ready = true;
// Forgot Monitor.Pulse(_lock)!
}
// Waiting threads never wake up!

// ✅ GOOD
lock (_lock)
{
_ready = true;
Monitor.Pulse(_lock); // Wake waiting threads
}

Interview Questions

Q: What's the difference between lock and Monitor?

A: lock is syntactic sugar that compiles to Monitor.Enter/Exit in a try-finally block. Monitor provides additional methods like TryEnter (with timeout), Wait (release and wait for pulse), and Pulse/PulseAll (wake waiting threads). Use lock for simple cases, Monitor when you need timeouts or wait/pulse coordination.

Q: Explain Monitor.Wait and Monitor.Pulse.

A: Monitor.Wait releases the lock and blocks the thread until another thread calls Monitor.Pulse or PulseAll. When pulsed, the thread wakes up and re-acquires the lock before continuing. This is used for thread coordination, like producer-consumer patterns.

Q: How do you prevent deadlocks?

A: Use consistent lock ordering (always acquire locks in the same order), use Monitor.TryEnter with timeout to detect deadlock, minimize lock scope, avoid nested locks when possible, or use a single lock for related resources.

Q: Why should you never lock on this, string, or typeof?

A: Locking on this allows external code to lock on your instance. Strings are interned and shared across the application. Type objects are shared across all instances. All of these can cause unintended lock sharing and deadlocks.

Q: When would you use Monitor.TryEnter instead of lock?

A: When you want timeout support (avoid waiting indefinitely), when implementing non-blocking patterns (check if lock is available without waiting), or when you want to detect potential deadlocks.

Practice Exercises

  1. Implement producer-consumer queue with Monitor.Wait/Pulse
  2. Create a timer that uses Monitor.TryEnter to prevent overlapping executions
  3. Demonstrate a deadlock scenario and fix it
  4. Build a barrier synchronization using Monitor.Wait/PulseAll
  5. Implement a thread-safe cache with timeout support using Monitor.TryEnter
  • Concurrent Collections in .NET
  • Async Synchronization patterns