Back to Blog
C#

Using the C# lock Keyword for Thread Safety

c# lock keyword: Understand the C# lock keyword: its syntax, how it maps to Monitor, common pitfalls, performance impact, and when to use alternatives.

C# concurrencythread synchronizationlock statementMonitor classrace conditions
Illustration of a padlock protecting a shared data block while multiple threads wait in line, representing the C# lock keyword.

The c# lock keyword is the most direct way to prevent multiple threads from executing the same block of code simultaneously. It creates a critical section around a resource, ensuring that only one thread can enter at a time. This is essential when shared state—such as a collection, a counter, or a cached value—must be modified atomically to avoid race conditions.

What the lock Keyword Does

When you wrap code in a lock statement, the runtime uses a monitor to enforce mutual exclusion. The first thread to reach the lock acquires the monitor for the specified object. Any other thread that attempts to enter the same lock blocks until the owning thread exits the block, either by completing normally or throwing an exception. This guarantees that the guarded section executes without interleaving from other threads.

The object passed to lock is not the resource being protected; it is a synchronization handle. The convention is to use a dedicated private object, never this, a Type object, or a string literal, because those can be accessed by unrelated code and lead to deadlocks or unintended blocking.

Basic Syntax and Usage

private readonly object _syncRoot = new object(); private int _counter; public void Increment() { lock (_syncRoot) { _counter++; } }

The lock statement is syntactic sugar for Monitor.Enter and Monitor.Exit wrapped in a try/finally block. The compiler expands it to ensure the lock is released even if an exception occurs inside the block. This is why you should never call Monitor.Enter manually without a matching finally—the lock keyword handles that correctly.

For simple atomic operations like incrementing an integer, Interlocked.Increment is more efficient, but lock is the right choice when the critical section contains multiple statements or complex logic that must be performed as a unit.

How lock Works with Monitor

The lock keyword relies on the System.Threading.Monitor class. When you write lock (x), the compiler generates code equivalent to:

Monitor.Enter(x); try { // guarded code } finally { Monitor.Exit(x); }

Monitor.Enter acquires an exclusive lock on the object. If another thread already holds the lock, the current thread blocks until the lock becomes available. The monitor is reentrant: the same thread can acquire the same lock multiple times without deadlocking, as long as each acquisition is matched by a corresponding release. This is useful when a method that takes a lock calls another method that also takes the same lock.

One important detail is that Monitor.Enter and Monitor.Exit must be called on the same object. If you use different objects for entering and exiting, the lock will not be released correctly, leading to deadlocks or SynchronizationLockException.

Common Pitfalls and How to Avoid Them

A frequent mistake is locking on a public field or a property. If external code can access that object, it can acquire the same lock and cause contention or deadlock. Always use a private, readonly object that is never exposed outside the class.

Another pitfall is locking on a value type. The lock statement requires a reference type; if you pass a struct, it gets boxed, and each call creates a new boxed instance, so the lock is ineffective because threads are not synchronizing on the same object. The compiler will warn about this, but it is still a common error in older code.

Locking on a string literal is also problematic because strings are interned. Two unrelated code sections that lock on the same string literal will actually share the same lock, potentially causing unexpected blocking across different parts of the application.

Finally, be careful with lock ordering. If two threads acquire locks in different orders, they can deadlock. For example, thread A locks object1 then object2, while thread B locks object2 then object1. When both threads hold one lock and wait for the other, neither can proceed. The solution is to establish a consistent ordering for lock acquisition across all threads.

Locking and Performance Considerations

Locking has a cost. Acquiring and releasing a monitor involves a transition into the kernel if there is contention, which is relatively expensive compared to lock-free operations. For uncontended locks, the overhead is small, but it is still non-zero. The key performance concern is contention: when multiple threads frequently attempt to enter the same lock, they spend time blocked, and the lock becomes a bottleneck.

To minimize contention, keep the critical section as short as possible. Do not perform I/O, database calls, or long-running computations inside a lock. If you must do such work, consider using a more granular synchronization primitive or a lock-free approach.

Another performance aspect is that lock is not asynchronous. If you need to await inside a critical section, you cannot use lock because it does not support async/await. The Monitor is thread-affine; it cannot be released on a different thread than the one that acquired it. In async code, use SemaphoreSlim with WaitAsync instead.

Alternatives to the lock Keyword

The lock keyword is not the only synchronization tool in C#. Depending on the scenario, other primitives may be more appropriate:

  • Interlocked methods: For atomic operations on integers and floats, such as increment, decrement, or compare-and-swap, these are lock-free and faster.
  • ReaderWriterLockSlim: Allows multiple readers or a single writer, improving concurrency when reads are frequent and writes are rare.
  • SemaphoreSlim: Supports asynchronous waiting and can limit the number of threads entering a section, not just one.
  • Mutex: A cross-process synchronization primitive, useful when multiple processes need to coordinate.
PrimitiveScopeAsync SupportMultiple ThreadsTypical Use Case
lockProcessNoSingleProtecting a short critical section
InterlockedProcessNoSingleAtomic numeric operations
ReaderWriterLockSlimProcessNoMultiple readers or one writerRead-heavy shared data
SemaphoreSlimProcessYesConfigurableAsync code or throttling
MutexCross-processNoSingleCoordinating between processes

Choosing the right primitive depends on the access pattern, whether you need async support, and whether the synchronization must span processes. The lock keyword remains the default for simple, synchronous mutual exclusion because it is concise, readable, and less error-prone than manually managing Monitor calls.

When lock Is Not the Right Choice

If your critical section is very short and you only need to increment a counter or swap a reference, Interlocked is more efficient. If you are writing async code, lock will not work because the compiler forbids await inside a lock block. In that case, use SemaphoreSlim with WaitAsync.

Also, if you need to allow multiple readers but only one writer, ReaderWriterLockSlim can improve throughput. For example, a cache that is read frequently and updated occasionally benefits from allowing concurrent reads while still protecting writes.

Finally, if you are building a library that might be used across process boundaries, consider whether a Mutex is necessary. The lock keyword only works within a single process.

Locking and Exception Safety

The lock statement guarantees that the lock is released even if an exception is thrown inside the block. This is a critical advantage over manual Monitor.Enter/Exit where a forgotten finally can leave the lock held indefinitely, causing deadlocks. Because the compiler generates a finally block, you do not need to write a try/finally yourself.

However, this does not mean that the state of the protected resource is automatically consistent after an exception. If an exception occurs midway through modifying a collection, the collection may be left in a partially updated state. The lock only ensures mutual exclusion, not atomicity of the entire operation. You may need to handle exceptions inside the critical section to roll back changes or mark the resource as invalid.

A practical pattern is to perform validation before entering the lock, and to keep the critical section free of operations that are likely to throw, such as I/O. If an exception is expected, consider using a try/catch inside the lock to restore invariants before the lock is released.

c# lock keyword: Practical Usage and Code Examples | RYUSLOG DEV