📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do the concurrent collections in .NET work? Explain ConcurrentDictionary (including the GetOrAdd and AddOrUpdate caveats), ConcurrentQueue, ConcurrentStack, ConcurrentBag and BlockingCollection, why compound operations are still not atomic, and when you would use Channel<T> instead.

System.Collections.Concurrent in depth: locking strategies, atomicity limits, delegate re-execution, expensive members and producer/consumer options.

Senior Theory Advanced channels collections concurrency concurrentdictionary thread-safety

🎯 Key points

  • `ConcurrentDictionary` has lock-free reads and striped locks for writes
  • Single methods are atomic; check-then-act across calls is not
  • `GetOrAdd` and `AddOrUpdate` delegates can run more than once; use `Lazy<T>` for expensive work
  • `Count`, `ToArray`, `Keys` and `Values` take all locks; avoid them in hot paths
  • Enumeration is safe but not a snapshot
  • `ConcurrentQueue` and `ConcurrentStack` are lock-free; `ConcurrentBag` suits same-thread add and take
  • `BlockingCollection` bounds and blocks threads; prefer `Channel<T>` for async producer/consumer
  • A plain `Dictionary` behind a `lock` can be simpler and faster for compound updates

⏱ 30-second answer

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

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. ConcurrentDictionary<TKey,TValue>

  • Reads (TryGetValue, indexer get) are lock-free.
  • Writes take a lock on one stripe of buckets (a fixed array of locks, by default related to processor count), so different keys can be written concurrently.
  • Atomic operations: 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.

2. ConcurrentQueue<T> and ConcurrentStack<T>

  • Lock-free FIFO and LIFO using Interlocked operations. ConcurrentQueue<T> uses linked array segments; ConcurrentStack<T> is a linked list of nodes (allocates per push).
  • API: Enqueue/TryDequeue/TryPeek; Push/TryPop/PushRange/TryPopRange.
  • They don't block when empty — a consumer must poll or sleep, which wastes CPU or adds latency. That's the gap BlockingCollection<T> and Channel<T> fill.

3. ConcurrentBag<T>

  • An unordered collection optimised for the case where the same thread adds and removes items (e.g. object pools, work-stealing). Each thread has a local list; TryTake takes from the local list first and steals from other threads only when empty.
  • Misused as a general thread-safe list, it performs poorly (stealing is expensive) and provides no ordering. For "collect results from parallel work", prefer ConcurrentQueue<T>, or have each task return its result and use Task.WhenAll.

4. BlockingCollection<T>

  • A wrapper (default over ConcurrentQueue<T>) adding blocking Take, bounded capacity (Add blocks when full), and CompleteAdding + GetConsumingEnumerable() for producer/consumer.
  • It blocks threads — fine in a dedicated thread, harmful in ASP.NET Core or async code because blocked thread-pool threads cause thread-pool starvation.

5. Why compound operations are still not atomic

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.

6. When to use Channel<T> instead

System.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).

7. A production example

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.

🔍 What the interviewer is checking

Checks real concurrency experience rather than API recall.

  • Green flags: explains striped locking and lock-free reads, the GetOrAdd factory caveat with the Lazy<T> fix, compound operation races, expensive Count, and recommends Channel<T> over BlockingCollection for async code.
  • Red flags: believes a concurrent collection makes all code thread-safe, uses Count in hot loops, or uses ConcurrentBag as a queue.

📚 Learn in depth

1. ConcurrentDictionary internals

Tables (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
  • Reads load the current 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.
  • Writes compute the bucket, take the corresponding stripe lock, modify the chain (new nodes or value replacement), and release.
  • Resize takes all locks, builds new tables and possibly more lock stripes (growLockArray), then publishes them.
  • Operations needing global state (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.

2. Why factories run outside the lock

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):

  1. TryGetValue (lock-free). If found, return.
  2. Call factory(key) without a lock.
  3. 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.

3. Lock-free queues: 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.

4. ConcurrentBag<T> work stealing

Each 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.

5. Channels internals

  • 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.
  • Reads return 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.

6. Choosing the right tool

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

Try it yourself

  1. Call 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>.
  2. Implement a counter with dict[key] = dict[key] + 1 from many threads and compare with AddOrUpdate.
  3. Benchmark ConcurrentDictionary.Count in a hot loop vs not calling it.
  4. Build a bounded channel pipeline with one fast producer and a slow consumer; watch memory stay flat.

💻 Code

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);

⚠ Common mistakes

1. Expensive or side-effecting factories in GetOrAdd

What 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).

2. Check-then-act sequences

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).

3. Read-modify-write with the indexer

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.

4. Mutable values inside concurrent collections

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.

5. Calling Count, Keys or ToArray frequently

What 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.

6. Using ConcurrentBag<T> as a general thread-safe list

What 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>.

7. BlockingCollection<T> in async/ASP.NET Core code

What 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.

8. Polling concurrent queues with sleeps

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.

9. Unbounded producer/consumer queues

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.

10. Caching faulted tasks forever

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions read-heavy shared state Why: Checks immutable snapshot alternative
    How to answer

    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.

  2. When: Candidate compares ConcurrentQueue with Queue<T> Why: Grounds the discussion in single-threaded basics
    How to answer

    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.

🛠 Real-world problems: how you handle them

Rate-limit errors from duplicate cache fills in GetOrAdd
Situation
A currency conversion service cached exchange rates in a `ConcurrentDictionary<string, decimal>` filled with `GetOrAdd(pair, FetchRate)`. The upstream provider allowed [N] calls per minute.
Signals
HTTP 429 responses from the provider at the start of each cache expiry window; logs showed the same currency pair fetched [5 to 20] times within milliseconds.
Investigation
Reproduced with a load test: when many requests asked for an uncached pair simultaneously, each thread ran the factory before any had stored a value. Confirmed from the docs that the factory runs outside the lock.
Resolution
Changed the value type to `Lazy<Task<decimal>>` so only one fetch runs per pair; removed faulted entries so failures could retry; added expiry by replacing entries via `TryUpdate`.
Prevention
A test that fires [100] parallel requests for one key and asserts a single upstream call; a code review rule that `GetOrAdd` factories must be cheap and pure or wrapped in `Lazy`.

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.

↩ Usually asked after

More in Collections

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me