Concurrency and shared state
Skill Sarmkadan/dotnet-senior-skills/skills/concurrency-and-shared-state
Review .NET concurrency - lock discipline, Interlocked, concurrent collections, SemaphoreSlim for async mutual exclusion, Channels, and Parallel.ForEachAsync. Use when reviewing shared mutable state, locks, or parallel code.From its SKILL.md
npx -y skills add Sarmkadan/dotnet-senior-skills --skill concurrency-and-shared-stateAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
4.9 KB, ~1.1k tokens by cl100k_base, as published. Nobody here has run it
Concurrency and Shared State
First question: does this state need to be shared?
Most "how do I lock this" reviews end with removing the shared state: make the service scoped instead of singleton, pass values through the call chain, or use immutable snapshots. A singleton with mutable fields is guilty until proven thread-safe - and "proven" means every access site audited, not "it hasn't crashed yet". Races surface under production load as corrupted state, not as test failures.
lock discipline
// non-compiling: illustrative
// WRONG: check and act are separate; two threads both pass the check
if (!_cache.ContainsKey(key)) { _cache[key] = Create(key); }
// RIGHT: the whole read-modify-write under one lock (or use ConcurrentDictionary.GetOrAdd)
lock (_gate) { if (!_cache.TryGetValue(key, out var v)) { v = Create(key); _cache[key] = v; } }
- Lock object:
private readonly Lock _gate = new();(.NET 9+) orprivate readonly object _gate = new();. Neverlock (this),lock (typeof(X)), or lock on a string - all reachable by other code, all deadlock bait. - Hold locks for nanoseconds, not milliseconds: no I/O, no callbacks, no unknown virtual calls inside a lock. A lock around an HTTP call serializes your whole service.
awaitinsidelockdoes not compile - and the workaround people reach for (Monitor.Entermanually) is broken, because the continuation resumes on a different thread that does not own the monitor. Async mutual exclusion isSemaphoreSlim(1, 1):
await _semaphore.WaitAsync(ct);
try { await RefreshAsync(ct); }
finally { _semaphore.Release(); }
- Two locks acquired in different orders in different methods is the textbook deadlock. If you need two, define and document a global order; better, restructure to one.
Interlocked and volatile
- Counters:
Interlocked.Increment(ref _count), not_count++(read-modify-write, loses updates) and notlock(overkill). Read withInterlocked.Read/Volatile.Readon the same field family. volatileis not a lock and not for counters - it orders reads/writes of a single field. If you are reasoning about fences to justify lock-free code outside a measured hot path, stop and take the lock; the review cost of clever memory-model code exceeds its benefit almost everywhere.- Lazy one-time init:
Lazy<T>orLazyInitializer.EnsureInitialized, not hand-rolled double-checked locking.
Concurrent collections
ConcurrentDictionary:GetOrAdd/AddOrUpdateare atomic per key, but thevalueFactorymay run multiple times concurrently (only one result wins). Factory with side effects (opens a connection, increments a counter): wrap the value inLazy<T>-GetOrAdd(key, k => new Lazy<T>(() => Create(k))).Value.- Iterating a concurrent collection gives a moving snapshot -
Countthenforeachcan disagree. Do not build invariants across multiple calls; each call is atomic, the sequence is not. List<T>+lockbeatsConcurrentBag<T>in almost every real case;ConcurrentBagis for same-thread-mostly producer-consumer and its unordered semantics surprise everyone.
Producer-consumer: Channel<T>
Queue work between components with System.Threading.Channels, not BlockingCollection (blocks threads) or a hand-rolled Queue + lock + event:
var channel = Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(1000)
{ FullMode = BoundedChannelFullMode.Wait });
// producer: await channel.Writer.WriteAsync(item, ct);
// consumer: await foreach (var item in channel.Reader.ReadAllAsync(ct)) { ... }
Bounded, always - an unbounded channel is an unbounded memory leak when the consumer falls behind. FullMode is a deliberate backpressure decision: Wait (slow the producer), DropOldest/DropWrite (shed load) - pick per use case, in review.
Parallelism
- CPU-bound batch over a collection:
Parallel.ForEachAsync(items, new ParallelOptions { MaxDegreeOfParallelism = n, CancellationToken = ct }, ...). UnboundedTask.WhenAll(items.Select(DoAsync))over 10k items fires 10k concurrent operations at your database or HTTP dependency - that is a self-inflicted DoS, not parallelism. Bound it (ForEachAsync, orSemaphoreSlimaround the body). Parallel.For/PLINQare for CPU-bound sync work only; feeding them async lambdas produces async void (exceptions escape, work outruns the loop).- No parallelism inside a request handler for sub-100ms work - the thread coordination costs more than it saves, and it steals pool threads from other requests. Parallel work belongs in background jobs and batch processing.
- Shared
DbContext,HttpContext, or any scoped service captured by parallel bodies: rejection. Each parallel unit resolves its own scope.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.