Skill v1.0.0
currentAutomated scan100/100version: "1.0.0" name: csharp-concurrency-primitives description: Use when writing or reviewing C#/.NET code that coordinates access across threads or async work — picking between lock/System.Threading.Lock, SemaphoreSlim, Interlocked/Volatile, Channel<T>, ConcurrentDictionary, ReaderWriterLockSlim, Parallel.ForEachAsync, and lazy initialization. Wrong primitive choice causes deadlocks, torn state, or silent double-work.
Concurrency primitives — picking the right tool
Every primitive here trades off differently on "blocks a thread vs. frees it", "works with await", and "how many writers/readers". Pick based on those axes, not habit.
lock / System.Threading.Lock — simple mutual exclusion, no await inside
.NET 9+ has a dedicated System.Threading.Lock type. The lock statement recognizes it and uses its ref struct enumerator (EnterScope) instead of Monitor, which is faster and avoids some Monitor-specific footguns:
private readonly Lock _gate = new();private int _counter;public void Increment(){lock (_gate){_counter++;}}
- Prefer
Lockover a bareobjectfor new code targeting .NET 9+; it's a drop-inlock (_gate)target with better performance and clearer intent than "lock on some object". - Never `await` inside a `lock` block. The lock is thread-affine (
Monitor-based, includingLock) — the continuation after anawaitcan resume on a different thread, which either throws (SynchronizationLockException) or silently corrupts the lock's owner tracking depending on the primitive. If you need to hold exclusion across anawait, useSemaphoreSliminstead. - Keep the critical section short: no I/O, no calling into code you don't control, nothing that can throw and leave shared state half-mutated without a
try/finally.
SemaphoreSlim — mutual exclusion or throttling across await
private readonly SemaphoreSlim _gate = new(1, 1);public async Task UpdateAsync(CancellationToken ct){await _gate.WaitAsync(ct).ConfigureAwait(false);try{await DoWorkAsync().ConfigureAwait(false);}finally{_gate.Release();}}
new SemaphoreSlim(1, 1)used as a mutex is the standard way to serialize async work —WaitAsyncdoesn't block a thread while waiting, unlikelock.- Also use it (with initial count > 1) to throttle concurrency, e.g.
new SemaphoreSlim(maxConcurrent)around calls you fan out withTask.WhenAll. - Always
Release()in afinally. Always pass aCancellationTokentoWaitAsync— seecsharp-asyncfor why cancellation is part of the contract. - Don't reach for
SemaphoreSlimwhen a plainlock/Lockwould do (noawaitin the critical section) — it's heavier and the async overhead is wasted.
Interlocked / Volatile — lock-free single-value updates
private long _requestCount;public void RecordRequest() => Interlocked.Increment(ref _requestCount);private volatile bool _shuttingDown;public void Shutdown() => Volatile.Write(ref _shuttingDown, true);public bool IsShuttingDown => Volatile.Read(ref _shuttingDown);
Interlocked.Increment/Decrement/Add/Exchange/CompareExchangeare for single scalar fields where a full lock would be overkill — counters, flags, simple state machines viaCompareExchangeloops.Volatile.Read/Write(or thevolatilekeyword on a field) prevent instruction/memory reordering around a single field's read/write — use for simple published flags, not for anything needing atomic read-modify-write (that'sInterlocked).- Don't hand-roll a multi-field invariant out of several
Interlockedcalls — if more than one value must change together consistently, that's alock, not a cleverness contest.
Channel<T> — async producer/consumer
var channel = Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(capacity: 100){FullMode = BoundedChannelFullMode.Wait,SingleReader = true,SingleWriter = false,});// producer(s)await channel.Writer.WriteAsync(item, ct).ConfigureAwait(false);// ...channel.Writer.Complete();// consumerawait foreach (var item in channel.Reader.ReadAllAsync(ct).ConfigureAwait(false)){Process(item);}
- Bounded vs. unbounded:
CreateBounded<T>applies backpressure — producers slow down (or fail/drop, depending onFullMode) when the channel is full, which is almost always what you want for anything talking to an external system.CreateUnbounded<T>has no capacity limit and can grow without bound if the consumer falls behind — only use it when you've already reasoned about worst-case queue growth. - `BoundedChannelFullMode`:
Wait(default — producer awaits until space frees, backpressure),DropOldest/DropNewest(lossy, for "latest value wins" telemetry-style feeds),DropWrite(drop the item being written, keep everything already queued). Pick deliberately;Waitis the safe default. - `SingleReader`/`SingleWriter`: set
truewhen you can guarantee only one reader or writer thread — it lets the channel skip synchronization it would otherwise need, which is a real throughput win. Get it wrong (settruebut actually use multiple writers) and behavior is undefined, not just slow. - Channels replace hand-rolled
BlockingCollection+Task.Runproducer loops for the async case — prefer them over building your own queue+SemaphoreSlimsignaling.
ConcurrentDictionary — mind GetOrAdd's factory
// ❌ factory can run more than once under contention — the return values race,// only one wins the dictionary slot, but all of them ran (side effects included)var value = dict.GetOrAdd(key, k => ExpensiveCompute(k));// ✅ Lazy<T> trick: the *Lazy* object may be constructed more than once, but// its factory delegate (.Value) runs at most once because Lazy<T> itself// synchronizesvar lazy = dict.GetOrAdd(key, k => new Lazy<T>(() => ExpensiveCompute(k)));var value = lazy.Value;
ConcurrentDictionary<TKey,TValue>.GetOrAdd(key, factory)does not guarantee the factory runs only once per key. Under concurrent first-access, multiple threads can all call the factory; only one result is stored, the rest are discarded — but if the factory has side effects (I/O, counters, allocation you care about), that's a real bug, not a theoretical one.- The
Lazy<T>wrapper fixes this:Lazy<T>'s own synchronization (defaultLazyThreadSafetyMode.ExecutionAndPublication) ensures the inner factory runs once, even though the outerGetOrAddmay construct (but not invoke) severalLazy<T>instances that are cheap, unevaluated wrappers. - If the value is cheap/pure (no side effects, idempotent), plain
GetOrAddis fine and simpler — don't add theLazy<T>layer speculatively. AddOrUpdate's update factory has the same multiple-invocation caveat.
ReaderWriterLockSlim — rarely
private readonly ReaderWriterLockSlim _rw = new();public T Read(){_rw.EnterReadLock();try { return _state; }finally { _rw.ExitReadLock(); }}
- Only worth it when reads vastly outnumber writes and the protected work under the read lock is non-trivial (a cheap
lock/Lockis faster thanReaderWriterLockSlim's overhead for small critical sections, even single-threaded, because of the extra bookkeeping). - No native async support — same "don't
awaitinside" rule aslock. - Measure before reaching for it;
ConcurrentDictionaryor a plainlockaround a small object usually wins on both simplicity and speed for typical workloads.
Parallel.ForEachAsync — bounded CPU/IO-parallel iteration
await Parallel.ForEachAsync(items,new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = ct },async (item, token) =>{await ProcessAsync(item, token).ConfigureAwait(false);}).ConfigureAwait(false);
- The async-native replacement for
Parallel.ForEach+ blocking, or a hand-rolledSemaphoreSlim+Task.WhenAllloop. SetMaxDegreeOfParallelismexplicitly — the default isEnvironment.ProcessorCount, which is too high for I/O-bound work hitting an external service. - Honors
CancellationTokenpassed viaParallelOptionsand via the per-item delegate parameter — thread it through to inner async calls.
Lazy initialization — Lazy<T> and LazyInitializer
private readonly Lazy<ExpensiveService> _service = new(() => new ExpensiveService());public ExpensiveService Service => _service.Value;
Lazy<T>defaults to thread-safe (ExecutionAndPublication): concurrent first access blocks all callers until one factory run completes, and all callers see the same instance. That's almost always what you want.LazyThreadSafetyMode.Noneremoves the synchronization for single-threaded-only scenarios — don't use it unless you've proven there's no concurrent access.LazyInitializer.EnsureInitialized(ref field, ...)is a lighter-weight alternative when you don't need theLazy<T>wrapper object itself (e.g. a mutable field rather than a property) — check its overload docs for whether it guarantees single-invocation vs. "may run more than once but only one wins," similar to theConcurrentDictionarycaveat above.
CancellationToken in waits
Every blocking-in-spirit wait that has an async or token-accepting overload should take one: SemaphoreSlim.WaitAsync(ct), Channel.Reader.ReadAsync(ct), Task.Delay(ms, ct). A wait with no way to cancel is a wait that can hang the shutdown path. See csharp-async for the broader cancellation contract.
Checklist
- [ ] No
awaitinside alock/Lockblock — useSemaphoreSliminstead - [ ] New code targeting .NET 9+ uses
System.Threading.Lockover a bareobjectforlock - [ ]
SemaphoreSlim.Release()infinally;WaitAsynctakes aCancellationToken - [ ] Single-field lock-free updates use
Interlocked/Volatile, not a full lock - [ ]
Channel<T>capacity andFullModechosen deliberately;SingleReader/SingleWriteronly when actually true - [ ]
ConcurrentDictionary.GetOrAddfactories with side effects wrapped inLazy<T>, or confirmed idempotent - [ ]
ReaderWriterLockSlimonly after measuring, not by default - [ ]
Parallel.ForEachAsynchas an explicit, workload-appropriateMaxDegreeOfParallelism - [ ] Lazy init uses
Lazy<T>'s default thread-safe mode unless single-threaded access is proven - [ ] Every wait accepts and honors a
CancellationToken
See csharp-async for the broader async correctness/perf rules these primitives sit inside, and csharp-performance for when the allocation/contention cost of a primitive actually matters.