AlgoInsight

Free lesson · Concurrency Interview Mastery

Lock, Monitor, and Synchronization

Medium10 min readconcurrencymultithreadingcsharp

This is the everyday synchronization lesson for C#. The lock statement is the tool you'll use most for mutual exclusion, and it turns out to be syntactic sugar over the Monitor class - understanding that relationship explains both how lock works and what extra power Monitor gives you when you need it. We'll then survey the broader landscape of C# synchronization primitives so you know what's available and when to reach for each. The concepts are the universal ones from Module 1; here we nail the C# specifics, including a couple of "what should I lock on" rules that separate correct code from subtly broken code.

The lock statement

The lock statement provides mutual exclusion over a block: only one thread can be inside a lock on a given object at a time. It's clean, automatic, and exception-safe.

private readonly object _lock = new object();
private int _count;

public void Increment() {
    lock (_lock) {
        _count++;          // critical section - one thread at a time
    }
}

The lock releases automatically when the block exits - whether normally or via an exception - so you can't forget to unlock or strand the lock on an exception. As covered in the memory-model lesson, lock also provides memory ordering: writes before releasing the lock are visible to the next thread that acquires it. That dual role - mutual exclusion plus visibility - is exactly what you want for protecting shared state.

What to lock on: the rules that matter

C# lets you lock on any reference object, and choosing the wrong lock object is a classic source of subtle bugs. Three rules:

// GOOD: a private, readonly, dedicated lock object
private readonly object _lock = new object();
lock (_lock) { ... }

Warning: The "what to lock on" question is a favorite interview probe because the wrong answers are tempting and the bugs are nasty. Locking on this, on a Type, or on a string means some other code you've never seen can lock on the same object and deadlock with you, because those references are publicly reachable or globally shared. Always lock on a private, dedicated, readonly object that nothing outside your class can reach. The readonly matters too: if the lock object reference can change, two threads might lock on different objects and both enter the critical section. A private readonly dedicated lock object is the only safe default.

Monitor: what lock is built on

The lock statement compiles to roughly this:

Monitor.Enter(_lock);
try {
    _count++;
} finally {
    Monitor.Exit(_lock);    // lock guarantees this even on exception
}

So lock is sugar over Monitor.Enter/Monitor.Exit wrapped in a try/finally. That's why lock is exception-safe - the generated finally releases the monitor no matter how the block exits. Using Monitor directly is rare, but it exposes capabilities the lock keyword doesn't:

// Condition-variable pattern with Monitor
lock (_lock) {
    while (!_ready)
        Monitor.Wait(_lock);     // release lock & wait until pulsed; re-check in a loop
    Consume();
}
// another thread: lock (_lock) { _ready = true; Monitor.Pulse(_lock); }

Important: Note Monitor.Wait is always used inside a while loop re-checking the condition, never a bare if - this guards against spurious wakeups and lost-signal races (covered fully in later modules). The pattern is: hold the lock, while (!condition) Wait(), then proceed. Wait atomically releases the lock and blocks, and re-acquires it on wakeup. This is C#'s condition-variable mechanism, and the Monitor.Wait/Pulse pair is the reason you'd ever drop from lock down to Monitor directly.

flowchart TD
    L["lock (obj) { ... }"] -->|compiles to| M["Monitor.Enter(obj)<br/>try { ... }<br/>finally { Monitor.Exit(obj) }"]
    M --> E1[Monitor.TryEnter: non-blocking/timed acquire]
    M --> E2[Monitor.Wait/Pulse: condition variables]
This diagram shows that the lock statement in C# compiles down to Monitor calls. A lock block becomes Monitor.Enter on the object, a try block containing the critical section, and a finally block that calls Monitor.Exit - which is why lock is exception-safe. Using Monitor directly also unlocks extra capabilities the lock keyword doesn't expose: Monitor.TryEnter for non-blocking or timed acquisition, and Monitor.Wait and Pulse for condition-variable signaling between threads.

The broader synchronization landscape

C# offers a rich set of synchronization primitives beyond lock/Monitor, each for a specific need. A quick map so the names aren't strangers when they appear in later modules:

// Limit concurrency to 10 (works in async code)
SemaphoreSlim sem = new SemaphoreSlim(10);
await sem.WaitAsync();
try { /* limited section */ } finally { sem.Release(); }

Tip: Default to lock for ordinary mutual exclusion - it's simple, exception-safe, and provides memory ordering. Reach for the specialized primitives only when you need their specific capability: SemaphoreSlim to cap concurrency (especially in async code), ReaderWriterLockSlim for read-heavy data, Mutex for cross-process locking, and Monitor.Wait/Pulse for condition-variable signaling. Knowing which primitive solves which problem - and that lock is the right default - is exactly the judgment interviewers probe. The full treatment of each lives in the synchronization-primitives module; here, recognize the landscape.

Why lock-object choice causes real deadlocks

Let me make the "what to lock on" rules concrete with the kind of scenario that turns them from arbitrary advice into hard-won wisdom. Suppose your class locks on this to protect its internal state. Somewhere else in the codebase, completely unrelated code has a reference to your object and decides to lock on it too - maybe it's using your object as a convenient lock for its own purposes, which is legal because your object's reference is public. Now two pieces of code that know nothing about each other are contending for the same lock. In the best case you get unexpected contention and mysterious slowdowns; in the worst case, if that external code holds the lock on your object while waiting for something you need, you have a deadlock that spans two unrelated parts of the system, nearly impossible to diagnose because nothing in your class's code suggests an outside party is involved.

The same disaster, amplified, happens with lock(typeof(SomeType)) or locking on a string literal. Type objects are shared across the entire application domain, so every piece of code that locks on that type - including code in libraries you didn't write - contends on the same lock. Interned string literals are similarly shared: two unrelated methods that both lock("ready") are locking on the same interned string instance and will block each other for no reason either author intended. These create deadlocks and contention between completely unrelated components, and because the shared lock object is invisible in any single piece of code, the bug is brutal to track down.

This is why the rule is absolute: lock on a private readonly object created solely for locking. Private means no outside code can reach it. Readonly means the reference can't change out from under you (if it could, two threads might lock different objects and both enter the critical section). Dedicated means it has no other purpose that could cause incidental contention. Following this rule guarantees that the only code contending for your lock is the code you wrote inside your class, which is the only sane way to reason about a lock at all.

Holding locks well: granularity and duration

Beyond what to lock on, how long you hold a lock and how much it protects determine whether your concurrency actually helps. A lock serializes everything inside it - only one thread at a time - so the time spent holding a lock is time other threads spend waiting. This is the re-serialization trap from Module 1: hold locks too long or too broadly and you turn a concurrent program back into a sequential one with extra overhead.

The discipline is to make critical sections as small as possible - hold the lock only for the operations that genuinely touch the shared state, and do everything else (slow computation, I/O, anything that doesn't need protection) outside the lock. A common, valuable pattern is to grab the shared data under the lock, copy what you need into a local, release the lock, and then do the expensive work on the local copy without holding the lock. This minimizes contention. The opposite mistake - wrapping an entire method body in a lock when only two lines touch shared state - is a frequent cause of concurrency that doesn't scale, because every thread queues behind every other for the whole method even though most of it didn't need protection.

How C# locking compares

Placing C#'s approach next to the other languages sharpens it. Java's synchronized is the closest analog to C#'s lock - both are intrinsic, block-scoped, auto-released mutual exclusion that also provide memory ordering, and both have a Monitor-style wait/notify (Wait/Pulse in C#) for condition signaling. C++ takes a different route with RAII lock guards, where release is tied to object destruction rather than to a keyword's block. Python uses the with lock: context manager, conceptually similar to C#'s lock in that release is automatic on block exit. The shared insight across all of them is the same: automatic, scope-bound release is far safer than manual lock/unlock, because it can't be forgotten and survives exceptions. C#'s lock achieves this by compiling to a try/finally around Monitor, which is its particular mechanism for the universal goal of "never leave a lock held by accident." Recognizing that every language solved the same problem - guaranteed lock release - with its own idiom is exactly the kind of cross-language synthesis that reads as senior in an interview.

A worked example: producer-consumer with Monitor

To see Monitor.Wait/Pulse working as a condition variable, here's a bounded buffer - the canonical producer-consumer setup - built directly with lock and Monitor. It ties together the locking, the condition signaling, and the always-loop-on-the-condition rule.

private readonly object _lock = new object();
private readonly Queue<int> _buffer = new Queue<int>();
private const int Capacity = 10;

public void Produce(int item) {
    lock (_lock) {
        while (_buffer.Count == Capacity)   // wait for space - note: while, not if
            Monitor.Wait(_lock);            // releases lock, blocks until pulsed
        _buffer.Enqueue(item);
        Monitor.Pulse(_lock);               // wake a waiting consumer
    }
}

public int Consume() {
    lock (_lock) {
        while (_buffer.Count == 0)          // wait for an item
            Monitor.Wait(_lock);
        int item = _buffer.Dequeue();
        Monitor.Pulse(_lock);               // wake a waiting producer
        return item;
    }
}

Trace the mechanics. A consumer that finds the buffer empty calls Monitor.Wait, which atomically releases the lock and blocks - releasing the lock is essential, because otherwise no producer could ever acquire it to add an item and signal. When a producer enqueues and calls Monitor.Pulse, a waiting consumer is woken, re-acquires the lock, and re-checks its condition. The condition check is a while loop, never a bare if, for two reasons covered fully in later modules: spurious wakeups (a thread can wake without being pulsed) and the possibility that another thread changed the state between the pulse and this thread re-acquiring the lock. Re-checking in a loop handles both correctly - the thread only proceeds when the condition is genuinely true, and goes back to waiting otherwise.

This is the same producer-consumer logic you'd build with condition variables in any language - Java's wait/notify, C++'s condition_variable, Python's Condition - just expressed with C#'s Monitor.Wait/Pulse. In real code you'd often reach for a higher-level construct like a BlockingCollection<T> (which implements exactly this safely for you), but understanding the Monitor-based version is what lets you reason about what those higher-level tools do underneath, and it's a common interview exercise. The pattern - lock, loop-and-wait on the condition, act, pulse - is worth committing to muscle memory because it recurs across every concurrency toolkit.

Note: In production C#, you'll often skip hand-written Monitor.Wait/Pulse in favor of higher-level constructs - BlockingCollection<T> for producer-consumer, Channel<T> for async pipelines (covered later), or the concurrent collections in System.Collections.Concurrent. But understanding the lock plus Monitor foundation is what lets you reason about what those higher-level tools do underneath and debug them when they misbehave, which is exactly why this lesson teaches the primitive before the convenience.

The interview angle

Interview: Reliable questions: (1) "What is the lock statement and what's it built on?" - Mutual exclusion over a block, compiling to Monitor.Enter/Exit in a try/finally (hence exception-safe), and it also provides memory ordering. (2) "What should you lock on?" - A private, readonly, dedicated object - never this, a Type, or a string, because those are publicly or globally reachable and let outside code deadlock with you. (3) "lock vs Monitor?" - lock is the sugar; Monitor directly adds TryEnter (non-blocking/timed) and Wait/Pulse (condition variables). (4) "How do you limit concurrency in async code?" - SemaphoreSlim with WaitAsync. (5) "How do you do reader-writer locking?" - ReaderWriterLockSlim. The "what to lock on" rules especially separate people who've been burned by real locking bugs from those who haven't.

Quick reference

private readonly object _lock = new object();   // private, readonly, dedicated
lock (_lock) { /* critical section, auto-released even on exception */ }

// Monitor (what lock compiles to) - extra power:
Monitor.TryEnter(_lock, timeout);               // non-blocking / timed
lock (_lock) { while (!cond) Monitor.Wait(_lock); }   // condition variable
// signal: lock (_lock) { cond = true; Monitor.Pulse(_lock); }

// other primitives:
new SemaphoreSlim(n);        // cap concurrency; WaitAsync for async
new ReaderWriterLockSlim();  // many readers or one writer
new Mutex();                 // cross-process locking (expensive)
AutoResetEvent / ManualResetEventSlim;   // signaling

// NEVER lock(this), lock(typeof(T)), or lock on a string

Next, the modern heart of C# concurrency - the Task Parallel Library, where Task, continuations, Parallel.For, and composition replace most of the raw threading you've just learned.

Back to Concurrency Interview Mastery