AlgoMaster Logo

Lock Object

Medium Priority18 min readUpdated June 6, 2026

C# 13 adds a dedicated System.Threading.Lock type for guarding shared state across threads. For two decades, lock (someObject) worked by hijacking the header of any reference type to act as a mutual exclusion primitive, which made every object a potential lock and led to subtle bugs. The new Lock type makes locks a real, named thing in the type system, with a faster code path and clearer intent. This lesson covers what Lock is, why it exists, how the compiler special-cases it inside a lock statement, when to use it over Monitor, SemaphoreSlim, and ReaderWriterLockSlim, and how to migrate existing code without breaking behavior.

The Problem Lock Solves

Before C# 13, the textbook way to guard shared state looked like this:

This works, but look at the lock field: private readonly object _lock = new(). The compiler thinks _lock is an ordinary object. Nothing about its type says "I am a lock." Three problems fall out of that.

First, anyone with a reference to _lock can take it. If the field were public (or accidentally exposed), unrelated code could lock on it from the outside and deadlock the class. The same risk exists if a developer locks on a string literal, a boxed integer, or the type itself (lock (typeof(Inventory))), each of which is a real anti-pattern that the type system used to allow without a peep.

Second, the locking mechanism itself, the CLR's Monitor, works by attaching a hidden synchronization block to the object's header. Every reference type in .NET pays for the possibility that someone might lock on it, in the form of a couple of header bits and a lazy sync block allocation. The cost per object is tiny, but multiplied across millions of objects, it's measurable.

Third, the intent is invisible at the call site. A reader looking at lock (_lock) has to scan back to the field declaration to confirm whether _lock is a dedicated lock or just some random object the author repurposed. With Lock, the type tells you.

The old lock (object) pattern goes through Monitor.Enter / Monitor.Exit, which always allocates or looks up the object's sync block. The new Lock type sidesteps that machinery and uses its own internal state, which is faster on uncontended acquisition and avoids the sync block entirely.

Introducing System.Threading.Lock

Lock is a regular .NET class in the System.Threading namespace, available starting in .NET 9 (which is the runtime that ships with C# 13). You instantiate it like any other type and store a reference in a private field:

The C# 13 compiler recognizes Lock specifically. When it sees lock (someLockField) and the field is typed as System.Threading.Lock, it does not emit the old Monitor.Enter / Monitor.Exit pair. Instead, it lowers the statement to a call on the Lock type's own API, EnterScope(), which returns a small ref struct whose Dispose method releases the lock. The pattern is essentially:

You almost never write EnterScope() directly. The whole point is that the lock statement still works, the syntax stays familiar, and the compiler quietly picks the better implementation when the operand is a Lock. Here's the updated inventory class:

The body of the class looks almost identical to the pre-C# 13 version. The only change is the field type. That's intentional: migration should be a one-line edit, not a rewrite.

How the Compiler Lowers lock (Lock)

The lock statement is a thin sugar over a try / finally pair. Before C# 13, this:

was lowered roughly to:

Monitor.Enter and Monitor.Exit are static methods on System.Threading.Monitor, and they hit the sync block of temp. With C# 13, when _lock is typed as System.Threading.Lock, the same statement lowers to:

EnterScope() returns a Lock.Scope, a ref struct whose Dispose method releases the lock. Because Lock.Scope is a ref struct, the compiler enforces that it can't escape the method, can't be boxed, and can't outlive the stack frame it was created on. That keeps the lock-release semantics tied tightly to the lifetime of the syntactic block.

The diagram below contrasts the two paths the lock statement takes depending on the operand's type:

The two paths produce the same observable behavior, mutual exclusion with the same memory ordering guarantees, but they go through different code at runtime. The Lock path is a few CPU instructions shorter on uncontended acquisition because it doesn't have to find or allocate the sync block.

Identity Safety: What Lock Prevents

The old pattern allowed three classic foot-guns that Lock removes by construction.

Locking on `this`. A class that does lock (this) is locking on its own instance, which is visible to anyone holding a reference to that instance. External code can take the same lock and stall the class's methods.

With Lock, the field is private and typed as Lock. Outside code can't lock on a Cart because Cart isn't a Lock. The compiler enforces that.

Locking on a type. lock (typeof(SomeClass)) locks on the Type object, which is a process-wide singleton. Two unrelated libraries that both lock (typeof(string)) (yes, this happens) deadlock each other for reasons neither author can see.

Locking on a string. String interning means that two different "cart-lock" literals in different parts of the program may resolve to the same string instance. Locks taken on those literals interact unpredictably. The compiler in C# 13 actually warns on lock with a string operand, but the warning predates Lock itself.

Lock makes the lock object explicitly a Lock. There is no Lock instance attached to your Cart unless you put one there, and the field is private readonly. Nothing else in the program can reach it.

Reentrance and Semantics

Lock is reentrant, meaning the same thread can enter it multiple times without deadlocking itself. The lock keeps a counter; each EnterScope() from the holding thread increments the counter, each Dispose decrements it, and the lock is fully released only when the count returns to zero.

Reentrance matches the old Monitor-based behavior, so migration doesn't change the semantics. The same thread can recursively enter a method that takes the lock, which is what you want when one locked method internally calls another locked method on the same object.

A few other semantics worth being explicit about:

  • `Lock` is a class, not a struct. It lives on the heap; the field stores a reference. Don't try to copy it. If two fields hold references to the same Lock instance, they are the same lock. If they hold references to two different Lock instances, they are independent locks.
  • `Lock.Scope` is a `ref struct`. The disposable token returned by EnterScope() can't be stored in a field, captured by a lambda, or moved across await boundaries. The compiler stops you. That restriction is what guarantees the lock is released by the end of the enclosing block.
  • `Lock` is synchronous. It does not interact with async / await. await inside a lock block has always been illegal in C# and remains illegal with the new Lock. To coordinate around an await, use SemaphoreSlim instead.
  • Fairness is not guaranteed. Lock does not promise FIFO acquisition order across threads. For fair scheduling, Lock is not the right primitive.

A Lock field adds roughly the same memory overhead as the old object pattern (one reference plus the lock's own state object). The savings come from per-acquisition CPU cost and from avoiding the sync block on whatever object was used as the lock target.

Migrating Existing Code

For the most common case, migration is a one-line change per lock field:

The lock (_lock) { ... } statements scattered through the class don't change. The compiler sees the new field type and switches to the EnterScope lowering automatically.

A few situations need more thought.

The lock field is passed to other code. If you've been passing the lock object to helper methods or storing it on shared state, the helper signatures probably take object. Change them to take Lock:

If you leave the parameter as object, the call site still compiles, because Lock is a reference type and converts to object, but inside the helper, lockObj is statically object, so the compiler emits the old Monitor-based code path. You lose the performance benefit. The compiler will warn you about this (CS9216), nudging you to use Lock everywhere a lock travels.

Code that calls `Monitor.Enter` / `Monitor.Exit` directly. Direct Monitor calls don't know about Lock. Don't pass a Lock instance to Monitor.Enter; the behavior is not defined. If you have explicit Monitor usage (rare, but it exists for things like timed acquisition with Monitor.TryEnter), keep it on an object lock for now, or switch to Lock's own TryEnter method.

Locking on `this`, `typeof(...)`, strings, or arbitrary shared objects. This is the time to fix those. Introduce a dedicated Lock field and migrate the lock target. The new type makes the change feel less arbitrary, because you're swapping a misused object for a purpose-built Lock.

The diagram below summarizes the migration decision tree:

For greenfield .NET 9 code, the rule is simpler: instead of private readonly object _lock = new(), write private readonly Lock _lock = new().

Beyond the lock Statement: EnterScope and TryEnter

Most of the time, the lock statement is the right surface. It's familiar, it's compact, and the compiler handles the try / finally for you. Two situations call for the underlying API directly.

`TryEnter` for non-blocking acquisition. If you want to attempt the lock and fall back to alternative behavior when it's already held, use TryEnter. It's the Lock equivalent of Monitor.TryEnter:

TryEnter returns bool. There's also an overload that accepts a timeout (TryEnter(TimeSpan)), useful when you want to wait briefly but not forever. Pair every successful TryEnter with a matching Exit in a finally block, exactly the same discipline Monitor.Exit requires.

Explicit `EnterScope`. If you find the using-with-scope pattern clearer than lock for some unusual reason, you can write it directly:

The behavior is identical to lock (_lock) { _pendingWrites++; }. There's no real reason to prefer one over the other when both work; the lock statement is more conventional and reads better in most code.

When Lock Does Not Fit

Lock is a synchronous, exclusive, reentrant mutex. That's the right shape for guarding a small critical section against concurrent threads. It's not the right shape for everything.

PrimitiveUse when
Lock (C# 13+)Synchronous, exclusive mutual exclusion within a single process. Default choice.
MonitorYou need explicit TryEnter with timeout on a plain object, or you're targeting .NET 8 or older.
SemaphoreSlimYou need to await while holding the protected resource, or you need to limit concurrency to N (not just 1).
ReaderWriterLockSlimMany readers, few writers, and the read sections are long enough that exclusive locking would be a bottleneck.
InterlockedThe protected operation is a single integer or reference update (Increment, Add, CompareExchange). No lock needed.
MutexCross-process synchronization, where the lock must coordinate threads in different processes (rare).

A few of these are worth elaborating.

`SemaphoreSlim` vs. `Lock`. Lock fits when the critical section is synchronous. The moment the section needs to await something (an HTTP call, a database query, a file write), Lock stops working, because a Lock can't be held across an await. SemaphoreSlim is the async-friendly alternative. It allows await SemaphoreSlim.WaitAsync() inside the locked region. The price is that SemaphoreSlim is more expensive on uncontended acquisition and is not reentrant.

`ReaderWriterLockSlim` vs. `Lock`. If a piece of shared state is read 99% of the time and written 1% of the time, and reads are not trivial, Lock serializes the readers unnecessarily. ReaderWriterLockSlim lets multiple readers proceed in parallel while writers get exclusive access. The trade-off is more complexity and more overhead per acquisition; if the critical section is just a couple of field accesses, Lock is faster than ReaderWriterLockSlim even with many readers.

`Interlocked` vs. `Lock`. For an _orderIdCounter++ that's the only thing happening under the lock, Interlocked.Increment(ref _orderIdCounter) does the job without any lock at all. It compiles to a single atomic CPU instruction. As soon as the protected section has more than one operation (read, compute, write), or coordinates multiple fields, Lock fits again.

The rule of thumb: use Interlocked first when the operation fits, Lock for general critical sections, SemaphoreSlim for async, ReaderWriterLockSlim only when measurements justify it.

A Larger Example: Guarding a Cart

Putting the pieces together, here's a small ShoppingCart class that uses Lock to keep its line items and its total in sync under concurrent updates:

Two details to note. First, both Add and Snapshot take the same lock. A snapshot reader must not observe a half-updated _lines and _total, so it needs the same mutual exclusion the writer takes. Second, Snapshot returns a tuple of the snapshotted values rather than handing out a reference to the internal _lines list. Returning _lines directly would allow the caller to iterate it on another thread while a writer was mutating it, and Lock wouldn't help because the iteration is outside the locked region. Locking guards the section, not the data; once the data leaves the locked region, the caller is responsible for safe handling.

Add does an allocation for the CartLine and possibly a List<T> resize, both inside the lock. Keep the section as short as possible: build expensive objects outside the lock when possible, and only do the strictly-shared updates inside.

Quiz

Lock Object Quiz

10 quizzes