Locking in .NET Core
Reading Time: 4 minutes
Why Locking Matters
As soon as multiple threads or requests interact with shared state, correctness becomes harder than it first appears.
Typical examples:
- updating in-memory caches
- incrementing counters
- coordinating background workers
- protecting shared collections
- preventing duplicate execution of critical sections
Without synchronization, race conditions appear:
- corrupted state
- lost updates
- duplicate work
- intermittent production bugs that are hard to reproduce
Locking is one of the main tools used to protect shared resources in .NET.
The Simplest Case: lock
The most familiar mechanism is the C# lock statement.
private readonly object _sync = new();
private int _counter;
public void Increment()
{
lock (_sync)
{
_counter++;
}
}
This ensures only one thread at a time can execute that critical section.
Important rules
- Lock on a private object, not
this - Keep the locked section small
- Do not call external or slow code while holding the lock
What lock Actually Uses
Under the hood, lock is built on Monitor.
Equivalent form:
private readonly object _sync = new();
public void DoWork()
{
Monitor.Enter(_sync);
try
{
// critical section
}
finally
{
Monitor.Exit(_sync);
}
}
You usually prefer lock because it is safer and cleaner.
Common Locking Mistakes
Locking on public objects
lock (this)
{
}
This is dangerous because outside code could also lock on the same object and create unexpected contention or deadlocks.
Holding locks too long
Bad pattern:
lock (_sync)
{
CallExternalApi();
Thread.Sleep(500);
SaveToDatabase();
}
This increases contention and makes throughput worse.
Nested locks in inconsistent order
If thread A locks resource 1 then 2, and thread B locks resource 2 then 1, you can deadlock.
lock and Async Do Not Mix
One of the most important rules in modern .NET:
Do not use await inside a lock.
This is invalid:
lock (_sync)
{
await Task.Delay(100);
}
For asynchronous coordination, use SemaphoreSlim instead.
Async-Friendly Locking with SemaphoreSlim
SemaphoreSlim is commonly used to protect async code paths.
private readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task RefreshCacheAsync()
{
await _semaphore.WaitAsync();
try
{
await Task.Delay(100);
// async critical section
}
finally
{
_semaphore.Release();
}
}
With an initial count of 1, this behaves like an async-compatible mutex.
Good use cases
- async cache refresh
- preventing duplicate background job execution
- coordinating async resource access
ReaderWriterLockSlim
Sometimes you have many readers and relatively few writers.
In that case, ReaderWriterLockSlim can improve throughput by allowing concurrent reads while still protecting writes.
private readonly ReaderWriterLockSlim _lock = new();
private readonly Dictionary<string, string> _data = new();
public string? Get(string key)
{
_lock.EnterReadLock();
try
{
return _data.TryGetValue(key, out var value) ? value : null;
}
finally
{
_lock.ExitReadLock();
}
}
public void Set(string key, string value)
{
_lock.EnterWriteLock();
try
{
_data[key] = value;
}
finally
{
_lock.ExitWriteLock();
}
}
When it helps
- read-heavy in-memory structures
- shared lookup tables
- cache metadata
When it hurts
- low-contention workloads
- codebases where simplicity matters more than micro-optimization
If you do not have evidence of read-heavy contention, a normal lock is often better.
Interlocked for Small Atomic Operations
If your need is just a single atomic update, a full lock may be unnecessary.
private int _counter;
public void Increment()
{
Interlocked.Increment(ref _counter);
}
Other useful APIs:
Interlocked.DecrementInterlocked.ExchangeInterlocked.CompareExchange
This is often the best choice for counters and simple state transitions.
Concurrent Collections
Before writing your own locking logic around shared collections, check whether .NET already provides a thread-safe collection.
Examples:
ConcurrentDictionary<TKey, TValue>ConcurrentQueue<T>ConcurrentBag<T>
private readonly ConcurrentDictionary<string, string> _cache = new();
public void Set(string key, string value)
{
_cache[key] = value;
}
These can reduce manual synchronization code significantly.
Lock Contention and Performance
Locking is correct but not free.
High contention can cause:
- reduced throughput
- longer request latency
- thread blocking
- poor scalability under load
Good mitigation strategies:
- reduce shared mutable state
- keep critical sections small
- prefer immutable data where practical
- use atomic operations when sufficient
- use concurrent collections instead of custom locking where appropriate
Deadlock Prevention Tips
- Always acquire multiple locks in the same order
- Avoid calling external dependencies while holding locks
- Avoid mixing sync-over-async patterns
- Prefer timeouts or diagnostics in complex coordination code
Choosing the Right Primitive
Use lock when
- code is synchronous
- critical section is small
- simplicity matters most
Use SemaphoreSlim when
- code is asynchronous
- you need async-compatible mutual exclusion
Use ReaderWriterLockSlim when
- reads dominate writes
- profiling shows contention worth optimizing
Use Interlocked when
- the change is a small atomic operation
Use concurrent collections when
- the shared resource is a standard collection type
Summary
Locking in .NET Core is about protecting shared state without creating unnecessary complexity.
The practical default guidance is:
- start with
lockfor simple synchronous critical sections - use
SemaphoreSlimfor async coordination - use
Interlockedfor atomic counters/state changes - use
ReaderWriterLockSlimonly when the workload justifies it - prefer reducing shared mutable state whenever possible
Correctness comes first. Then optimize the locking strategy based on actual contention and real workload behavior.
References
- The C#
lockstatement: https://learn.microsoft.com/dotnet/csharp/language-reference/statements/lock SemaphoreSlimAPI: https://learn.microsoft.com/dotnet/api/system.threading.semaphoreslimReaderWriterLockSlimAPI: https://learn.microsoft.com/dotnet/api/system.threading.readerwriterlockslimInterlockedAPI: https://learn.microsoft.com/dotnet/api/system.threading.interlocked