csharp-concurrency-primitives
Skill2 starsChanged 42 days agoMITView on GitHub
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.
In this repository56 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- benchmarkingSkill
- commit-messagesSkill
- csharp-15-syntaxSkill
- csharp-aot-trimmingSkill
- csharp-api-designSkill
- csharp-assembly-inspectionSkill
- csharp-asyncSkill
- csharp-collections-modernSkill
- csharp-data-layoutSkill
- csharp-debuggingSkill
- csharp-discardsSkill
- csharp-disposable-patternsSkill
- csharp-docsSkill
- csharp-error-handlingSkill
- csharp-extension-membersSkill
- csharp-generics-modernSkill
- csharp-guard-clausesSkill
- csharp-json-source-generationSkill
- csharp-language-versionsSkill
- csharp-logging-diagnosticsSkill
- csharp-lspSkill
- csharp-modern-typesSkill
- csharp-nullabilitySkill
- csharp-nullable-migrationSkill
- csharp-nunitSkill
- csharp-pattern-matchingSkill
- csharp-performanceSkill
- csharp-polyfillsSkill
- csharp-source-generatorsSkill
- csharp-static-lambdasSkill
- csharp-string-handlingSkill
- csharp-tunitSkill
- csharp-verificationSkill
- dispatching-subagentsSkill
- dotnet-11-librariesSkill
- dotnet-analyzersSkill
- dotnet-central-package-managementSkill
- dotnet-file-based-appsSkill
- dotnet-msbuild-usingsSkill
- dotnet-multi-targetingSkill
- dotnet-project-hygieneSkill
- dotnet-runSkill
- dotnet-runtime-asyncSkill
- dotnet-slnxSkill
- dotnet-test-platformsSkill
- executing-plansSkill
- performance-bounded-deduplicationSkill
- roslyn-analyzer-performanceSkill
- roslyn-discoverySkill
- roslyn-duplicate-detectionSkill
- roslyn-guard-rewritersSkill
- roslyn-rewritersSkill
- subagent-driven-developmentSkill
- tddSkill
The file
We split this skill into the rules it gives an agent
10 rules. About 0 are common, 0 are distinctive, and 10 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
10
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 8
Every primitive here trades off differently on "blocks a thread vs.
- Very specific: line 27
Prefer Lock over a bare object for new code targeting .NET 9+.
- Very specific: line 29
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 …
- Very specific: line 52
Always Release() in a finally.
- Very specific: line 53
Don't reach for SemaphoreSlim when a plain lock/Lock would do (no await in the critical section) — it's heavier and the async overhead is …
- Very specific: line 68
Don't hand-roll a multi-field invariant out of several Interlocked calls — if more than one value must change together consistently, that's …
- Very specific: line 113
If the value is cheap/pure (no side effects, idempotent), plain GetOrAdd is fine and simpler — don't add the Lazy<T> layer speculatively.
- Very specific: line 129
Only worth it when reads vastly outnumber writes *and* the protected work under the read lock is non-trivial (a cheap lock/Lock is faster …
- Very specific: line 130
No native async support — same "don't await inside" rule as lock.
- Very specific: line 161
Every blocking-in-spirit wait that has an async or token-accepting overload should take one: SemaphoreSlim.WaitAsync(ct), …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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
awaitinside alockblock. 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();
// consumer
await 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: settruewhen 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
// synchronizes
var 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 trueConcurrentDictionary.GetOrAddfactories with side effects wrapped inLazy<T>, or confirmed idempotentReaderWriterLockSlimonly after measuring, not by defaultParallel.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.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

