📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How does the lock statement work in C#, and what changed with System.Threading.Lock in .NET 9 and C# 13?

lock lowering to Monitor or Lock.EnterScope, choosing the lock object, re-entrancy, why you cannot await inside a lock, and lock scope design.

Mid-Level Theory Intermediate csharp-13 lock monitor synchronization

🎯 Key points

  • lock provides mutual exclusion and memory visibility per lock object
  • For objects it lowers to Monitor.Enter/Exit in try/finally
  • With System.Threading.Lock (.NET 9, C# 13) it uses EnterScope
  • Lock on a private readonly field, never this, Type or strings
  • Locks are re-entrant on the same thread
  • You cannot await inside a lock; use SemaphoreSlim for async
  • Keep critical sections short and take locks in a consistent order

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"lock (x) { ... } gives mutual exclusion per lock object: only one thread at a time runs code guarded by the same x. Entering and leaving are also memory barriers, so writes made under the lock are visible to the next thread that takes it. For an ordinary object, the compiler lowers it to Monitor.Enter(x, ref lockTaken) inside try/finally with Monitor.Exit, so the lock is released even on exceptions. Since .NET 9 with C# 13, if x is a System.Threading.Lock, the compiler instead calls x.EnterScope() and disposes the returned scope. That's a dedicated lock type: clearer intent, and it avoids the object-header and sync-block machinery Monitor uses. Locks are re-entrant on the same thread; you can't await inside one (CS1996) because they're thread-affine, so async critical sections use SemaphoreSlim. In practice I lock on a private readonly field, keep the critical section to exactly the invariant being protected (no I/O, no logging, no callbacks), and take multiple locks in a consistent order."


1. What lock guarantees

Guarantee Meaning
Mutual exclusion One thread at a time per lock object
Visibility / ordering Acquire on enter, release on exit: no volatile needed for fields consistently protected by the same lock
Exception safety Released in finally
Re-entrancy The owning thread can enter again (must exit the same number of times)
Fairness Not guaranteed (no FIFO order)

2. What the compiler generates

// lock (_gate) { Body(); }   where _gate is object
bool taken = false;
try
{
    Monitor.Enter(_gate, ref taken);
    Body();
}
finally
{
    if (taken) Monitor.Exit(_gate);
}

// lock (_lock) { Body(); }   where _lock is System.Threading.Lock (C# 13, .NET 9+)
using (_lock.EnterScope())
{
    Body();
}

If a Lock is cast to object and then locked, the compiler warns: it would silently fall back to Monitor.

3. Monitor on objects vs System.Threading.Lock

Aspect lock (object) lock (Lock)
Availability All versions .NET 9+ with C# 13
Where lock state lives Object header (thin lock) → inflates to a sync block under contention Inside the Lock instance
Wait/Pulse Yes (Monitor.Wait/Pulse) No
Intent Any object could be a lock Obviously a lock
Extra API Monitor.TryEnter(timeout), IsEntered Enter, TryEnter(timeout), Exit, IsHeldByCurrentThread, EnterScope
Performance Good Somewhat cheaper, especially under contention

4. Choosing the lock object

Lock on Verdict Why
private readonly Lock _lock = new(); Best (.NET 9+) Private, dedicated
private readonly object _gate = new(); Good (older targets) Private
this Avoid Callers can lock the same instance → hidden contention/deadlocks
typeof(MyType) Avoid Global across the process (and AppDomain-agile historically)
String literal Avoid Interned: unrelated code shares the same object
Value type Compile error Would box a new object each time

5. Rules that prevent incidents

Rule Why
No await inside Thread-affine lock; use SemaphoreSlim(1, 1)
No I/O, logging sinks, HTTP, DB inside Holds the lock for milliseconds–seconds; everyone queues
No calls into unknown code (events, callbacks, virtuals) Re-entrancy and lock-order inversions
Consistent lock ordering Prevents deadlocks when multiple locks are needed
Protect reads too Unlocked reads can see torn/stale state
Measure contention dotnet.monitor.lock_contentions metric (.NET 9+ System.Runtime) and traces

Production example

Our [API gateway's in-memory rate limiter] kept per-client counters in a Dictionary guarded by a lock, and it logged every throttling decision inside the lock. Whenever the log sink slowed down (a network sink with back-pressure), every request queued behind that lock: latency jumped to [seconds] with low CPU, and the lock contention counter spiked. A trace showed most threads in Monitor.Enter while one thread sat inside the sink's write call. I moved logging outside the lock (copying the values needed first), then replaced the dictionary and lock with a ConcurrentDictionary of counters updated with Interlocked. Contention dropped to [near zero]; we added the contention metric to dashboards and a review rule against I/O inside locks.

🔍 What the interviewer is checking

Checks core synchronisation knowledge and awareness of modern .NET.

Green flags

  • Explains the Monitor lowering and the .NET 9 Lock type.
  • Chooses a private lock object and explains why.
  • Knows locks are re-entrant and cannot contain awaits.
  • Talks about keeping critical sections short and lock ordering.

Red flags

  • Locks on this or strings.
  • Uses lock around async I/O.

📚 Learn in depth

1. How Monitor uses the object header

Every reference-type object has an object header word before its MethodTable pointer.

[ object header ][ MethodTable* ][ fields... ]
       |
       +-- thin lock: owning thread id + recursion count (uncontended fast path)
       +-- or: index of a SyncBlock (after contention, or if the header is needed for a hash code)
  • Uncontended: Monitor.Enter does an interlocked compare-exchange on the header: tens of nanoseconds.
  • Contended: the thread spins briefly (adaptive), then the lock inflates to a sync block with a kernel-backed wait. Waiting threads block and are woken on Exit.
  • GetHashCode (default identity hash) also uses the header, so hashed objects can force sync-block allocation when locked.

System.Threading.Lock keeps its state in its own fields, so it never touches object headers or sync blocks, and it can use a simpler, tuned spin/wait strategy.

2. Re-entrancy

lock (_lock)
{
    lock (_lock) { /* same thread: allowed, recursion count 2 */ }
}

Re-entrancy prevents self-deadlock but can hide design problems: a method calling back into the same object while its invariants are temporarily broken.

3. Why await is forbidden inside lock

Thread 7: Monitor.Enter(_gate)  -> await I/O -> method suspends, thread 7 returns to the pool
          continuation resumes on thread 12 -> Monitor.Exit(_gate) from thread 12
          -> SynchronizationLockException (thread 12 doesn't own it); thread 7 "owns" it forever

The compiler blocks this (CS1996). The async equivalent:

private readonly SemaphoreSlim _mutex = new(1, 1);

await _mutex.WaitAsync(ct);
try { await UpdateAsync(ct); }
finally { _mutex.Release(); }

4. Deadlock by lock ordering

// Thread A                     // Thread B
lock (_accounts) {              lock (_ledger) {
    lock (_ledger) { ... }          lock (_accounts) { ... }   // A waits for B, B waits for A
}                               }

Fixes: a global lock order (always _accounts then _ledger), one coarser lock, Monitor.TryEnter/Lock.TryEnter with timeouts plus back-off, or redesign to avoid nested locks.

5. Memory model in one table

Operation Ordering effect
Lock acquire Acquire: later reads/writes can't move before it
Lock release Release: earlier reads/writes can't move after it
Result Everything written under the lock is visible to the next owner

6. Diagnosing contention

Tool What you see
dotnet-counters (dotnet.monitor.lock_contentions) Contention rate
dotnet-trace with contention events Stacks where threads waited and for how long
dotnet-dump → syncblk Which thread owns which contended Monitor
parallelstacks Many threads in Monitor.Enter / Lock.Enter at the same frame

7. Version notes

Version Change
.NET Framework 4.0 Monitor.Enter(obj, ref lockTaken) lowering
.NET Core 3.0 Monitor.LockContentionCount
.NET 9 / C# 13 System.Threading.Lock, lock statement support via EnterScope
.NET 9 System.Runtime metric dotnet.monitor.lock_contentions
.NET 10 Same; analyzers increasingly suggest Lock for dedicated lock fields

8. Try it yourself

  1. Run the code example; remove the lock and observe a wrong balance.
  2. Paste a lock on object and on Lock into SharpLab and compare the lowered code.
  3. Benchmark contended lock(object) vs lock(Lock) with 8 threads using BenchmarkDotNet.
  4. Write the two-lock deadlock, take a dump, and run syncblk and clrstack -all.
  5. Put Thread.Sleep(50) inside the lock under load and watch dotnet.monitor.lock_contentions climb.
  6. Try await inside a lock and read CS1996; rewrite with SemaphoreSlim.

💻 Code

using System.Threading;

var account = new Account();
await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
{
    for (int i = 0; i < 100_000; i++) account.Deposit(1);
})));
Console.WriteLine(account.Balance); // 800000

public sealed class Account
{
    private readonly Lock _lock = new(); // .NET 9+, C# 13
    private decimal _balance;

    public decimal Balance
    {
        get { lock (_lock) return _balance; }
    }

    public void Deposit(decimal amount)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
        lock (_lock)
        {
            _balance += amount; // short, no I/O, no awaits
        }
    }

    public bool TryWithdraw(decimal amount)
    {
        lock (_lock)
        {
            if (_balance < amount) return false;
            _balance -= amount;
            return true;
        }
    }
}

⚠ Common mistakes

1. Locking on this, a Type or a string

What happens: lock (this), lock (typeof(Cache)), lock ("orders"). Why it is wrong: other code can take the same lock, causing invisible contention and deadlocks. Do instead: a private readonly Lock or object field.

2. Trying to await inside a lock

What happens: async code needs mutual exclusion. Why it is wrong: doesn't compile (CS1996); the lock is thread-affine. Do instead: SemaphoreSlim(1, 1) with WaitAsync and Release in finally.

3. I/O, logging or callbacks inside the lock

What happens: the critical section writes logs, calls HTTP or raises events. Why it is wrong: long hold times serialise all threads; callbacks can re-enter or take other locks. Do instead: copy what you need under the lock, do slow work outside.

4. Locking writes but not reads

What happens: a reader accesses a dictionary or multi-field state without the lock. Why it is wrong: torn reads, stale data, or exceptions from concurrent mutation. Do instead: protect reads with the same lock, or use immutable snapshots / concurrent collections.

5. Inconsistent lock ordering

What happens: two code paths take the same two locks in opposite order. Why it is wrong: classic deadlock under load. Do instead: define and document an order, or redesign to one lock.

6. Different lock objects for the same data

What happens: one method locks _a, another locks _b, both touching the same field. Why it is wrong: no mutual exclusion at all. Do instead: one lock per invariant, used everywhere that invariant is touched.

7. Locking on a field that gets reassigned

What happens: lock (_items) { _items = new List<T>(...); }. Why it is wrong: later threads lock a different object. Do instead: lock on a dedicated readonly field.

8. Casting a Lock to object

What happens: a Lock is stored as object and then locked. Why it is wrong: falls back to Monitor on the object header, losing the Lock semantics (the compiler warns). Do instead: keep the field typed as Lock.

9. Using lock for cross-process coordination

What happens: lock is expected to stop two processes writing the same file. Why it is wrong: it's in-process only. Do instead: named Mutex, file locks, or a distributed lock.

10. One giant lock everywhere "to be safe"

What happens: a single static lock guards unrelated state. Why it is wrong: serialises the application and hides real ownership. Do instead: minimise shared mutable state; use finer-grained locks or lock-free structures where measured.

🏛 Senior insight: trade-offs

A lock is a tool for protecting an invariant, so I name the invariant and keep the critical section to exactly that. When locks show up in profiles I first ask whether the state needs to be shared at all.

Situation Choice
Short critical section, sync code lock on a Lock
Critical section with awaits SemaphoreSlim(1, 1)
Single counter Interlocked
Shared map ConcurrentDictionary
Read-mostly config Immutable snapshot swapped atomically

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says lock lowers to Monitor Why: Checks Monitor features beyond lock
    How to answer

    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.

  2. When: Candidate mentions the object header or sync block Why: Probes lock internals in the runtime
    How to answer

    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.

  3. When: Candidate says you cannot await inside a lock Why: Checks async locking with SemaphoreSlim
    How to answer

    SemaphoreSlim is a lightweight, in-process counting semaphore that supports WaitAsync, so it works with async/await. With a count of 1 it is an async lock: await gate.WaitAsync(ct); try { ... } finally { gate.Release(); }. With a count of N it limits how many async operations run at once, for example ten concurrent calls to a partner API. It is not re-entrant and has no owner, so calling it again from inside the protected section deadlocks, and every successful wait must be paired with exactly one Release in finally.

🛠 Real-world problems: how you handle them

Logging inside a lock serialised all requests
Situation
An in-memory [rate limiter] on an API gateway locked a dictionary of counters and logged each decision inside the lock.
Signals
Latency rose to [seconds] whenever the log sink slowed; CPU was low; lock contention counter spiked.
Investigation
A trace showed most threads waiting in `Monitor.Enter` while one thread was in the log sink's write call.
Resolution
Moved logging outside the lock, then replaced the dictionary and lock with `ConcurrentDictionary` and `Interlocked` counters.
Prevention
Added a lock contention metric to dashboards and a review rule against I/O in locks.

How to tell it (STAR)

Our [gateway's rate limiter] made every request wait when logging slowed down. The trace showed threads queued on one lock while the owner was writing a log. I moved the log call out of the lock and later removed the lock with concurrent collections. Contention dropped to [near zero].

↩ Usually asked after

More in Synchronization

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me