lock lowering to Monitor or Lock.EnterScope, choosing the lock object, re-entrancy, why you cannot await inside a lock, and lock scope design.
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.
"
lock (x) { ... }gives mutual exclusion per lock object: only one thread at a time runs code guarded by the samex. 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 toMonitor.Enter(x, ref lockTaken)insidetry/finallywithMonitor.Exit, so the lock is released even on exceptions. Since .NET 9 with C# 13, ifxis aSystem.Threading.Lock, the compiler instead callsx.EnterScope()and disposes the returned scope. That's a dedicated lock type: clearer intent, and it avoids the object-header and sync-block machineryMonitoruses. Locks are re-entrant on the same thread; you can'tawaitinside one (CS1996) because they're thread-affine, so async critical sections useSemaphoreSlim. 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."
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) |
// 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.
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 |
| 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 |
| 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 |
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.
Checks core synchronisation knowledge and awareness of modern .NET.
Green flags
Lock type.Red flags
this or strings.lock around async I/O.Monitor uses the object headerEvery 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)
Monitor.Enter does an interlocked compare-exchange on the header: tens of nanoseconds.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.
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.
await is forbidden inside lockThread 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(); }
// 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.
| 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 |
| 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 |
| 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 |
lock on object and on Lock into SharpLab and compare the lowered code.lock(object) vs lock(Lock) with 8 threads using BenchmarkDotNet.syncblk and clrstack -all.Thread.Sleep(50) inside the lock under load and watch dotnet.monitor.lock_contentions climb.await inside a lock and read CS1996; rewrite with SemaphoreSlim.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;
}
}
}
this, a Type or a stringWhat 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.
await inside a lockWhat 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.
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.
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.
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.
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.
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.
Lock to objectWhat 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.
lock for cross-process coordinationWhat 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.
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.
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 |
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.
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.
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.
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].
Last updated 31 Oct 2026.