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/WriteorInterlocked.*or full barriers. lock(compiles toMonitor.Enter/Exit) is the default — clean and correct for protecting state. Don't try to be clever.Interlockedfor single-variable atomic operations (counters, flags).Increment,Decrement,CompareExchange,Read(forlongon 32-bit),Add,And,Or.Volatile.Read/Writefor ordering hints without atomicity. Rare in app code; common in lock-free libraries.SemaphoreSlimfor async-aware counted resource limits. Has bothWait()(sync) andWaitAsync()(async).ReaderWriterLockSlimallows multiple concurrent readers, exclusive writers. Use when reads vastly outnumber writes — but profile first;lockis often faster in practice.ConcurrentDictionary<K,V>is the go-to thread-safe dictionary.GetOrAddandAddOrUpdateare atomic at the entry level.PriorityQueue<T, TPriority>(.NET 6+) — min-heap. Not thread-safe; wrap withlockif 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
Locktype (inSystem.Threading) gives you a strongly-typed lock object:private readonly Lock _lock = new(); public void Inc() { lock (_lock) _counter++; } // works; uses Lock.Scope optimized pathSlightly faster than locking on a generic
objectand 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
volatilekeyword in modern C#. It's confusing (different fence semantics fromVolatile.Read/Write) and the docs themselves recommendVolatile.*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 mutex — lock doesn't work across await (the lock is owned by the thread, but await may resume on another thread).
⚠️
SemaphoreSlimis not re-entrant. A thread canWaitAsynconce, can't re-acquire. Use the BCL or a third-partyAsyncReaderWriterLockif 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.
lockis 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
✅ Correct
❌ Wrong: ConcurrentDictionary.GetOrAdd with side-effecting factory
✅ Correct: Lazy<T> wrapper
❌ 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
lockis slower thanInterlocked; 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 orLock) for almost every critical section. - Use
SemaphoreSlimfor any critical section acrossawait. - Use
Interlockedfor atomic counters, flags. - Use
ConcurrentDictionaryinstead of lockedDictionary<K,V>for shared maps. - Use
ImmutableXxxfor "rarely written, often read" config. - Avoid
volatilekeyword — useVolatile.Read/Writeinstead. - Avoid
ReaderWriterLockSlimunless you've measured a real benefit. - Avoid
Thread.Sleep/Monitor.Waitin modern async code — useTask.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. - ⚠️
lockoverawait— ownership escapes the thread. - ⚠️
SemaphoreSlimre-entrancy — same thread re-acquiring blocks forever. - ⚠️
ConcurrentDictionary.GetOrAddfactory side effects — runs multiple times. - ⚠️
ReaderWriterLockSlim+await— resumes on different thread. - ⚠️
volatilekeyword vsVolatile.Read/Write— different fence semantics. UseVolatile.*. - ⚠️
++on shared counter — lost updates. UseInterlocked.Increment. - ⚠️
Thread.Sleepin async code — blocks the threadpool thread.