Threading
How .NET runs code in parallel: threads and the thread pool, protecting shared data with locks and atomic operations, concurrent collections and queues between threads. In an interview this topic checks whether you can spot races and deadlocks and find them in production.
Before this topic: Types and memory.
How does Thread differ from Task, and what is the thread pool?
Section titled “How does Thread differ from Task, and what is the thread pool?”What it is
Thread— an OS thread. You create it withnew Thread(...), start it withStart()and wait for it withJoin().- Thread pool — a set of background threads that the CLR creates on demand and reuses.
Task— a unit of work with a result.Task.Run(...)queues a delegate to the pool and returns aTaskorTask<T>.
Why
Creating a thread is expensive: each one reserves its own stack — by default 1.5 MB on Windows and 8 MB on Linux. The pool removes that cost: after a task the thread goes back to the pool.
How to use
- Start the work:
var task = Task.Run(() => Compute());. - Wait for the result:
int result = await task;.
Common mistake
Thinking that a Task is a thread. A task may not occupy a thread at all while it waits for the network or disk.
Takeaway: use Task for background work and a raw Thread only when you cannot do without it.
How it works
- A
Taskstores its state, result (Task<T>) and exception. Withawaitthe exception is rethrown as is; with.Wait()and.Resultit is wrapped in anAggregateException. - Pool threads are background threads: the process does not wait for them on exit.
new Threadis a foreground thread by default (IsBackground = false) and keeps the process alive until it finishes.TaskCreationOptions.LongRunninghints the scheduler to give the task a dedicated thread outside the pool.
When to choose what
| Job | What to use |
|---|---|
| Short CPU work in the background | Task.Run |
| Waiting for the network or disk | an async API and await, no Task.Run |
| Long blocking work | LongRunning or your own Thread |
| STA (COM, UI), special priority | your own Thread |
Pitfalls
Task.Run(() => File.ReadAllText(path))ties up a pool thread waiting for the disk.await File.ReadAllTextAsync(path)is better.Thread.AbortthrowsPlatformNotSupportedExceptionin .NET 5+. Threads are stopped cooperatively, with aCancellationToken.
Rule of thumb
Task.Run for CPU work, await for I/O, Thread only when you need control over the thread itself.
Takeaway: a Task describes the work, and the pool decides which thread runs it.
Under the hood
- To the pool, every task is a work item — an entry in its queue.
- A work item queued from code running on a pool thread goes to that thread’s local queue. It is read in LIFO order for cache locality.
- Everything else goes to the global FIFO queue.
- An idle thread looks for work in this order:
- its own local queue;
- the global queue;
- the tail of another thread’s local queue — that is work stealing.
- Up to the minimum (
ThreadPool.GetMinThreads, by default the processor count) the pool creates threads immediately. Above it, an algorithm adjusts the thread count while watching throughput.
Cost
- Every extra thread means stack memory, context switches and extra GC work when walking stacks.
- A blocked pool thread drops out of work, and above the minimum its replacement does not appear right away.
Trade-offs
LongRunningwith an async delegate is pointless: the dedicated thread lives only until the firstawait, then the continuation moves to the pool.- Since .NET 8 on Windows you can switch to the Windows thread pool (
UseWindowsThreadPool); Native AOT apps on Windows use it by default. Whether it wins depends on the workload, so measure.
How to check in production
dotnet-counters monitor shows the pool thread count, its queue length and the number of completed work items. More than roughly three threads per core is a sign that code is blocking pool threads.
Takeaway: the pool is built for short non-blocking work items, and anything that holds its threads hurts throughput.
// Our own thread: created and joined manually, foreground by defaultvar thread = new Thread(() => Console.WriteLine("running on a dedicated thread"));thread.Start();thread.Join();
// A task on the thread pool: it has a resultTask<int> task = Task.Run(() => 6 * 7);Console.WriteLine(await task); // 42
// Pool threads are background threadsawait Task.Run(() => Console.WriteLine(Thread.CurrentThread.IsBackground)); // True
// Long blocking work gets a dedicated thread outside the poolawait Task.Factory.StartNew(() => Thread.Sleep(1000), TaskCreationOptions.LongRunning);- What happens to the process if
Mainreturns while anew Threadis still running? — The process waits for it: such a thread is foreground by default. - How does
Task.Rundiffer fromTask.Factory.StartNew? —Task.Runhas safe defaults (DenyChildAttach, the default scheduler) and unwraps async lambdas. - Does a task waiting for a database response occupy a thread? — No: async I/O does not hold a thread; only the continuation needs one.
- How many threads are in the pool? — The default minimum equals the processor count; beyond that the pool tunes the number itself.
- Why
TaskCreationOptions.LongRunning? — So long blocking work gets a dedicated thread instead of tying up the pool.
What are a race condition and a deadlock, and how do you avoid them?
Section titled “What are a race condition and a deadlock, and how do you avoid them?”What it is
- Race condition — the result depends on which thread got there first.
- Deadlock — thread 1 holds resource A and waits for B, thread 2 holds B and waits for A. Both wait forever.
Why
These are the two main bugs in multithreaded code. Both depend on timing and are hard to catch in tests.
How to avoid them
- Do not share mutable data between threads if you can avoid it.
- Protect shared data with
lock— it lets only one thread into the block. - Change simple counters with
Interlocked.Increment— one indivisible operation. - Always take locks in the same order and hold them as briefly as possible.
Common mistake
Treating count++ as one operation. It is three steps — read, add, write — so with two threads one increment can get lost.
Takeaway: a race corrupts data, a deadlock hangs the program; discipline around shared state protects against both.
How it works
- An atomic operation happens all at once: another thread never sees an intermediate state.
- Reads and writes of an aligned
intor a reference are atomic. Compound operations are not:x++, “check, then add”. - A deadlock happens when each thread holds a lock and waits for the one the other holds.
When to choose what
| Situation | Tool |
|---|---|
| A single variable: counter, flag | Interlocked |
| An invariant across several fields | lock |
| A shared dictionary or queue | concurrent collections |
| Data that need not be shared | per-thread isolation, immutable objects |
Pitfalls
if (!dict.ContainsKey(k)) dict[k] = v;is a race even on aConcurrentDictionary: another thread can slip in between the check and the write. Use a singleTryAddcall.- Raising events or calling callbacks and virtual methods inside a
lock: foreign code may take another lock and close the cycle. - Blocking on async code (
.Result) under a single-threadedSynchronizationContextis a deadlock without a singlelock.
Rule of thumb
Everyone takes locks in the same order. Where you can back off, use Monitor.TryEnter or Lock.TryEnter with a timeout.
Takeaway: atomicity removes races; lock ordering and short critical sections remove deadlocks.
Under the hood
- A data race is concurrent access to the same memory location without synchronization, where at least one access is a write.
- The JIT may read a field once and keep the value in a register. A
while (!_stop)loop withoutvolatilemay never see_stop = true. - A deadlock needs all four Coffman conditions:
- mutual exclusion;
- hold and wait;
- no preemption;
- circular wait.
Cost
A deadlock is a hang without CPU load that often shows up only under load. The process is alive, but requests that need the lock never complete.
Trade-offs
- A lock order (a hierarchy of levels) breaks the cycle but needs discipline across the whole codebase.
TryEnterwith a timeout removes infinite waiting but adds a failure branch you have to design.- Livelock: threads are active but make no progress — for example, they keep retrying
CompareExchange. The fix is backing off between attempts (SpinWait). - The best synchronization is none: immutable snapshots, message passing through queues, partitioning data by thread.
How to check in production
- Take a dump:
dotnet-dump collect -p PID. - In
dotnet-dump analyze, runclrstack -allorparallelstacksand find threads stuck inMonitor.Enter. - The
syncblkcommand shows which thread owns aMonitorand how many threads are waiting for it. - For async chains, use
dumpasync.
Takeaway: a deadlock is a waiting cycle; you prevent it with lock ordering and find it in a dump.
int unsafeCount = 0;int safeCount = 0;
Parallel.For(0, 100_000, _ =>{ unsafeCount++; // race: read, add and write are not atomic Interlocked.Increment(ref safeCount); // atomic});
Console.WriteLine(unsafeCount); // usually less than 100000Console.WriteLine(safeCount); // 100000
// Deadlock: two threads take locks a and b in different ordersvar a = new object();var b = new object();var t1 = Task.Run(() => { lock (a) { Thread.Sleep(50); lock (b) { } } });var t2 = Task.Run(() => { lock (b) { Thread.Sleep(50); lock (a) { } } });// await Task.WhenAll(t1, t2) never completes.// Fix: both threads take a first, then b.The first number changes from run to run — that is the race.
- Is writing a
longatomic? — On a 64-bit platform yes, if it is aligned; on 32-bit no, so useInterlocked.ReadorVolatile. - Why is
ContainsKeyfollowed by a write wrong even forConcurrentDictionary? — Another thread slips in between the two calls; you need a singleTryAddorGetOrAdd. - How do you find a deadlock in production? — Take a dump and look at
clrstack -allandsyncblkindotnet-dump analyze, or Parallel Stacks in Visual Studio. - What is a livelock? — Threads are not blocked, but constant retries keep them from doing useful work.
- Can a thread deadlock with itself? — Not with
lock, which is reentrant; withSemaphoreSlim(1, 1)it can: a secondWaitwaits for itself.
How does lock work, and what does the System.Threading.Lock type (.NET 9) give you?
Section titled “How does lock work, and what does the System.Threading.Lock type (.NET 9) give you?”What it is
lock guarantees that only one thread at a time runs the code inside the block. Other threads that reach lock with the same object wait.
Why
So that several threads do not change shared data at the same time. For example, so that two withdrawals from one account do not push the balance below zero.
How to use
- Declare a private field:
private readonly Lock _sync = new();(before .NET 9 —new object()). - Wrap the work with shared data:
lock (_sync) { _balance -= amount; }. - Keep only that work inside — no I/O and no long computations.
Common mistake
Locking on this, typeof(...) or a string. Other code can lock on them too, which leads to extra contention or a deadlock.
Takeaway: lock on a private field, briefly, and only around shared data.
How it works
- For a reference type,
lockcompiles toMonitor.Enter(obj, ref lockTaken)in atryandMonitor.Exit(obj)in afinally. - For
Lockit compiles tousing (x.EnterScope()).Lock.Scopeis aref structwith aDisposemethod. - A thread that already owns the lock can enter again. It has to exit the same number of times.
- Only the owning thread can release the lock; otherwise you get
SynchronizationLockException.
When to choose what
| Situation | What to use |
|---|---|
| .NET 9+, synchronous code | a Lock field and lock |
| .NET 8 and earlier | an object field and lock |
You need await inside |
a one-slot semaphore: SemaphoreSlim(1, 1) and WaitAsync |
| You need a timeout | Lock.TryEnter or Monitor.TryEnter |
Pitfalls
awaitinsidelockis error CS1996: the continuation may run on another thread, but the owner must exit.object o = _sync; lock (o)isMonitornow, notLock(warning CS9216).- Raising events or calling callbacks and virtual methods inside
lock: foreign code may take another lock and cause a deadlock. lockon a value type does not compile: each boxing would produce a new object.
Rule of thumb
One lock per resource, and inside it only work with that resource’s data.
Takeaway: lock is Monitor or Lock in try/finally, bound to a thread, so no await inside.
Under the hood
- Any object can act as a monitor. While there is no contention, the lock state lives in the object header (a thin lock).
- Under contention or waiting, the runtime “inflates” the lock into a sync block — an entry in an internal table.
Lockkeeps its state in its own field and does not touch the object header. The uncontended fast path is an atomic operation on that field.- Entering a lock keeps later reads from running before it, and exiting keeps earlier writes from being delayed past it. That is why fields accessed only under
lockdo not needvolatile.
Cost
- An uncontended
lockis cheap. A contended one is expensive: threads spin, then go to sleep, and latency grows. - A long queue for one lock (a lock convoy) turns parallel code into sequential code.
Trade-offs
Lockremoves the extraobjectand shows intent through the type, but it has noWaitorPulse. Code built onMonitor.Waithas to move to another primitive during migration.- Against contention: narrow the section, shard data with separate locks, or move to lock-free structures.
How to check in production
- In
dotnet-counters:dotnet.monitor.lock_contentions(before .NET 9 — Monitor Lock Contention Count), how many times a thread waited for a heldMonitor. - In a dump, the
syncblkcommand showsMonitorowners and the number of waiting threads. - Since .NET 9,
dotnet-tracerecords a WaitHandleWait event with a stack, including waits inlock.
Takeaway: Lock is the default choice in .NET 9+, and you find a bottleneck through the contention counter and wait stacks.
public sealed class Account{ private readonly Lock _sync = new(); // C# 13 / .NET 9+, earlier — new object() private decimal _balance;
public bool TryDebit(decimal amount) { lock (_sync) // the compiler emits using (_sync.EnterScope()) { if (_balance < amount) return false; _balance -= amount; return true; } }
public void Credit(decimal amount) { lock (_sync) { _balance += amount; } }
// object o = _sync; lock (o) { } — warning CS9216: this is Monitor now // lock (_sync) { await SaveAsync(); } — error CS1996: await inside lock is not allowed}- Why not
lock (this)? — Outside code can lock on the same object: extra contention or a deadlock. - What happens if an exception is thrown inside
lock? — The lock is released infinally, but the data may be left inconsistent. - How do you get mutual exclusion in async code? —
SemaphoreSlim(1, 1):await WaitAsync(), andRelease()infinally. - Is
lockreentrant? — Yes, with bothMonitorandLock: the same thread can enter again. - What changes if a field holding
new Lock()is declared asobject? —lockfalls back toMonitor, and the compiler reports CS9216.
What synchronization primitives does .NET have, and how do they differ?
Section titled “What synchronization primitives does .NET have, and how do they differ?”What it is
A synchronization primitive is a type that makes threads wait for each other according to a specific rule.
Why
lock solves one problem: one thread in a section. Limiting parallelism, separating reads from writes and signaling need other tools.
How to use
| Primitive | What it does |
|---|---|
Mutex |
like lock, but can work across processes |
SemaphoreSlim |
lets in no more than N threads at once |
ReaderWriterLockSlim |
many threads may read at once, only one may write |
ManualResetEventSlim |
threads wait in Wait() until someone calls Set() |
Barrier |
threads wait for each other at the end of each phase |
Common mistake
Not releasing a primitive when an exception is thrown. Always call Release(), ExitReadLock() and ReleaseMutex() in finally.
Takeaway: lock for mutual exclusion, a semaphore for limiting parallelism, events for signals.
How it works
Mutex,Semaphore,AutoResetEventandManualResetEventderive fromWaitHandleand wrap an OS kernel object.- A named
Mutexis visible to other processes on every OS; namedSemaphoreandEventWaitHandlework only on Windows. - Types with the
Slimsuffix avoid kernel objects as long as possible and work only within a process. - Semaphores have no owner:
Waiton one thread andReleaseon another is a normal scenario.
When to choose what
| Job | Primitive |
|---|---|
| A single app instance | a named Mutex |
At most N operations, with await |
SemaphoreSlim and WaitAsync |
| Frequent long reads, rare writes | ReaderWriterLockSlim |
| One thread opens the “gate” for many | ManualResetEventSlim |
| Wait for N signals | CountdownEvent |
| Computation phases across N threads | Barrier |
Pitfalls
- A thread exits without releasing a
Mutex— the next owner gets anAbandonedMutexException. - An extra
Releaseon a semaphore succeeds silently, and later another thread getsSemaphoreFullExceptionon its legitimateRelease. ReaderWriterLockSlimhas no recursion by default. You move from read to write only through upgradeable mode, and only one thread can be in it at a time.
Rule of thumb
Of this list only SemaphoreSlim works with await; the rest are for synchronous code.
Takeaway: pick the primitive by the job and by whether the code has await.
Under the hood
Monitorand theSlimtypes first spin in a loop (SpinWait), hoping the owner releases soon. Only then does the thread go into a kernel wait.- Kernel objects make a system call on every operation, even without contention.
SpinLockis a struct that only spins. A copy of aSpinLockis an independent lock, so never pass it by value.
Cost
- Spinning burns CPU and pays off only for very short waits.
ReaderWriterLockSlimcosts more to enter and exit thanlock. With short sections and frequent writes a plainlockis often faster.
Trade-offs
- For data that is almost only read, use an immutable snapshot and an atomic reference swap instead of a reader-writer lock. Readers then need no synchronization at all.
- Async reader-writer locks and events come from Microsoft.VisualStudio.Threading (
AsyncReaderWriterLock,AsyncManualResetEvent) or are built onTaskCompletionSource. SemaphoreSlimhas no owner and is not reentrant: a secondWaitAsyncin the same call chain waits for itself.- An abandoned
Mutexis a signal that the protected state may be corrupted.
How to check in production
- Since .NET 9,
dotnet-tracerecords a WaitHandleWait event when a thread blocks inlock,ManualResetEventSlim.WaitorSemaphoreSlim.Wait. The event stacks show who waits and where. - In a dump,
parallelstacksgroups threads stuck in the same wait.
Takeaway: the cheapest primitive is the one you managed to avoid; beyond that, choose by ownership, cross-process needs and load profile.
// SemaphoreSlim: at most 3 concurrent operations, works with awaitusing var gate = new SemaphoreSlim(3);await Task.WhenAll(Enumerable.Range(1, 10).Select(async i =>{ await gate.WaitAsync(); try { await Task.Delay(100); } finally { gate.Release(); } // no owner: any thread can release}));
// Barrier: three threads move through computation phases in lockstepusing var barrier = new Barrier(3, b => Console.WriteLine($"phase {b.CurrentPhaseNumber} done"));var threads = Enumerable.Range(0, 3).Select(_ => new Thread(() =>{ for (int phase = 0; phase < 2; phase++) { Thread.Sleep(Random.Shared.Next(50)); // compute our part barrier.SignalAndWait(); // wait for the others }})).ToList();threads.ForEach(t => t.Start());threads.ForEach(t => t.Join());// phase 0 done// phase 1 done- How does
Mutexdiffer fromlock? — It is a kernel object: slower, but it works across processes by name; an abandoned one throwsAbandonedMutexException. - Why is
SemaphoreSlim(1, 1)not a full replacement forlock? — It has no owner and no reentrancy: re-entering in the same call chain hangs. - When is
ReaderWriterLockSlimworse than a plainlock? — With short sections and frequent writes: entering and exiting cost more. - How does
CountdownEventdiffer fromBarrier? — WithCountdownEventyou wait for N signals; withBarrierN participants wait for each other, phase by phase. - How do you wait for a signal asynchronously? — With a
TaskCompletionSourceinstead of aManualResetEvent.
How do concurrent collections work, and when do you need them?
Section titled “How do concurrent collections work, and when do you need them?”What it is
Collections from System.Collections.Concurrent that are safe to modify from several threads at once.
Why
A plain Dictionary<TKey,TValue> can corrupt its internal structure under concurrent writes. Concurrent collections synchronize themselves.
How to use
| Collection | Order | Main methods |
|---|---|---|
ConcurrentDictionary |
by key | TryAdd, GetOrAdd, AddOrUpdate, TryRemove |
ConcurrentQueue |
FIFO | Enqueue, TryDequeue |
ConcurrentStack |
LIFO | Push, TryPop |
ConcurrentBag |
unordered | Add, TryTake |
Methods starting with Try return false if they fail: another thread may have got there first.
Common mistake
if (!dict.ContainsKey(key)) dict[key] = value; — two calls, and another thread can slip in between them. The right way is a single dict.TryAdd(key, value) call.
Takeaway: in multithreaded code, take a shared collection from System.Collections.Concurrent and change it in one call.
How it works
ConcurrentDictionaryis a bucket table plus an array of locks, each protecting a share of the buckets. A write takes one lock, a read takes none.- Enumeration is safe but it is not a snapshot: it may see changes made after it started.
ConcurrentQueueandConcurrentStackare built onInterlocked, without locks.ConcurrentBagstores items in per-thread lists.TryTakechecks its own list first, then takes from others.
When to choose what
| Job | Collection |
|---|---|
| A shared cache or index by key | ConcurrentDictionary |
| Work in arrival order | ConcurrentQueue |
| A thread adds and takes its own items (object pool) | ConcurrentBag |
Pitfalls
GetOrAddwith a factory. The delegate runs outside the lock. Two threads can both miss the key and both call the factory, but only one value is stored.AddOrUpdatemay call the update delegate again on a conflict. So it must be a pure function.ConcurrentQueuecannot wait for an item. ATryDequeueloop without a pause burns CPU.
Rule of thumb
Do a compound operation with one atomic method, and wrap a factory with side effects in Lazy<T>: dict.GetOrAdd(key, k => new Lazy<T>(() => Create(k))).Value. Lazy<T> is thread-safe by default and calls Create once.
Takeaway: a concurrent collection is thread-safe, but only each individual call is atomic.
Under the hood
Count,Keys,Values,ToArray,Clearand table growth onConcurrentDictionaryacquire all the locks.- The default number of locks depends on the processor count; the
concurrencyLevelconstructor parameter sets it. ConcurrentQueueis built from segments — ring buffers — and barely allocates in a steady state.ConcurrentStackallocates a node perPush, andConcurrentDictionarya node per entry.
Cost
- Frequent
Counton a hot path serializes writers. If you need a counter, keep it separately withInterlocked. ConcurrentBagloses as a queue between different producers and consumers: consumers keep taking from other threads’ lists.
Trade-offs
- Lookups and configuration that are almost only read are cheaper in a
FrozenDictionary(.NET 8+) or anImmutableDictionary, swapping the whole reference atomically. Reads then need no synchronization at all. - Two related collections will not stay consistent on their own: such an invariant needs an outer
lock. - Concurrent reads of a plain
Dictionarywith no writes are safe — no concurrent collection needed.
How to check in production
- A CPU profile (dotnet-trace, PerfView) shows time in
ConcurrentDictionaryinternal locking, for example inAcquireAllLocks. - In a dump,
dotnet-dump analyzeshows contents withdumpconcurrentdictionaryanddumpconcurrentqueue.
Takeaway: concurrent collections suit frequent writes from many threads; for rare writes and frequent reads, immutable snapshots are cheaper.
using System.Collections.Concurrent;
var cache = new ConcurrentDictionary<string, Lazy<Client>>();int created = 0;
Client Get(string key) => cache.GetOrAdd(key, k => new Lazy<Client>(() => { Interlocked.Increment(ref created); // inside Lazy the factory runs once return new Client(k); })).Value;
Parallel.For(0, 1000, _ => Get("billing"));Console.WriteLine(created); // 1
var counts = new ConcurrentDictionary<string, int>();Parallel.ForEach(new[] { "a", "b", "a" }, word => counts.AddOrUpdate(word, 1, (_, old) => old + 1)); // pure function: may be called againConsole.WriteLine(counts["a"]); // 2
record Client(string Name);Without Lazy, concurrent calls could run the factory several times, even though the dictionary would still keep only one value.
- Why can
GetOrAddcall the factory twice? — The delegate runs outside the lock so the lock is never held over foreign code; only one value is stored. - Is
Counton aConcurrentDictionaryexpensive? — Yes: it takes all internal locks; on a hot path keep a separate counter. - When is
ConcurrentBaga bad choice? — When producers and consumers are different threads: consumers keep taking from other threads’ lists. - Does enumerating a
ConcurrentDictionarygive a snapshot? — No: enumeration is safe but sees changes;ToArray()gives a snapshot. - Do you need concurrent collections if data is only read? — No: concurrent reads of plain collections with no writes are safe.
What are Interlocked, volatile and the .NET memory model?
Section titled “What are Interlocked, volatile and the .NET memory model?”What it is
Interlocked— a static class with atomic operations:Increment,Decrement,Add,Exchange,CompareExchange.volatile— a modifier for a field that other threads change.
Why
count++is a read, an add and a write. Another thread can slip in between them, and the increment is lost.- Without
volatilethe JIT may read a field once and keep the value in a register. Awhile (!_stop)loop then never sees_stop = true.
How to use
- A counter:
Interlocked.Increment(ref _count);. - A stop flag:
private volatile bool _stop;.
Common mistake
Thinking that volatile makes x++ safe. It does not merge the read and the write into one operation. Also, it cannot be applied to long and double.
Takeaway: Interlocked for atomic changes, volatile only for flag visibility.
How it works
- Every
Interlockedmethod is atomic and acts as a full fence: memory accesses do not move across it in either direction. - A volatile read (acquire) keeps later accesses from running before it.
- A volatile write (release) keeps earlier accesses from being delayed past it.
- The
volatilekeyword makes every access to a field volatile. TheVolatileclass (Volatile.Read,Volatile.Write) does it for individual accesses, includinglong,doubleand array elements.
When to choose what
| Job | Tool |
|---|---|
| A counter, a sum | Interlocked.Increment, Interlocked.Add |
| A “stop” flag | volatile or Volatile.Read and Volatile.Write |
| Your own atomic operation (maximum) | a CAS loop on CompareExchange |
| An invariant across several fields | lock |
Pitfalls
- Volatile does not mean “fresh”: a read is not guaranteed to return the latest value written by another processor.
- There is no single order of volatile writes that all threads agree on.
longanddoubleare not read atomically on a 32-bit platform. UseInterlocked.ReadorVolatile.Read.
Rule of thumb
A CAS loop goes like this:
- Read the current value.
- Compute the new one.
- Call
CompareExchange; if the value changed in the meantime, go back to step 1.
Takeaway: Interlocked gives atomicity and a full fence, volatile gives ordering only.
Under the hood
The .NET memory model is described in the Memory-model.md spec in the dotnet/runtime repository, and it is stronger than ECMA-335:
- ordinary accesses are reordered only if a single thread cannot tell;
- the JIT does not introduce new reads but may merge adjacent reads of one field — hence the hanging
while (!_flag); - speculative writes are not allowed;
- storing an object reference to shared memory is a release for its fields, so a published object is seen fully constructed;
- reads through a reference do not get ahead of reading the reference itself.
Cost
- On x64, volatile reads and writes are plain instructions: the CPU (a TSO model) reorders only a write with a later read.
- ARM64 has a weaker model, and volatile operations need instructions with barrier semantics.
Interlockedon a shared cache line is expensive: the line bounces between cores, and a hot shared counter scales poorly.
Trade-offs
- “Write my flag, check the other one” schemes need
InterlockedorInterlocked.MemoryBarrier: acquire/release does not forbid that reordering. - A hot counter is better sharded: one per thread or core, summed on read.
- False sharing: independent hot fields on one cache line slow each other down.
- Double-checked locking is correct in .NET, but
Lazy<T>andLazyInitializerread more simply.
How to check in production
- Code that is stable on x64 gets stress-tested on ARM64 (Graviton, Apple Silicon).
- Counter scalability is measured in BenchmarkDotNet with different thread counts.
Takeaway: correct code relies on the guarantees of the memory model, not on how x64 behaves.
public sealed class Stats{ private long _requests; // counter: only via Interlocked private int _maxLatencyMs; // maximum: CAS loop private volatile bool _stopped; // flag: volatile is enough
public void Record(int latencyMs) { Interlocked.Increment(ref _requests);
int current = Volatile.Read(ref _maxLatencyMs); while (latencyMs > current) { // write only if nobody changed the value since we read it int seen = Interlocked.CompareExchange(ref _maxLatencyMs, latencyMs, current); if (seen == current) break; current = seen; // someone beat us — try again } }
public long Requests => Interlocked.Read(ref _requests); // atomic even on 32-bit public void Stop() => _stopped = true; public bool IsStopped => _stopped;}- Why doesn’t
volatilemakex++safe? — It is three operations; volatile orders each of them but does not merge them into one atomic step. - Does a field accessed only under
lockneedvolatile? — No: entering and exiting the lock already order the accesses. - What is the ABA problem? — CAS sees the old value even though it was changed and changed back in between; it is real for counters and indexes.
- Why does code that worked on x64 break on ARM64? — ARM has a weaker memory model: reorderings that x64 never does become visible.
- What is false sharing? — Independent hot fields on one cache line: cores keep taking the line from each other.
How do you build producer-consumer: BlockingCollection or System.Threading.Channels?
Section titled “How do you build producer-consumer: BlockingCollection or System.Threading.Channels?”What it is
- Producer — creates work.
- Consumer — processes it.
- In between there is a thread-safe queue.
Why
To decouple fast intake from slow processing. For example, accept messages immediately and write them to the database at its own pace.
How to use
- Create a channel:
var channel = Channel.CreateBounded<int>(100);— at most 100 items. - The producer writes with
await channel.Writer.WriteAsync(item)and callschannel.Writer.Complete()at the end. - The consumer reads with
await foreach (var item in channel.Reader.ReadAllAsync()).
Common mistake
Forgetting Complete(). The consumer then waits for new items forever.
Takeaway: channels are the standard async queue between producers and consumers.
How it works
CreateUnbounded— a write always completes immediately, memory is unbounded.CreateBounded— when the channel is full, theFullModefromBoundedChannelOptionskicks in.- Channels support multiple producers and consumers. Consumers using
ReadAllAsyncsplit the items between them. BlockingCollection<T>is a synchronous wrapper overConcurrentQueue<T>(by default):Add,Take,CompleteAdding,GetConsumingEnumerable.
When to choose what
FullMode |
What happens when the channel is full |
|---|---|
Wait (default) |
WriteAsync waits for space, TryWrite returns false |
DropOldest |
the oldest item in the queue is dropped |
DropNewest |
the newest item in the queue is dropped |
DropWrite |
the item being written is dropped |
Pitfalls
- With several producers, call
Complete()only when all of them are done. ReadAsyncon a completed channel throwsChannelClosedException, whileReadAllAsyncsimply ends the enumeration.- Do not lose errors:
Writer.TryComplete(ex)stops writing and passes the exception toReader.Completion.
Rule of thumb
A channel is bounded by default; unbounded only if the source is bounded itself.
Takeaway: a channel is an async queue with an explicit bound and a completion protocol.
Under the hood
- Channel async methods return
ValueTask: if the operation completes synchronously, there is no allocation. SingleReader = trueandSingleWriter = trueare promises that let the channel pick a faster implementation.AllowSynchronousContinuations = truelets the reader’s continuation run right inside the writer’sWriteAsync.
Cost
- A waiting channel consumer does not occupy a thread. Every waiting
BlockingCollection.Takeholds a thread, and on the pool that quickly eats up free threads. - With a slow consumer, an unbounded channel turns latency into memory growth and then into an
OutOfMemoryException.
Trade-offs
Waitpropagates the slowdown back up the pipeline — to the source, which stops reading the socket or the broker.DropOldestsuits telemetry and “latest value” data. Drop modes also have anitemDroppedcallback.- Synchronous continuations reduce context switches, but the writer unexpectedly runs reader code and waits for it.
- For graphs with branching and built-in parallelism limits there is TPL Dataflow; channels are a lower-level building block.
How to check in production
- Queue length is the main signal:
Reader.Count, whenReader.CanCountistrue. - Growing
WriteAsynctime means the system is bound by the consumer. Reader.Completionfinishing with an exception in the logs shows that the pipeline stopped on an error.
Takeaway: the key decision in producer-consumer is the queue bound and what happens when you hit it.
using System.Threading.Channels;
// At most 100 items; when full, the producer waits (backpressure)var channel = Channel.CreateBounded<int>( new BoundedChannelOptions(100) { FullMode = BoundedChannelFullMode.Wait });
var producer = Task.Run(async () =>{ try { for (int i = 0; i < 1_000; i++) await channel.Writer.WriteAsync(i); // waits if the queue is full channel.Writer.Complete(); } catch (Exception ex) { channel.Writer.TryComplete(ex); } // the error reaches consumers});
// Three consumers split the items between themvar consumers = Enumerable.Range(0, 3).Select(_ => Task.Run(async () =>{ await foreach (int item in channel.Reader.ReadAllAsync()) await Task.Delay(1); // slow processing})).ToArray();
await Task.WhenAll(consumers.Append(producer));- What happens to an unbounded channel if the consumer is slower than the producer? — The queue grows without limit: memory, latency and eventually OOM.
- How does
DropOldestdiffer fromDropWrite? — The first drops the oldest item in the queue, the second drops the item being written. - How do you stop a pipeline on an error? —
Writer.TryComplete(ex): writing stops, andReader.Completioncompletes with that exception. - When is
BlockingCollectionstill appropriate? — In synchronous code with dedicated consumer threads, where blocking a thread does no harm. - What does
SingleReader = truegive you? — A promise that there is one reader, so the channel can pick a faster implementation.
What is thread pool starvation, and how do you diagnose it?
Section titled “What is thread pool starvation, and how do you diagnose it?”What it is
All pool threads are busy waiting, and new requests sit in the queue.
What it looks like
Under load the service responds in seconds instead of milliseconds. Meanwhile the CPU is barely loaded.
Why it happens
- A request runs on a pool thread.
- The code calls
httpClient.GetStringAsync(url).Result— the thread stops and waits for the response. - Under load, every pool thread ends up stuck like this.
- New requests wait in the queue while the pool slowly adds threads.
Common mistake
Treating the symptom: raising the minimum thread count instead of removing the blocking.
Takeaway: in code that runs on the pool, await all the way up, no .Result and no .Wait().
How it works
- Up to the minimum (
ThreadPool.GetMinThreads, by default the processor count) the pool creates threads immediately. - Above it — with a delay. In Microsoft’s walkthrough the thread count quickly reaches 2–3 per core and then grows by 1–2 per second.
- While threads are short, the queue and the latency of every request grow.
When to choose what
| Cause | Fix |
|---|---|
.Result, .Wait() on async code |
await along the whole chain |
| Synchronous I/O | async APIs (ReadAsync, SaveChangesAsync) |
| A third-party blocking API | a dedicated thread or LongRunning, with limited parallelism |
| Surviving a spike before the fix | SetMinThreads, temporarily |
Pitfalls
- The thread count has settled high — more than about three per core. That means code blocks threads and the pool compensates; on a spike or a restart, starvation comes back.
Thread.Sleepand a longlockin code on the pool also eat threads.
Rule of thumb
Starvation signs in dotnet-counters: the pool thread count rises slowly, the queue is long, little work completes, and CPU is well below 100%.
Takeaway: you fix starvation by removing blocking, not by adding threads.
Under the hood
- Hill climbing periodically changes the number of active threads and checks whether throughput — completed work items per second — went up.
- A separate mechanism adds a thread when there is a queue but no progress. Both show up in
ThreadPoolWorkerThreadAdjustmentevents with reasons such as Climbing move and Starvation. - Since .NET 6 the pool adds threads right away when a pool thread blocks waiting for a task. Without delay — up to
ThreadsToAddWithoutDelay_ProcCountFactortimes the processor count, then with a growing delay. - That mechanism covers only waiting for tasks (
Task.Wait,.Result), notlock, synchronous I/O orThread.Sleep.
Cost
- Compensation means hundreds of threads: stack memory, context switches, longer stack walks during garbage collection.
- A large
SetMinThreadspays the same overhead all the time, not only during a spike.
Trade-offs
The System.Threading.ThreadPool.Blocking.* and MinThreads settings buy time until the code is fixed. The real fix is async all the way down the stack and dedicated threads for unavoidable blocking.
How to check in production
dotnet-counters monitor -n MyApp. In .NET 9+ these are thedotnet.thread_pool.thread.count,dotnet.thread_pool.queue.lengthanddotnet.thread_pool.work_item.countmetrics; earlier — ThreadPool Thread Count, ThreadPool Queue Length and ThreadPool Completed Work Item Count.- A persistent problem:
dotnet-stack report -n MyAppor a dump withparallelstacks. Look for threads withWorkerThreadat the bottom of the stack andTask.WaitorGetResultat the top. - An intermittent problem:
dotnet-trace collect -n MyApp --clrevents waithandle --clreventlevel verbose(.NET 9+), then the WaitHandleWait stacks in PerfView.
Takeaway: counters show starvation, and the stacks of blocked pool threads show its cause.
var builder = WebApplication.CreateBuilder(args);builder.Services.AddHttpClient();var app = builder.Build();
// Bad: sync-over-async — a pool thread is stuck for the whole requestapp.MapGet("/bad", (IHttpClientFactory f) => f.CreateClient().GetStringAsync("https://example.com").Result.Length);
// Good: while waiting, the thread goes back to the poolapp.MapGet("/good", async (IHttpClientFactory f) => (await f.CreateClient().GetStringAsync("https://example.com")).Length);
app.Run();Under load /bad holds one thread per request and the pool thread count keeps growing; /good gets by with a handful of threads.
- Why is CPU low while requests are slow? — Threads are not computing but waiting; the work sits in the pool queue.
- Will
ThreadPool.SetMinThreads(500, 500)help? — It hides the symptom during spikes but adds memory and context switches; the blocking code stays. - What changed in .NET 6? — The pool adds threads faster when a thread blocks on
Task.Waitor.Result; starvation is shorter but does not go away. - How do you tell starvation from a deadlock? — With starvation the thread count grows and work moves slowly; with a deadlock threads sit on the same locks and there is no progress at all.
- How does
TaskCreationOptions.LongRunninghelp? — Long blocking work gets a dedicated thread and does not tie up the pool.