Threading in .NET: the mental model
The hard part of threading in .NET isn’t the APIs, it’s the mental model. Here’s the way I explain it to teammates when we’re working on APIs and background services.
1. Threads vs tasks
Threads are OS-level workers. Tasks are logical units of work. A single thread can execute many tasks over time as they await I/O.
2. Async/await frees threads during I/O
In backend systems, most latency is I/O: HTTP calls, queues, SQL. Using
async/await lets threads do other work while waiting, which increases throughput
without “more threads”.
3. Avoid blocking on async
I avoid patterns like .Result, .Wait(), and Task.Run in
ASP.NET:
- They can deadlock or starve the thread pool.
- They make behavior under load unpredictable.
Once a pipeline is async, keep it async all the way down.
4. Concurrency vs parallelism
I treat them differently:
- Concurrency: multiple things in flight at once (e.g., multiple HTTP requests).
- Parallelism: splitting CPU-bound work across cores.
For CPU-bound jobs I’ll use Parallel.ForEachAsync or channels + worker tasks
with a clear degree-of-parallelism.
5. Shared state is the real enemy
Threading bugs almost always come from shared state. To avoid them:
- Prefer immutable data passed between tasks.
- Keep shared mutable state behind a single owner (e.g., a worker loop).
- Use locks sparingly and around tiny critical sections.