Threading, the TPL and Synchronization
Multithreading in .NET without the foot-guns: the thread pool and Task Parallel Library, locks, Interlocked and concurrent collections, avoiding deadlocks, and producer/consumer pipelines with Channels.
🎯 What you'll learn
- Thread pool, Tasks and which tool fits which job
- Race conditions and synchronization primitives
- Avoiding deadlocks with lock ordering
- Bounded producer/consumer pipelines with Channels
📚 Before you start
1Threads, the Thread Pool and Tasks
Creating OS threads is expensive (about 1 MB of stack each, kernel scheduling). .NET keeps a thread pool of worker threads and reuses them; the Task Parallel Library (TPL) schedules Tasks onto it. You almost never create Thread objects directly.
| Need | Tool |
|---|---|
| Wait for I/O without blocking | async/await |
| CPU-bound work in parallel over a collection | Parallel.For/ForEach, PLINQ (AsParallel()) |
| Bounded concurrent async work | Parallel.ForEachAsync(items, new ParallelOptions { MaxDegreeOfParallelism = 8 }, ...) |
| Offload CPU work from a UI thread | Task.Run |
| Producer/consumer pipelines | System.Threading.Channels |
| Long-running background loops in a service | BackgroundService (hosted service) |
The pool grows slowly when threads are blocked (roughly one new thread per second or so beyond the minimum), which is why blocking calls in ASP.NET Core cause thread pool starvation: requests queue while the pool slowly adds threads.
2Race Conditions and Synchronization Primitives
Shared mutable state accessed by several threads needs synchronization:
lock (obj)(Monitor): mutual exclusion for short critical sections; can't containawait. .NET 9 addsSystem.Threading.Lock.Interlocked.Increment/CompareExchange: atomic operations on single variables, lock-free.SemaphoreSlim: limit concurrency, andWaitAsyncworks as an async lock.ReaderWriterLockSlim: many readers or one writer.volatile/Volatile.Read: visibility only, not atomicity of compound operations.- Concurrent collections:
ConcurrentDictionary,ConcurrentQueue,ConcurrentBag,BlockingCollection.
The best synchronization is none: prefer immutability, message passing (channels), and confining state to one thread or one actor.
3Deadlocks
Two threads each hold a lock the other needs. Avoid by acquiring locks in a consistent global order (e.g. by account ID), keeping critical sections small, never calling unknown code (events, callbacks) while holding a lock, and using timeouts (Monitor.TryEnter). Sync-over-async (.Result) is a deadlock source too.
4Channels: Producer/Consumer
var channel = Channel.CreateBounded<Order>(new BoundedChannelOptions(100) { FullMode = BoundedChannelFullMode.Wait });
// producer
await channel.Writer.WriteAsync(order, ct); // waits when full: backpressure
channel.Writer.Complete();
// consumers
await foreach (var o in channel.Reader.ReadAllAsync(ct)) await HandleAsync(o);Bounded channels give backpressure (producers slow down when consumers fall behind), and several consumer tasks give parallelism with a fixed degree.
5Parallelism Done Right
- Parallelize CPU-bound work; for I/O, use async concurrency instead.
- Partition work so threads don't contend on shared state; aggregate per-thread results at the end (
Parallel.Forwith thread-local state). - Measure: parallel overhead and contention can make code slower than sequential.
⚠️ Common Pitfalls
count++from multiple threads: a read-modify-write race; useInterlocked.- Locking on
this, types or strings: other code can lock the same object. - Blocking thread pool threads with
.Result,Thread.Sleepor sync I/O in servers. - Inconsistent lock ordering: deadlocks under load.
Parallel.ForEachwith async lambdas: it doesn't await them; useParallel.ForEachAsync.
📋 Quick Reference
thread pool + Task · async for I/O · Parallel/PLINQ for CPU · Parallel.ForEachAsync for bounded async
lock (short, no await) · Interlocked (atomic) · SemaphoreSlim (limit/async lock) · concurrent collections
deadlocks: consistent lock order, small critical sections, no callbacks under locks
Channels: bounded = backpressure · BackgroundService for long-running loops · measure parallel speedups🧠 Key takeaways
- Use async for I/O, Parallel/PLINQ for CPU, Parallel.ForEachAsync for bounded async work.
- Protect shared state with Interlocked, lock, SemaphoreSlim or concurrent collections, or avoid sharing.
- Prevent deadlocks with a consistent lock order and small critical sections.
- Bounded Channels give producer/consumer pipelines with backpressure.
🎤 Interview Q&A
Q1Thread vs Task vs async/await?core▾
A Thread is an OS thread (expensive). A Task represents work or a future result, usually run on the thread pool. async/await composes tasks without blocking threads during waits. Prefer async for I/O and Parallel/Task.Run for CPU-bound work.
- Should you create threads manually? Rarely; long-running dedicated loops might use TaskCreationOptions.LongRunning or BackgroundService.
Q2What causes thread pool starvation?core▾
Blocking pool threads (sync-over-async, Thread.Sleep, synchronous I/O) faster than the pool injects new threads; requests queue and latency explodes even with low CPU.
- How do you detect it? dotnet-counters thread pool queue length and thread count growing.
Q3Which synchronization primitive would you use when?core▾
Interlocked for single-variable atomic updates; lock for short critical sections; SemaphoreSlim to limit concurrency or as an async lock; ReaderWriterLockSlim for read-mostly data; concurrent collections for shared maps/queues.
- Why not lock(this)? External code can lock the same object.
Q4How do you avoid deadlocks?senior▾
Acquire locks in a consistent global order, hold them briefly, don't call external code or await while holding locks, use timeouts, and avoid sync-over-async.
- How do you diagnose one? A dump with dotnet-dump/Visual Studio showing threads waiting on each other's locks.
Q5When would you use System.Threading.Channels?core▾
For in-process producer/consumer pipelines: bounded channels provide backpressure, multiple consumers give controlled parallelism, and async APIs integrate with hosted services.
- Channels vs BlockingCollection? Channels are async-first and faster.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.
count++ unsafe across threads?await inside lock?