lock gives one thread at a time access to a synchronous critical section. It fits small in-process state changes such as updating a counter or taking a snapshot of a shared dictionary. Awaited I/O should normally happen outside that mutation and publish its result inside a short lock. When an invariant genuinely must span asynchronous work, SemaphoreSlim.WaitAsync can gate that bounded span; cross-process coordination needs Mutex, and N concurrent holders need Semaphore rather than mutual exclusion.
How It Works
lock (obj) { body } is sugar over Monitor:
lock (_gate) { _count++; }
// the compiler emits:
bool lockTaken = false;
try
{
Monitor.Enter(_gate, ref lockTaken);
_count++;
}
finally
{
if (lockTaken) Monitor.Exit(_gate);
}lockTaken records whether Monitor.Enter succeeded. If acquisition throws, the finally block does not attempt to release a monitor the thread never owned.
Monitor/lock is reentrant. Its owning thread can acquire the same monitor again. Each acquisition increments a recursion count, so the thread must exit the same number of times. A one-permit semaphore has no such ownership model and self-deadlocks on a nested wait.
System.Threading.Lock (.NET 9+) makes this intent explicit. When the operand has type Lock, the statement uses Lock.EnterScope() and disposes its ref struct scope at the closing brace instead of lowering to Monitor.Enter:
private readonly Lock _gate = new(); // .NET 9+
lock (_gate) { _count++; } // compiler calls _gate.EnterScope()A dedicated Lock field is harder to misuse than a general object. With either type, the gate stays private readonly. Locking on this, a Type, or an interned string exposes the monitor to unrelated code and can create invisible lock-ordering cycles.
Example
public sealed class MetricsBuffer
{
private readonly Lock _gate = new(); // .NET 9+; an `object` pre-9
private readonly Dictionary<string, long> _counts = new();
public void Increment(string name)
{
lock (_gate)
{
_counts.TryGetValue(name, out long n);
_counts[name] = n + 1;
}
}
public IReadOnlyDictionary<string, long> Snapshot()
{
lock (_gate)
return new Dictionary<string, long>(_counts); // copy out under the lock
}
}Both reads and writes use _gate. Snapshot copies the dictionary while protected, then returns data that callers can enumerate after the live lock has been released.
Pitfalls
- A monitor cannot span
await. Monitor ownership has thread affinity, while a continuation may resume on another thread. The compiler reports CS1996. Async mutual exclusion uses a semaphore andWaitAsync. - I/O inside a lock turns latency into contention. Every waiter queues behind the slow operation, increasing the chance of convoys and deadlocks. Computation and fetching belong outside the lock, which then protects only the shared-state update.
- A public gate leaks synchronization.
this, aType, and interned strings can all be locked by unrelated code. Monitor.TryEnter(obj, timeout)bounds acquisition. It is useful when an external lock order cannot be fully controlled. Deadlocks covers the ordering mechanics.
Tradeoffs
lock/Monitoris synchronous, in-process, reentrant mutual exclusion. Its fast path stays in user mode.SemaphoreSlimis in-process, supportsWaitAsync, and can expose more than one permit. It has no thread ownership or reentrancy.Mutexsupplies thread-owned mutual exclusion through an operating-system object and can coordinate named participants across processes.
For short synchronous state changes inside one process, lock is the smallest correct primitive. Ordinary async flows need restructuring so awaited I/O completes before the protected mutation. When the invariant must remain gated across asynchronous work, SemaphoreSlim.WaitAsync provides that gate, and the span needs to stay bounded because remote latency becomes contention. A real process boundary justifies Mutex. N concurrent entrants make the problem a Semaphore problem regardless of whether callers wait synchronously or asynchronously.
Questions
What is a race condition, and how does
lockprevent it?A race condition occurs when the result depends on the timing of threads that access shared state concurrently.
lockprevents it by letting only one thread at a time run code protected by the same lock object, so one protected update cannot interleave with another.For example, two threads run
_count++on a shared field. The increment is three steps: read the value, add one and write the result back.
- Thread A reads 5.
- Thread B reads 5 before A writes.
- A writes 6.
- B writes 6.
Two increments ran, but one was lost. With
lock (_gate) { _count++; }in both threads, B cannot read until A has written and released the lock, so the result is 7.The protection covers only code that takes the same lock. Every read and write of the shared state must use that lock object; an unprotected access elsewhere can still interleave with a protected update.
Microsoft: Managed threading best practices · Microsoft:
lockstatement
When
locktargets an ordinary object, what does the compiler ensure around the critical section?The compiler acquires the monitor with
Monitor.Enter(obj, ref lockTaken), runs the body insidetry, and exits infinallyonly if acquisition succeeded. This releases ownership on normal and exceptional exits without exiting a monitor the thread never acquired. The specializedSystem.Threading.Lockpath in C# 13 usesEnterScopeinstead. Microsoft:lockstatement · Monitor.Enter
Why can’t a
lockblock containawait?
Monitorownership belongs to the thread that entered it, and only that thread can exit it. An awaited continuation may resume on a different thread, which would then try to release a monitor it does not own. The C# compiler therefore rejectsawaitinside alockblock.Holding a lock across
awaitwould also make other threads wait for the whole asynchronous operation, turning I/O latency into lock contention.Two alternatives remain:
- Restructure: the awaited work runs outside the lock, and only the resulting state change runs inside a short
lock. This fits most cases.- Async-aware gate: when an invariant must stay protected across an
await, aSemaphoreSlimwith one permit andawait WaitAsync()provides the gate. It has no thread ownership, so a continuation on another thread can release it.Release()belongs infinally.Microsoft:
lockstatement · Monitor.Enter · SemaphoreSlim API
What does
System.Threading.Lockchange aboutlockin C# 13 and .NET 9+?
System.Threading.Lockgives the compiler a dedicated synchronization type instead of an arbitrary object. In C# 13,lockon that type lowers to anEnterScope()guard that releases when the scope exits, rather than the ordinaryMonitor.Enter/Exitpattern. It remains synchronous and cannot spanawait; the type does not promise a universal speedup. Microsoft:lockstatement
How does reentrancy differ between
lock/MonitorandSemaphoreSlim?
lock/Monitoris reentrant for its owning thread.SemaphoreSlimis not reentrant, because it has no owner.
lock/Monitor: records which thread owns the monitor and how many times it has entered. The owner can enter again without blocking, and other threads can acquire the monitor only after the owner has exited the same number of times.SemaphoreSlim: records only how many permits remain, not which thread or asynchronous flow holds them. A second wait from the same code is just another request for a permit.With a one-permit
SemaphoreSlim, a method that holds the permit and then calls code that waits on the same semaphore ends up waiting for itself. Without a timeout or cancellation,Wait()blocks forever andWaitAsync()never completes. The permit therefore belongs at one boundary in the call chain, withRelease()infinallyso that each acquisition is released exactly once.Microsoft:
lockstatement · Monitor.Enter · SemaphoreSlim API