Free lesson · Concurrency Interview Mastery
Lock, Monitor, and Synchronization
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) { ... }
- Lock on a private, dedicated object - a
private readonly objectcreated just for locking. This gives you full control over who can acquire it. - Never
lock(this)- external code might also lock on your object's reference, causing unexpected contention or deadlock you can't see or control. - Never
lock(typeof(SomeType))or lock on a string - type objects and interned strings are shared across the whole application domain, so unrelated code locking on the same type or string literal will contend with you, again causing deadlocks you can't predict.
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 aType, 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,readonlyobject that nothing outside your class can reach. Thereadonlymatters 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:
Monitor.TryEnter- attempt to acquire without blocking forever, optionally with a timeout - the C# equivalent of try-lock and timed locking.Monitor.Wait/Monitor.Pulse/Monitor.PulseAll- the condition-variable mechanism: a thread holding the monitor canWait(releasing the monitor and blocking until signaled), and another thread canPulse(wake one waiter) orPulseAll(wake all). This is how you build producer-consumer signaling.
// 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.Waitis always used inside awhileloop re-checking the condition, never a bareif- 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.Waitatomically releases the lock and blocks, and re-acquires it on wakeup. This is C#'s condition-variable mechanism, and theMonitor.Wait/Pulsepair is the reason you'd ever drop fromlockdown toMonitordirectly.
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:
Mutex- like a lock but can work across processes (system-wide), at higher cost. Use it only when you need cross-process mutual exclusion; for in-process locking,lockis far cheaper.SemaphoreSlim(andSemaphore) - limits how many threads can enter a section concurrently (e.g., cap to 10 simultaneous operations).SemaphoreSlimis the lightweight in-process version and, importantly, supportsasync(WaitAsync), making it the go-to for limiting concurrency in async code.ReaderWriterLockSlim- allows many concurrent readers or one exclusive writer; the C# reader-writer lock for read-heavy shared data where readers shouldn't block each other.ManualResetEventSlim/AutoResetEvent- signaling primitives: one thread waits for an event, another sets it.AutoResetEventresets after releasing one waiter;ManualResetEventstays signaled until reset, releasing all waiters.
// 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
lockfor ordinary mutual exclusion - it's simple, exception-safe, and provides memory ordering. Reach for the specialized primitives only when you need their specific capability:SemaphoreSlimto cap concurrency (especially in async code),ReaderWriterLockSlimfor read-heavy data,Mutexfor cross-process locking, andMonitor.Wait/Pulsefor condition-variable signaling. Knowing which primitive solves which problem - and thatlockis 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/Pulsein favor of higher-level constructs -BlockingCollection<T>for producer-consumer,Channel<T>for async pipelines (covered later), or the concurrent collections inSystem.Collections.Concurrent. But understanding thelockplusMonitorfoundation 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
lockstatement and what's it built on?" - Mutual exclusion over a block, compiling toMonitor.Enter/Exitin a try/finally (hence exception-safe), and it also provides memory ordering. (2) "What should you lock on?" - A private,readonly, dedicated object - neverthis, aType, or a string, because those are publicly or globally reachable and let outside code deadlock with you. (3) "lockvsMonitor?" -lockis the sugar;Monitordirectly addsTryEnter(non-blocking/timed) andWait/Pulse(condition variables). (4) "How do you limit concurrency in async code?" -SemaphoreSlimwithWaitAsync. (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.