agentleFS
glennawatsonglennawatson/CSharpAgentSkills/skills/csharp-concurrency-primitives/SKILL.md

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.

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
  1. Very specific:

    Every primitive here trades off differently on "blocks a thread vs.

    line 8
  2. Very specific:

    Prefer Lock over a bare object for new code targeting .NET 9+.

    line 27
  3. Very specific:

    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 …

    line 29
  4. Very specific:

    Always Release() in a finally.

    line 52
  5. Very specific:

    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 …

    line 53
  6. Very specific:

    Don't hand-roll a multi-field invariant out of several Interlocked calls — if more than one value must change together consistently, that's …

    line 68
  7. Very specific:

    If the value is cheap/pure (no side effects, idempotent), plain GetOrAdd is fine and simpler — don't add the Lazy<T> layer speculatively.

    line 113
  8. Very specific:

    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 …

    line 129
  9. Very specific:

    No native async support — same "don't await inside" rule as lock.

    line 130
  10. Very specific:

    Every blocking-in-spirit wait that has an async or token-accepting overload should take one: SemaphoreSlim.WaitAsync(ct), …

    line 161

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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 Lock over a bare object for new code targeting .NET 9+; it's a drop-in lock (_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, including Lock) — the continuation after an await can 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 an await, use SemaphoreSlim instead.
  • 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 — WaitAsync doesn't block a thread while waiting, unlike lock.
  • Also use it (with initial count > 1) to throttle concurrency, e.g. new SemaphoreSlim(maxConcurrent) around calls you fan out with Task.WhenAll.
  • Always Release() in a finally. Always pass a CancellationToken to WaitAsync — see csharp-async for why cancellation is part of the contract.
  • 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 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/CompareExchange are for single scalar fields where a full lock would be overkill — counters, flags, simple state machines via CompareExchange loops.
  • Volatile.Read/Write (or the volatile keyword 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's Interlocked).
  • Don't hand-roll a multi-field invariant out of several Interlocked calls — if more than one value must change together consistently, that's a lock, 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 on FullMode) 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; Wait is the safe default.
  • SingleReader/SingleWriter: set true when 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 (set true but actually use multiple writers) and behavior is undefined, not just slow.
  • Channels replace hand-rolled BlockingCollection + Task.Run producer loops for the async case — prefer them over building your own queue+SemaphoreSlim signaling.

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 (default LazyThreadSafetyMode.ExecutionAndPublication) ensures the inner factory runs once, even though the outer GetOrAdd may construct (but not invoke) several Lazy<T> instances that are cheap, unevaluated wrappers.
  • If the value is cheap/pure (no side effects, idempotent), plain GetOrAdd is fine and simpler — don't add the Lazy<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/Lock is faster than ReaderWriterLockSlim's overhead for small critical sections, even single-threaded, because of the extra bookkeeping).
  • No native async support — same "don't await inside" rule as lock.
  • Measure before reaching for it; ConcurrentDictionary or a plain lock around 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-rolled SemaphoreSlim + Task.WhenAll loop. Set MaxDegreeOfParallelism explicitly — the default is Environment.ProcessorCount, which is too high for I/O-bound work hitting an external service.
  • Honors CancellationToken passed via ParallelOptions and 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.None removes 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 the Lazy<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 the ConcurrentDictionary caveat 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 await inside a lock/Lock block — use SemaphoreSlim instead
  • New code targeting .NET 9+ uses System.Threading.Lock over a bare object for lock
  • SemaphoreSlim.Release() in finally; WaitAsync takes a CancellationToken
  • Single-field lock-free updates use Interlocked/Volatile, not a full lock
  • Channel<T> capacity and FullMode chosen deliberately; SingleReader/SingleWriter only when actually true
  • ConcurrentDictionary.GetOrAdd factories with side effects wrapped in Lazy<T>, or confirmed idempotent
  • ReaderWriterLockSlim only after measuring, not by default
  • Parallel.ForEachAsync has an explicit, workload-appropriate MaxDegreeOfParallelism
  • 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.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.