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 and WaitAsync.
  • 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, a Type, 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/Monitor is synchronous, in-process, reentrant mutual exclusion. Its fast path stays in user mode.
  • SemaphoreSlim is in-process, supports WaitAsync, and can expose more than one permit. It has no thread ownership or reentrancy.
  • Mutex supplies 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

References