← Back to all posts

Locking in .NET Core

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.Decrement
  • Interlocked.Exchange
  • Interlocked.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 lock for simple synchronous critical sections
  • use SemaphoreSlim for async coordination
  • use Interlocked for atomic counters/state changes
  • use ReaderWriterLockSlim only 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