System.Collections.Concurrent in depth: locking strategies, atomicity limits, delegate re-execution, expensive members and producer/consumer options.
System.Collections.Concurrent types are safe for many threads at once. ConcurrentDictionary uses lock-free reads and fine-grained (striped) locks for writes. Each single method is atomic, but check-then-act across two calls is not, so use GetOrAdd, AddOrUpdate, TryUpdate or TryRemove. Caveat: the value factory in GetOrAdd/AddOrUpdate can run more than once under contention (only one result is stored), so wrap expensive or side-effecting work in Lazy<T>. Count, IsEmpty and ToArray take every lock, so avoid them in hot paths. ConcurrentQueue/ConcurrentStack are lock-free FIFO/LIFO. ConcurrentBag is unordered, optimised for the same thread adding and taking. BlockingCollection adds bounding and blocking Take but blocks threads; for async producer/consumer use Channel<T>.
The concurrent collections in System.Collections.Concurrent make individual operations thread-safe using fine-grained locks or lock-free techniques. The senior points are: knowing which caveats each one has (especially ConcurrentDictionary.GetOrAdd and AddOrUpdate running factories more than once), that sequences of operations are still not atomic, and that for producer/consumer pipelines Channel<T> is usually the better modern choice.
ConcurrentDictionary<TKey,TValue>TryGetValue, indexer get) are lock-free.TryAdd, TryRemove, TryUpdate(key, new, comparisonValue), GetOrAdd, AddOrUpdate, TryRemove(KeyValuePair) (remove only if value matches).The GetOrAdd/AddOrUpdate caveat: the value factory runs outside the lock. If two threads call GetOrAdd(key, factory) for a missing key at the same time, both factories can run; only one result is stored and both callers get that stored value, but the losing factory's work (and side effects) happened anyway. For AddOrUpdate, the update factory can run multiple times under contention (it retries with TryUpdate).
Consequences: expensive or side-effecting factories (database calls, creating HttpClients, opening connections) may execute more than once. Fix with Lazy<T>:
private readonly ConcurrentDictionary<string, Lazy<Task<Rates>>> _cache = new();
public Task<Rates> GetRatesAsync(string currency) =>
_cache.GetOrAdd(currency, static c => new Lazy<Task<Rates>>(() => LoadRatesAsync(c))).Value;
The Lazy objects are cheap and may be created twice, but only the stored one's Value is ever evaluated, and LazyThreadSafetyMode.ExecutionAndPublication (default) ensures it runs once. Caching the Task also means concurrent callers share a single in-flight load (no cache stampede) — but remember to evict faulted tasks.
Other notes: Count, IsEmpty, Keys, Values, ToArray() take all locks (expensive); enumeration is lock-free and not a snapshot — it may or may not see concurrent changes but never throws.
ConcurrentQueue<T> and ConcurrentStack<T>Interlocked operations. ConcurrentQueue<T> uses linked array segments; ConcurrentStack<T> is a linked list of nodes (allocates per push).Enqueue/TryDequeue/TryPeek; Push/TryPop/PushRange/TryPopRange.BlockingCollection<T> and Channel<T> fill.ConcurrentBag<T>TryTake takes from the local list first and steals from other threads only when empty.ConcurrentQueue<T>, or have each task return its result and use Task.WhenAll.BlockingCollection<T>ConcurrentQueue<T>) adding blocking Take, bounded capacity (Add blocks when full), and CompleteAdding + GetConsumingEnumerable() for producer/consumer.Each call is thread-safe, but sequences of calls are not:
if (!_dict.ContainsKey(key)) _dict[key] = Create(key); // check-then-act race
var count = _dict[key]; _dict[key] = count + 1; // read-modify-write race: lost updates
if (_queue.Count > 0) _queue.TryDequeue(out var item); // Count is stale immediately
Use the atomic APIs (GetOrAdd, AddOrUpdate, TryUpdate in a loop, TryDequeue), Interlocked on values, or a lock around the whole sequence. And mutable values stored in a concurrent dictionary (e.g. a List<T>) are not protected at all.
Channel<T> insteadSystem.Threading.Channels is the modern async producer/consumer primitive:
var channel = Channel.CreateBounded<Order>(new BoundedChannelOptions(1_000)
{
FullMode = BoundedChannelFullMode.Wait, // backpressure
SingleReader = true
});
// producer
await channel.Writer.WriteAsync(order, ct);
// consumer (e.g. in a BackgroundService)
await foreach (var o in channel.Reader.ReadAllAsync(ct)) await ProcessAsync(o, ct);
Advantages over BlockingCollection/ConcurrentQueue: async waiting (no blocked threads), built-in backpressure with bounded capacity and full modes (Wait, DropOldest, DropNewest, DropWrite), completion and error propagation, and optimisations when SingleReader/SingleWriter are set. Use channels for in-process pipelines and background work queues; use concurrent collections for shared state (caches, registries, counters).
A service cached per-tenant HttpClient-based API clients with _clients.GetOrAdd(tenantId, id => CreateClient(id)), where CreateClient fetched credentials from Key Vault. Under a burst after a deployment, dozens of requests for the same tenant raced, Key Vault was hit many times per tenant and started throttling (429), and some clients were created and discarded without disposal. Wrapping values in Lazy<Task<TenantClient>> made creation happen exactly once per tenant and shared the in-flight task. Later we switched to IHttpClientFactory named clients and cached only credentials, with a HybridCache for stampede protection.
Checks real concurrency experience rather than API recall.
GetOrAdd factory caveat with the Lazy<T> fix, compound operation races, expensive Count, and recommends Channel<T> over BlockingCollection for async code.Count in hot loops, or uses ConcurrentBag as a queue.ConcurrentDictionary internalsTables (immutable snapshot, replaced on resize)
├── Node?[] _buckets // linked lists of immutable-key nodes
├── object[] _locks // lock stripes; bucket i uses lock i % locks.Length
└── int[] _countPerLock
Tables reference with a volatile read and walk the bucket's node list without locking. Nodes for a key have a volatile value field, so readers always see a complete value.growLockArray), then publishes them.Count, Clear, ToArray, Keys) also acquire all locks — calling Count frequently on a hot dictionary serialises everything.Because readers never lock, ConcurrentDictionary is excellent for read-heavy shared state. Default concurrency level = processor count; initial capacity can be passed to avoid resizes.
Running user code under a lock invites deadlocks (the factory might access the same dictionary) and long lock holds that block other keys in the same stripe. So GetOrAdd(key, factory):
TryGetValue (lock-free). If found, return.factory(key) without a lock.TryAddInternal under the stripe lock: if another thread added first, discard our value and return theirs.AddOrUpdate loops: read current value, compute new value via the update factory (unlocked), then TryUpdate (compare-and-swap semantics under the lock); if it fails because someone else changed it, retry.
The state-passing overloads (GetOrAdd(key, (k, arg) => ..., arg)) with static lambdas avoid closure allocations on hot paths.
ConcurrentQueue<T>It is a linked list of segments, each a fixed-size array of slots with sequence numbers (based on Dmitry Vyukov's bounded MPMC queue). Enqueue/dequeue reserve a slot with Interlocked.CompareExchange on head/tail indexes; when a segment fills, a new larger segment is linked. Segments can be reused when drained. No locks in the common path, so it scales well with many producers and consumers.
ConcurrentStack<T> is a Treiber stack: push/pop are CAS loops on the head pointer, allocating a node per push — under heavy contention it can spin, and it creates GC pressure.
ConcurrentBag<T> work stealingEach thread gets a WorkStealingQueue (via ThreadLocal). Add pushes to the local queue without contention. TryTake pops locally (LIFO); if empty, it scans other threads' queues and steals from their heads (FIFO), which requires synchronisation. It's designed for scenarios like the thread pool's own local queues, not for general cross-thread hand-off.
UnboundedChannel<T> uses a ConcurrentQueue<T> plus a lock-protected list of waiting readers; TryWrite either hands the item directly to a waiting reader (completing its ValueTask) or enqueues it.BoundedChannel<T> uses a Deque<T> under a lock and tracks waiting writers when full.SingleConsumerUnboundedChannel<T> (when SingleReader = true) uses a lock-free single-producer/single-consumer optimised structure.ValueTask<T> backed by reusable, pooled IValueTaskSource objects, so steady-state reading allocates nothing.AllowSynchronousContinuations = false (default) runs consumer continuations asynchronously, preventing a producer's WriteAsync from running consumer code inline.| Scenario | Use |
|---|---|
| Shared cache/registry, many readers, some writers | ConcurrentDictionary (values via Lazy<T> if expensive) |
| Read-only shared lookup | FrozenDictionary / immutable + atomic swap |
| Counters | Interlocked.Increment on fields, or ConcurrentDictionary with AddOrUpdate |
| Async producer/consumer, background queues | Channel<T> (bounded for backpressure) |
| Thread-dedicated blocking consumer (legacy) | BlockingCollection<T> |
| Object pooling | ObjectPool<T> (Microsoft.Extensions.ObjectPool), which uses fast thread-local + array strategies |
| Collecting results from parallel tasks | Return values + Task.WhenAll, or ConcurrentQueue<T> |
| Cross-process / durable work | Azure Service Bus / Storage Queues |
GetOrAdd with a factory that increments a counter and sleeps, from 50 parallel tasks for the same key; print the counter. Repeat with Lazy<T>.dict[key] = dict[key] + 1 from many threads and compare with AddOrUpdate.ConcurrentDictionary.Count in a hot loop vs not calling it.using System.Collections.Concurrent;
using System.Threading.Channels;
// 1. Counters without lost updates
var hits = new ConcurrentDictionary<string, int>(StringComparer.Ordinal);
hits.AddOrUpdate(path, 1, (_, current) => current + 1);
// 2. Expensive creation exactly once per key
var rates = new ConcurrentDictionary<string, Lazy<Task<decimal>>>();
Task<decimal> GetRate(string pair) =>
rates.GetOrAdd(pair, p => new Lazy<Task<decimal>>(() => FetchRateAsync(p))).Value;
// note: a faulted task stays cached; remove it on failure if you want retries
// 3. Compare-and-swap update
if (hits.TryGetValue(path, out int seen))
hits.TryUpdate(path, seen + 10, comparisonValue: seen);
// 4. Remove only if the value is still what we expect
hits.TryRemove(KeyValuePair.Create(path, 0));
// 5. Async producer/consumer with back-pressure
var channel = Channel.CreateBounded<Order>(new BoundedChannelOptions(capacity: 100)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true
});
_ = Task.Run(async () =>
{
await foreach (var order in channel.Reader.ReadAllAsync())
await ProcessAsync(order);
});
await channel.Writer.WriteAsync(new Order(1)); // waits if 100 are queued
channel.Writer.Complete();
record Order(int Id);
GetOrAddWhat happens: GetOrAdd(key, k => LoadFromDatabase(k)) under concurrency.
Why it is wrong: the factory can run several times for the same key — duplicate database calls, throttling, leaked resources.
Do instead: store Lazy<T> (or Lazy<Task<T>>) values, or use a cache with stampede protection (HybridCache).
What happens: if (!dict.ContainsKey(k)) dict.TryAdd(k, v); or if (queue.Count > 0) queue.TryDequeue(...).
Why it is wrong: another thread can act between the calls; the check is stale.
Do instead: use single atomic operations (TryAdd, GetOrAdd, TryDequeue).
What happens: dict[key] = dict[key] + 1; from multiple threads.
Why it is wrong: two threads read the same value and one increment is lost.
Do instead: AddOrUpdate(key, 1, static (_, v) => v + 1), or Interlocked on a field.
What happens: ConcurrentDictionary<string, List<Order>> where lists are modified by multiple threads.
Why it is wrong: the dictionary is thread-safe; the lists inside are not.
Do instead: store immutable values and replace them atomically, or lock per value, or use concurrent collections as values.
Count, Keys or ToArray frequentlyWhat happens: logging dict.Count on every request or checking IsEmpty in loops.
Why it is wrong: these take all locks, serialising writers and hurting throughput.
Do instead: track counts separately with Interlocked if needed, or call them rarely.
ConcurrentBag<T> as a general thread-safe listWhat happens: parallel tasks add results to a ConcurrentBag and a different thread reads them.
Why it is wrong: it's optimised for same-thread add/take; cross-thread takes trigger stealing, and order is lost.
Do instead: return results from tasks and use Task.WhenAll, or use ConcurrentQueue<T>.
BlockingCollection<T> in async/ASP.NET Core codeWhat happens: Take() or GetConsumingEnumerable() used in request handlers or async background services.
Why it is wrong: blocks thread-pool threads, causing starvation and latency spikes.
Do instead: use Channel<T> with await ReadAsync/ReadAllAsync.
What happens: while (true) { if (q.TryDequeue(out x)) ...; else Thread.Sleep(100); }.
Why it is wrong: wastes CPU or adds latency, and blocks a thread.
Do instead: use a channel so consumers wait asynchronously.
What happens: producers outpace consumers with an unbounded channel or queue. Why it is wrong: memory grows without limit until the process is OOM-killed. Do instead: use bounded channels with an explicit full mode, and monitor queue depth.
What happens: Lazy<Task<T>> values cache a task that failed (e.g. a transient network error).
Why it is wrong: every later caller gets the same exception.
Do instead: remove the entry when the task faults (TryRemove(KeyValuePair) with the faulted value), or use a cache with failure handling.
Seniors separate "the collection is thread-safe" from "my operation is atomic", and they choose between locking, lock-free and immutable designs based on the read/write ratio.
| Workload | Best fit | Why |
|---|---|---|
| Read-heavy, rare updates | immutable/frozen snapshot | no locks on reads |
| Mixed reads and writes on keys | ConcurrentDictionary |
striped locks |
| Expensive per-key init | ConcurrentDictionary + Lazy<T> |
single execution |
| Async pipeline with back-pressure | bounded Channel<T> |
no thread blocking |
| Multi-step invariant across keys | lock around a plain Dictionary |
simpler and correct |
A notable senior point: sometimes a plain Dictionary behind a lock is faster and clearer than a concurrent collection, especially when every operation is a compound update. They measure contention before reaching for lock-free structures.
Immutable collections (System.Collections.Immutable) never change after creation; "modifying" methods such as Add return a new collection and leave the old one untouched, so they are safe to share across threads without locks. ImmutableArray<T> is a struct around a plain array: reads are as fast as an array, but every Add copies the whole thing (O(n)). ImmutableList<T> and ImmutableDictionary<TKey,TValue> are balanced trees that share structure: an Add is O(log n) and reuses most of the old nodes, but reads are slower. Use builders (ToBuilder() ... ToImmutable()) for many changes. A read-only view like ReadOnlyCollection<T> or IReadOnlyList<T> only stops you changing it; the underlying list can still change. FrozenDictionary/FrozenSet (.NET 8+) are immutable too but optimised purely for fast reads with no "modified copy" operations.
Queue<T> is FIFO (first in, first out) and Stack<T> is LIFO (last in, first out). Both are backed by an array and give O(1) Enqueue/Dequeue and Push/Pop (amortised, since the array grows). Queue<T> uses a circular buffer with head and tail indexes so dequeuing never shifts elements. Dequeue, Pop and Peek throw InvalidOperationException when empty; TryDequeue, TryPop and TryPeek return false instead. A stack enumerates from the top down. Use PriorityQueue<TElement,TPriority> (.NET 6+) when the next item depends on priority, and ConcurrentQueue<T> or Channel<T> when several threads produce or consume.
How to tell it (STAR)
**Situation:** Our conversion service at [company] started getting 429 rate-limit errors from the exchange-rate provider at peak times. **Task:** I needed to stop the errors without paying for a higher rate tier. **Action:** The logs showed the same currency pair being fetched many times within milliseconds. Our cache used `ConcurrentDictionary.GetOrAdd` with the fetch as the factory, and that factory runs outside the lock, so every concurrent miss called the provider. I changed the cached value to `Lazy<Task<decimal>>` so only one fetch runs per pair, made failed fetches removable so they could retry, and wrote a test that fires [100] parallel requests and asserts one upstream call. **Result:** Upstream calls fell by [about X%], the 429s stopped, and the `Lazy` pattern became our standard for expensive per-key initialisation.
Last updated 30 Oct 2026.