C# Lock Object: Choosing What to Lock and Why
c# lock object: Learn how to choose the right lock object in C#, why the choice affects thread safety, and which locking patterns avoid contention and deadlock.
When you search for c# lock object, the question is rarely just about syntax. The lock statement is simple to write, but the object you pass to it determines whether the synchronization actually works. The statement compiles to Monitor.Enter and Monitor.Exit wrapped in a try/finally block, and the object argument is the identity token for the critical section.
private readonly object _gate = new object(); public void Update() { lock (_gate) { // Only one thread at a time can be here. } }
Two threads that lock the same object instance are serialized. Two threads that lock different instances do not block each other, even if they are running the same method. That single fact drives every decision about which object to use.
What the Lock Object Actually Does
The object passed to lock is not the resource being protected. It is the monitor identity. When a thread enters the block, the runtime tries to acquire the monitor associated with that object instance. If another thread holds it, the second thread blocks until the first exits.
The object's fields, methods, or state are irrelevant. Only its identity matters. This is why locking on a value type is a compile error — boxing would create a new object each time, so no two threads would ever share the same monitor. It is also why locking on a string or a Type object is usually a design mistake: those objects can be shared across unrelated parts of the application.
Choosing a Private Instance Object
The most reliable pattern is a dedicated private field:
public class Counter { private readonly object _gate = new object(); private int _count; public void Increment() { lock (_gate) { _count++; } } public int GetCount() { lock (_gate) { return _count; } } }
The field is private, so no external code can lock the same instance and create unexpected contention. It is readonly, so the reference never changes after construction. It is a plain object, so there is no risk of accidentally exposing it through a public API.
Use this pattern when the lock protects instance state. If the state is shared across all instances of a type — a static counter, a static cache, a static configuration dictionary — the lock object must be static as well:
public static class Registry { private static readonly object _gate = new object(); private static readonly Dictionary<string, string> _values = new(); public static void Set(string key, string value) { lock (_gate) { _values[key] = value; } } }
An instance lock object cannot protect static state, because two different threads may hold two different instance locks while both access the same static field.
What Not to Lock On
Locking on this is tempting because it requires no extra field, but it is risky. Any caller that holds a reference to the instance can lock the same object. That means unrelated code can block your critical section, or worse, create a deadlock that is very hard to reproduce.
Locking on a string is dangerous because strings are interned. Two unrelated code paths that use the same string literal end up locking the same underlying object, even if they are in different classes. Locking on typeof(SomeClass) has the same problem: the Type object is process-wide and can be locked by any code that references the type.
None of these are compile-time errors. They fail at runtime, usually under load, and the failure mode is mysterious contention or a deadlock that only appears in production.
Locking and Reentrancy
The lock statement is reentrant. If the same thread already holds the monitor, it can enter the block again without blocking. This is convenient when one locked method calls another locked method on the same object:
public void UpdateAll() { lock (_gate) { UpdateOne(); } } private void UpdateOne() { lock (_gate) { // Reentrant: same thread already holds _gate. } }
Reentrancy prevents a self-deadlock, but it can also hide design problems. If a method is doing significant work while holding a lock, and it calls other methods that also take the lock, the critical section is larger than it appears. The lock is not a substitute for careful reasoning about what needs to be protected.
Performance and Contention
An uncontended lock has a relatively low cost, but it is not free. The real cost appears under contention: threads block, the scheduler wakes them, and context switches add latency. The longer a lock is held, the more likely other threads are to queue behind it.
The most effective way to reduce contention is to shrink the critical section. Do not perform I/O, network calls, or long computations inside a lock block. Copy the data you need under the lock, release the lock, and then do the expensive work:
public string ReadValue() { string value; lock (_gate) { value = _cache[key]; } return value; }
If the operation genuinely requires holding the lock for a long time, lock may not be the right tool. A SemaphoreSlim with async support, or a ReaderWriterLockSlim for read-heavy workloads, may fit the access pattern better.
Alternatives When Lock Is Not the Right Tool
The lock statement is the right choice when you need mutual exclusion over a short critical section that protects shared state. Other synchronization primitives exist for different access patterns:
| Primitive | Best fit | Notes |
|---|---|---|
lock / Monitor | Short critical sections | Reentrant, no async support |
ReaderWriterLockSlim | Read-heavy shared state | Multiple readers, one writer |
SemaphoreSlim | Limiting concurrent access | Supports async WaitAsync |
Interlocked | Simple atomic operations | No blocking, very low cost |
| Concurrent collections | Shared collections | No explicit locking required |
Interlocked is worth considering when the operation is a simple increment, exchange, or compare-and-swap. It avoids blocking entirely and is cheaper than a lock. For a counter like the one in the earlier example, Interlocked.Increment would be a better fit than a lock block.
Lock Ordering and Deadlock Avoidance
Deadlocks happen when two threads acquire two locks in opposite order. Thread A locks _first then _second; thread B locks _second then _first. If they interleave, each waits for the other to release.
The standard mitigation is consistent lock ordering. When a code path needs multiple locks, acquire them in a fixed global order and release them in reverse. The lock statement releases automatically on exit, so the release order is guaranteed by the structure of the code:
public void Transfer(Account from, Account to, decimal amount) { lock (from.Gate) { lock (to.Gate) { from.Balance -= amount; to.Balance += amount; } } }
If Transfer is always called with the same ordering rule — for example, ordering by account ID — the deadlock cannot occur. If the ordering is inconsistent, the code is deadlock-prone regardless of how carefully the locks are written.
Compatibility and Runtime Behavior
The lock statement compiles to Monitor.Enter and Monitor.Exit with a try/finally block, so the lock is always released even when an exception escapes the critical section. In older C# versions, the compiled form used an overload of Monitor.Enter that could leave the lock held if the thread was interrupted between the call and the try block; modern compilers use the safer overload that takes a ref bool and handles that edge case.
The behavior of Monitor is consistent across .NET Framework and .NET Core/.NET 5+, but the exact cost of acquisition and the scheduler behavior under contention can vary by platform and operating system. If you are writing code that must run on multiple runtimes, rely on the documented semantics of Monitor rather than on timing assumptions.
The choice of lock object is a design decision, not a performance trick. A private readonly object field is the default that works in almost every case. Deviating from it — locking on this, on a string, or on a Type — requires a concrete reason, because those objects are shared in ways that are easy to overlook.