Skip to content

Concurrency Primitives

Key Points

  • The .NET memory model since .NET 5 follows ECMA-335: regular reads/writes are not sequentially consistent; ordering requires explicit Volatile.Read/Write or Interlocked.* or full barriers.
  • lock (compiles to Monitor.Enter/Exit) is the default — clean and correct for protecting state. Don't try to be clever.
  • Interlocked for single-variable atomic operations (counters, flags). Increment, Decrement, CompareExchange, Read (for long on 32-bit), Add, And, Or.
  • Volatile.Read/Write for ordering hints without atomicity. Rare in app code; common in lock-free libraries.
  • SemaphoreSlim for async-aware counted resource limits. Has both Wait() (sync) and WaitAsync() (async).
  • ReaderWriterLockSlim allows multiple concurrent readers, exclusive writers. Use when reads vastly outnumber writes — but profile first; lock is often faster in practice.
  • ConcurrentDictionary<K,V> is the go-to thread-safe dictionary. GetOrAdd and AddOrUpdate are atomic at the entry level.
  • PriorityQueue<T, TPriority> (.NET 6+) — min-heap. Not thread-safe; wrap with lock if needed.

Concepts (deep dive)

The .NET memory model

The CLI spec (ECMA-335) since .NET 5 defines a memory model close to ECMA-CPP's "release/acquire". Key points:

  • Regular reads/writes can be reordered by the compiler/JIT/CPU.
  • Volatile.Read(ref x) is an acquire — no later read or write can be reordered before this read.
  • Volatile.Write(ref x, v) is a release — no earlier read or write can be reordered after this write.
  • Interlocked.* operations are full barriers (sequential consistency on the targeted variable).
  • Thread.MemoryBarrier() is a full fence; rarely needed in app code.

For 99% of code, you don't reach for these directly — lock and concurrent collections handle it. The exceptions: high-performance lock-free libraries.

lock — the default

private readonly object _lock = new();
private int _counter;

public void Inc()
{
    lock (_lock) _counter++;
}

lock (obj) { ... } lowers to Monitor.Enter(obj, ref taken); try { ... } finally { if (taken) Monitor.Exit(obj); }. It's recursive (same thread can re-enter), it's a fair-ish lock (FIFO-ish under load, no guarantees), and it's typically the fastest option for simple critical sections.

💡 Senior insight (C# 13+): the new dedicated Lock type (in System.Threading) gives you a strongly-typed lock object:

private readonly Lock _lock = new();
public void Inc() { lock (_lock) _counter++; }   // works; uses Lock.Scope optimized path

Slightly faster than locking on a generic object and has a richer API (EnterScope(), etc.). Migrate if you're on .NET 9+.

Don't lock on: - this — public; external code could lock you out. - typeof(X) — the Type object is shared globally. - A string — strings are interned; might be the same instance you don't expect. - A boxed value type — boxing creates new objects each time.

Do lock on a private readonly object dedicated to the lock.

Interlocked — atomic on a single variable

private long _hits;

public void RecordHit() => Interlocked.Increment(ref _hits);
public long Hits => Interlocked.Read(ref _hits);   // necessary on 32-bit for atomic long read

Common operations:

Method Effect
Increment(ref i) i++ atomically; returns new value
Decrement(ref i) i-- atomically; returns new value
Add(ref i, n) i += n atomically; returns new value
Exchange(ref a, b) var old = a; a = b; return old; atomically
CompareExchange(ref a, b, c) if a == c set a = b; returns previous value of a
Read(ref long i) atomic read; needed on 32-bit
And(ref i, m), Or(ref i, m) bitwise atomic

CompareExchange is the building block for lock-free code:

public T Swap<T>(ref T storage, Func<T, T> mutator) where T : class
{
    while (true)
    {
        var current = Volatile.Read(ref storage);
        var next = mutator(current);
        if (Interlocked.CompareExchange(ref storage, next, current) == current)
            return current;
    }
}

This pattern: "read; compute; CAS — retry on conflict". The basis of many concurrent data structures.

Volatile.Read/Write

private bool _stop;

void Stop() => Volatile.Write(ref _stop, true);

void Loop()
{
    while (!Volatile.Read(ref _stop)) { /* work */ }
}

Without Volatile, the JIT could cache _stop in a register and never see the write — the loop runs forever. Volatile.Read forces the JIT to re-read from memory; Volatile.Write forces immediate flush.

⚠️ Don't use the volatile keyword in modern C#. It's confusing (different fence semantics from Volatile.Read/Write) and the docs themselves recommend Volatile.* instead.

SemaphoreSlim — async-aware counted lock

private readonly SemaphoreSlim _gate = new(initialCount: 1, maxCount: 1);   // mutex

public async Task SafeDoAsync()
{
    await _gate.WaitAsync();
    try { await DoAsync(); }
    finally { _gate.Release(); }
}

SemaphoreSlim has both sync Wait() and async WaitAsync(). Use it as your async mutexlock doesn't work across await (the lock is owned by the thread, but await may resume on another thread).

⚠️ SemaphoreSlim is not re-entrant. A thread can WaitAsync once, can't re-acquire. Use the BCL or a third-party AsyncReaderWriterLock if you need re-entrancy.

For concurrent capacity:

private readonly SemaphoreSlim _maxConcurrent = new(initialCount: 10, maxCount: 10);

public async Task<T> RunAsync<T>(Func<Task<T>> op)
{
    await _maxConcurrent.WaitAsync();
    try { return await op(); }
    finally { _maxConcurrent.Release(); }
}

ReaderWriterLockSlim

private readonly ReaderWriterLockSlim _rw = new();
private Dictionary<string, string> _cache = new();

public string? Get(string key)
{
    _rw.EnterReadLock();
    try { return _cache.GetValueOrDefault(key); }
    finally { _rw.ExitReadLock(); }
}

public void Put(string key, string val)
{
    _rw.EnterWriteLock();
    try { _cache[key] = val; }
    finally { _rw.ExitWriteLock(); }
}

Multiple readers can hold the lock concurrently; writes are exclusive. Great when reads vastly outnumber writes. But:

⚠️ Profile before reaching for it. lock is often faster in practice because it's simpler. RWLock has higher per-acquisition overhead. The break-even is "thousands of concurrent readers with infrequent writes".

ReaderWriterLockSlim is not async-aware. Don't await while holding it. For async, use SemaphoreSlim or a third-party AsyncReaderWriterLock.

Concurrent collections

Collection Use case
ConcurrentDictionary<K,V> Thread-safe map. GetOrAdd and AddOrUpdate are atomic at the entry level.
ConcurrentQueue<T> FIFO queue; lock-free MPMC.
ConcurrentStack<T> LIFO; lock-free.
ConcurrentBag<T> Unordered; optimized for thread-locality.
BlockingCollection<T> Bounded thread-blocking queue (legacy; prefer Channel<T>).
ImmutableDictionary<K,V>, ImmutableList<T>, … Snapshots; concurrency by replacement (Interlocked.CompareExchange).

ConcurrentDictionary<K,V> gotcha: the valueFactory lambda passed to GetOrAdd(key, factory) may be invoked multiple times under contention. Only one result wins, but factories with side effects can fire repeatedly. Use the synchronous-add overloads or compute outside if side effects matter.

// ❌ side-effecting factory
var dict = new ConcurrentDictionary<int, ExpensiveResource>();
dict.GetOrAdd(1, _ =>
{
    Console.WriteLine("created!");   // could print multiple times under contention
    return new ExpensiveResource();
});

// ✅ Lazy<T> wrapper for "definitely once" semantics
var dict2 = new ConcurrentDictionary<int, Lazy<ExpensiveResource>>();
var resource = dict2.GetOrAdd(1, _ => new Lazy<ExpensiveResource>(() => new ExpensiveResource()));
resource.Value.Use();   // Lazy is thread-safe by default; create-once

PriorityQueue<TElement, TPriority> (.NET 6+)

var q = new PriorityQueue<Task, int>();   // Tasks ordered by priority (lower = sooner)
q.Enqueue(taskA, priority: 5);
q.Enqueue(taskB, priority: 1);   // takes precedence

while (q.TryDequeue(out var task, out var p))
    Run(task);

Min-heap; not thread-safe. O(log n) enqueue/dequeue. If you need thread safety, wrap with lock.

⚠️ Updating the priority of an already-enqueued element is not directly supported. Implementations typically dequeue+re-enqueue or use deferred deletion.

ThreadLocal<T> and AsyncLocal<T>

private static readonly ThreadLocal<Random> _rng =
    new(() => new Random(Thread.CurrentThread.ManagedThreadId));

// Each thread has its own Random instance; no contention.

private static readonly AsyncLocal<string?> _correlationId = new();

public async Task<T> RunAsync<T>(string id, Func<Task<T>> op)
{
    _correlationId.Value = id;
    return await op();   // _correlationId flows through awaits
}

ThreadLocal<T> is per-thread; AsyncLocal<T> flows through await (via ExecutionContext). Use AsyncLocal for request-scoped state in async code.

Lock-free patterns

SpinLock — tight CPU spin instead of OS wait. Useful for very short, very hot critical sections (microseconds). Real-world: framework internals; rarely in app code.

Lock-free counter (CAS-loop):

int Try(ref int value, int expected, int newValue)
    => Interlocked.CompareExchange(ref value, newValue, expected);

Padded fields to avoid false sharing:

[StructLayout(LayoutKind.Explicit, Size = 64)]
public struct PaddedLong
{
    [FieldOffset(0)] public long Value;
}

Two longs in the same 64-byte cache line cause "false sharing" — writes invalidate each other's caches. Padding to 64 bytes fixes it. Niche but real.


How it works under the hood

Monitor is implemented in the runtime; entering/exiting acquires/releases an OS-level critical section (or a fast path using a "thin lock" embedded in the object header before falling back to a heavier Sync Block). Modern impls have biased locking on the fast path.

Interlocked operations compile to single CPU instructions on x86/x64 (lock cmpxchg, etc.) — sequentially consistent.

SemaphoreSlim uses a counter + a list of Task<bool> continuations. WaitAsync either decrements the counter directly (no allocation) or queues a TaskCompletionSource to be released by future Release() calls. The "no allocation on fast path" is a key perf characteristic.

ConcurrentDictionary partitions its internal table into "lock buckets" — each bucket has its own lock, allowing concurrent operations on different keys to proceed in parallel.


Code: correct vs wrong

❌ Wrong: lock(this) or lock(typeof(X))

public class C
{
    public void Do() { lock (this) { /* ... */ } }   // ❌ external code can lock C instance
}

✅ Correct: private readonly lock object

public class C
{
    private readonly Lock _lock = new();   // C# 13+, or `object` for older versions
    public void Do() { lock (_lock) { /* ... */ } }
}

❌ Wrong: lock across await

private readonly object _lock = new();
public async Task DoAsync()
{
    lock (_lock)
    {
        await SomeAsync();   // ❌ compile error in modern C# — but if old, the lock is on the original thread; await may resume on another
    }
}

✅ Correct: SemaphoreSlim

private readonly SemaphoreSlim _gate = new(1, 1);
public async Task DoAsync()
{
    await _gate.WaitAsync();
    try { await SomeAsync(); }
    finally { _gate.Release(); }
}

❌ Wrong: forgetting Interlocked on shared counters

private int _count;
public void Hit() => _count++;   // ❌ lost updates under contention

✅ Correct

private int _count;
public void Hit() => Interlocked.Increment(ref _count);

❌ Wrong: ConcurrentDictionary.GetOrAdd with side-effecting factory

_dict.GetOrAdd(key, _ => HeavyConstruction());   // may run multiple times

✅ Correct: Lazy<T> wrapper

_dict.GetOrAdd(key, _ => new Lazy<T>(() => HeavyConstruction())).Value;

❌ Wrong: ReaderWriterLockSlim with await

_rw.EnterReadLock();
try { await ReadAsync(); }   // ❌ resume may be on different thread; lock not released
finally { _rw.ExitReadLock(); }

✅ Correct: don't await while holding RWLock

var data = _cache;   // snapshot inside lock
_rw.EnterReadLock(); try { data = _cache; } finally { _rw.ExitReadLock(); }
await ProcessAsync(data);

Design patterns for this topic

Pattern 1 — "lock first; reach for primitives only when measured"

  • Intent: keep concurrent code simple by default.
  • When to use it: any critical section.
  • Trade-offs: sometimes lock is slower than Interlocked; profile.

Pattern 2 — "SemaphoreSlim as async mutex"

  • Intent: safe critical section across await.
  • Code sketch: see "correct vs wrong" #2.

Pattern 3 — "Interlocked.CompareExchange for replace-with-snapshot"

  • Intent: lock-free updates of immutable data.
  • Code sketch:
public class ImmutableConfig
{
    private static ImmutableDictionary<string, string> _config = ImmutableDictionary<string, string>.Empty;

    public static void Set(string k, string v)
    {
        ImmutableDictionary<string, string> current, updated;
        do
        {
            current = _config;
            updated = current.SetItem(k, v);
        } while (Interlocked.CompareExchange(ref _config, updated, current) != current);
    }

    public static string? Get(string k) => _config.GetValueOrDefault(k);
}
  • Trade-offs: read is very cheap; write under contention retries. For high-write workloads, prefer ConcurrentDictionary.

Pattern 4 — "Channel-based fan-out instead of locks"

  • Intent: when many threads contribute to a queue, use a Channel<T> (one producer per source, one consumer) — no shared state.
  • Code sketch: see Channels & Async Streams.

Pattern 5 — "Bounded SemaphoreSlim for max concurrency"

  • Intent: rate-limit concurrent operations.
  • Code sketch:
private readonly SemaphoreSlim _gate = new(initialCount: 10, maxCount: 10);
async Task<T> ThrottledAsync<T>(Func<Task<T>> op)
{
    await _gate.WaitAsync();
    try { return await op(); }
    finally { _gate.Release(); }
}

Pros & cons / trade-offs

Primitive Pros Cons
lock (Monitor) Simple; correct; fast Not async-friendly
Interlocked Single-instruction atomicity Single variable only
Volatile.Read/Write Memory ordering without locks Confusing semantics
SemaphoreSlim Async-friendly Not re-entrant
ReaderWriterLockSlim Concurrent reads Higher per-acquire cost; not async
ConcurrentDictionary High concurrency; partitioned locks GetOrAdd factory can fire multiple times
ImmutableXxx Lock-free reads Allocation per write; copy cost

When to use / when to avoid

  • Use lock (with private object or Lock) for almost every critical section.
  • Use SemaphoreSlim for any critical section across await.
  • Use Interlocked for atomic counters, flags.
  • Use ConcurrentDictionary instead of locked Dictionary<K,V> for shared maps.
  • Use ImmutableXxx for "rarely written, often read" config.
  • Avoid volatile keyword — use Volatile.Read/Write instead.
  • Avoid ReaderWriterLockSlim unless you've measured a real benefit.
  • Avoid Thread.Sleep / Monitor.Wait in modern async code — use Task.Delay/SemaphoreSlim/channels.

Interview Q&A

Q1. What does lock(obj) do under the hood? Lowers to Monitor.Enter(obj, ref taken) + try/finally with Monitor.Exit. Uses a thin lock embedded in the object header for the fast path; falls back to a sync block for contended cases. Re-entrant on the same thread.

Q2. Why can't you lock across await? The lock is owned by the thread; await may resume on a different thread. The compiler now warns or errors on this; older code would deadlock or release on the wrong thread.

Q3. Why use SemaphoreSlim over Mutex in modern code? SemaphoreSlim is in-process, async-aware, and faster. Mutex is OS-level (cross-process), heavier, and not async-friendly.

Q4. What's the difference between Volatile.Read and Interlocked.Read? Volatile.Read is a half-fence (acquire) — any int/ref read; not atomic for long on 32-bit. Interlocked.Read(ref long) is a full barrier and atomic — required for safe long reads on 32-bit.

Q5. Why does ConcurrentDictionary.GetOrAdd's factory sometimes run multiple times? The dictionary doesn't lock around the factory call. Multiple threads under contention may invoke the factory; only one's result wins. Use Lazy<T> to make construction definitely-once.

Q6. What's Interlocked.CompareExchange for? The atomic primitive: "if the variable equals X, set it to Y; return the previous value". The building block for lock-free algorithms (CAS-loop pattern).

Q7. How does ImmutableDictionary achieve thread safety without locks? Each "modification" returns a new instance; the underlying tree-based structure shares unchanged subtrees. The "current" reference is updated atomically. Readers see whichever version was current at the time of read.

Q8. When would you use ReaderWriterLockSlim? When reads vastly outnumber writes AND profiling shows lock contention is real. The cost per acquire is higher than lock; it only pays off at high read concurrency.

Q9. What's "false sharing" and how do you fix it? Two unrelated fields placed in the same CPU cache line — writes to one invalidate the other's cached copy in remote cores. Fix: pad to 64 bytes (typical cache line) with [StructLayout] or by adding dummy fields.

Q10. What's the difference between ThreadLocal<T> and AsyncLocal<T>? ThreadLocal<T> is per-OS-thread. AsyncLocal<T> flows through await via ExecutionContext — so a single logical async operation sees consistent values regardless of which thread runs continuations.

Q11. How is PriorityQueue<TElement, TPriority> thread-safe? It's not. Wrap with lock for thread safety, or use a ConcurrentQueue<T> if you don't need priority.

Q12. Why is ConcurrentBag<T> faster than ConcurrentQueue<T> for some workloads? ConcurrentBag uses thread-local buffers — each thread adds to its own buffer; takes work-steal from others. Best when each thread tends to consume what it produces.

Q13. What's a SpinLock and when would you use it? A user-mode spin: instead of yielding the thread to the OS, it spins on a CAS. Useful for very short critical sections (microseconds). Costs CPU when contended for long. Use only after measurement shows lock is the bottleneck.


Gotchas / common mistakes

  • ⚠️ lock(this) — public objects can deadlock you.
  • ⚠️ lock over await — ownership escapes the thread.
  • ⚠️ SemaphoreSlim re-entrancy — same thread re-acquiring blocks forever.
  • ⚠️ ConcurrentDictionary.GetOrAdd factory side effects — runs multiple times.
  • ⚠️ ReaderWriterLockSlim + await — resumes on different thread.
  • ⚠️ volatile keyword vs Volatile.Read/Write — different fence semantics. Use Volatile.*.
  • ⚠️ ++ on shared counter — lost updates. Use Interlocked.Increment.
  • ⚠️ Thread.Sleep in async code — blocks the threadpool thread.

Further reading