Engineering Notes
Home/Languages/C#/Threading, the TPL and Synchronization
Languages · C#

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.

💻 Languages● Advanced⏱ 3 min read🛠️ 2 exercises🎤 5 interview Q&As

🎯 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

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.

NeedTool
Wait for I/O without blockingasync/await
CPU-bound work in parallel over a collectionParallel.For/ForEach, PLINQ (AsParallel())
Bounded concurrent async workParallel.ForEachAsync(items, new ParallelOptions { MaxDegreeOfParallelism = 8 }, ...)
Offload CPU work from a UI threadTask.Run
Producer/consumer pipelinesSystem.Threading.Channels
Long-running background loops in a serviceBackgroundService (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 contain await. .NET 9 adds System.Threading.Lock.
  • Interlocked.Increment/CompareExchange: atomic operations on single variables, lock-free.
  • SemaphoreSlim: limit concurrency, and WaitAsync works 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

csharp
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.For with thread-local state).
  • Measure: parallel overhead and contention can make code slower than sequential.

⚠️ Common Pitfalls

  1. count++ from multiple threads: a read-modify-write race; use Interlocked.
  2. Locking on this, types or strings: other code can lock the same object.
  3. Blocking thread pool threads with .Result, Thread.Sleep or sync I/O in servers.
  4. Inconsistent lock ordering: deadlocks under load.
  5. Parallel.ForEach with async lambdas: it doesn't await them; use Parallel.ForEachAsync.

📋 Quick Reference

text
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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

1. Why is count++ unsafe across threads?
Use Interlocked.
2. Two transfers lock accounts in opposite orders. Risk?
Order locks consistently.
3. What does a bounded channel give you?
Bounded buffer.
4. Can you await inside lock?
Monitor is thread-affine.