Language and Runtime Concurrency Models: C#
.NET puts a single, self-tuning thread pool at the centre: almost everything — Task.Run, async continuations, timers, I/O completions — runs on it.
Learning Objectives
Use Thread and ThreadPool; Task and Task<T>; async/await; Task.WhenAll/WhenAny; CancellationToken.
Synchronise with lock and Monitor (and .NET 9's System.Threading.Lock), SemaphoreSlim, Interlocked and Volatile.
Use ConcurrentDictionary, channels and concurrent collections; understand synchronization context, async streams and IAsyncDisposable; diagnose thread-pool starvation.
Plain-Language Explanation
.NET puts a single, self-tuning thread pool at the centre: almost everything — Task.Run, async continuations, timers, I/O completions — runs on it. async/await (which C# invented for the mainstream in 2012) keeps those pool threads busy with work instead of waiting. The main way to hurt a .NET service is to block pool threads.
Precise Technical Explanation
Thread and ThreadPool new Thread(...) for dedicated long-running threads (set IsBackground); ThreadPool.QueueUserWorkItem / Task.Run for everything else. The pool grows by hill-climbing and starvation detection — slowly (about one thread per 0.5–1 s beyond the minimum).
Task and Task<T> A promise-like handle (Module 9) that can represent CPU work on the pool (Task.Run), asynchronous I/O, or a manually completed operation (TaskCompletionSource). ValueTask<T> avoids allocation for often-synchronous completions.
async / await Compiler-generated state machines; continuations run on the captured SynchronizationContext if any, otherwise on the pool.
Task.WhenAll and Task.WhenAny Module 10 examples; WhenAll aggregates exceptions in Task.Exception; WhenAny returns the first completed task.
Module 14: cooperative cancellation passed to every async API.
lock and Monitor lock (obj) { } is Monitor.Enter/Exit in try/finally; re-entrant; Monitor.Wait/Pulse/PulseAll give condition-variable behavior..NET 9 / C# 13 add System.Threading.Lock — a dedicated lock type that lock statements use directly (shown below). You cannot await inside a lock block (compile error).
Counting semaphore with WaitAsync — the standard async-compatible mutual exclusion and concurrency limiter.
Interlocked and Volatile Interlocked.Increment/Add/Exchange/CompareExchange (full fences); Volatile.Read/Write (acquire/release).
ConcurrentDictionary, channels and concurrent collections ConcurrentDictionary (GetOrAdd, AddOrUpdate — value factories may run more than once under races!), ConcurrentQueue, ConcurrentBag, BlockingCollection, System.Threading.Channels (Module 8), immutable collections for snapshot publication.
WPF/WinForms/MAUI/old ASP.NET install a context that marshals continuations back to the UI/request thread; ASP.NET Core and console apps have none. ConfigureAwait(false) in libraries avoids capturing it.
IAsyncEnumerable<T> + await foreach + [EnumeratorCancellation] (Module 10).
await using calls DisposeAsync (Module 10).
Thread-pool starvation and diagnostics Blocking pool threads (.Result, .Wait(), Thread.Sleep, synchronous I/O) in async-heavy code exhausts the pool; the runtime injects threads slowly, so latency explodes (measured below). Diagnose with dotnet-counters (threadpool-thread-count, threadpool-queue-length), dotnet-trace, dotnet-stack, and the ThreadPoolStarvation event.
Key Terms
Queue CPU work to the thread pool Manually completed task (promise) Async-compatible lock/limiter Where continuations resume
Queued work waits because pool threads are blocked
Syntax and API Overview
Mutual exclusion (sync) lock, System.Threading.Lock (.NET 9+) Mutual exclusion (async) Task.Run, Parallel.For/ForEachAsync
Complete Code Example
Monitor.Wait/Pulse, the .NET 9 Lock type, async mutual exclusion with SemaphoreSlim, Interlocked, ConcurrentDictionary factory races, Parallel.ForEachAsync and thread-pool starvation (.NET 10; timings from one real run):
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
// 1. Monitor as a condition variable
var gate = new object();
bool ready = false;
var waiter = new Thread(() =>
{
lock (gate)
{
while (!ready) Monitor.Wait(gate); // releases gate while waiting
Console.WriteLine("Monitor.Wait woke up: ready = true");
}
});
waiter.Start();
Thread.Sleep(20);
lock (gate) { ready = true; Monitor.PulseAll(gate); }
waiter.Join();
// 2. System.Threading.Lock (.NET 9+) with the lock statement
var counterLock = new Lock();
int counter = 0;
Parallel.For(0, 100_000, _ => { lock (counterLock) counter++; });
Console.WriteLine($"System.Threading.Lock counter: {counter}");
// 3. async mutual exclusion: lock cannot contain await, SemaphoreSlim can
var mutex = new SemaphoreSlim(1, 1);
int inside = 0, peak = 0;
await Task.WhenAll(Enumerable.Range(0, 20).Select(async _ =>
{
await mutex.WaitAsync();
try
{
peak = Math.Max(peak, Interlocked.Increment(ref inside));
await Task.Delay(5); // awaiting while holding the async lock is
…Monitor.Wait woke up: ready = true
System.Threading.Lock counter: 100000
SemaphoreSlim(1,1): peak inside = 1
GetOrAdd from 8 threads: 1 entry, factory ran more than once -> use Lazy<T> values for exactly-once
Parallel.ForEachAsync(MaxDegreeOfParallelism = 4): max concurrent = 4
min pool threads: 8; a tiny task queued behind 24 blocking tasks started after ~2000 msLine-by-Line Explanation
Monitor.Wait must be called while holding gate, in a while loop; PulseAll wakes waiters, which reacquire the lock.
new Lock() (.NET 9+) is a dedicated lock object; the lock statement recognises it and calls EnterScope().
lock blocks cannot contain await; SemaphoreSlim(1,1) with WaitAsync provides async mutual exclusion — the peak is 1.
GetOrAdd stores exactly one value per key, but concurrent callers may each run the factory; wrapping values in Lazy<T> makes creation exactly-once.
Parallel.ForEachAsync caps in-flight async work at 4.
Blocking 3 × min pool threads with Thread.Sleep makes a trivial task wait until the pool injects extra threads or the blockers finish — starvation. The tasks are queued with PreferFairness so they go to the pool's global FIFO queue; plain Task.Run from code already running on a pool thread uses that thread's local LIFO queue, where the newest task (the probe) would jump the line — itself a useful detail when reasoning about pool scheduling.
Output
Shown above; counts are deterministic, timings approximate.
Interleaving or State Trace
Starvation: all minWorkers threads pick blocking tasks; the queue holds more blockers and the probe; the pool's starvation logic adds threads gradually; the probe runs only when a thread becomes free.
Correctness and Safety Analysis
Synchronous state uses lock/Monitor; async critical sections use SemaphoreSlim; counters use Interlocked; dictionary values that must be created once use Lazy<T>; nothing blocks on async work except the deliberate starvation demonstration.
Common Mistakes and Misconceptions
.Result/.Wait() on async methods (starvation, UI deadlocks).
async void outside event handlers.
lock around await (not allowed) — or a SemaphoreSlim without try/finally.
Assuming GetOrAdd's factory runs once.
Raising ThreadPool.SetMinThreads instead of removing blocking.
Failure Modes and Debugging Steps
dotnet-counters monitor --counters System.Runtime (thread-pool queue length rising, thread count climbing slowly → starvation); dotnet-stack report -p PID shows threads blocked in Task.Wait/Monitor.Wait; Visual Studio Parallel Stacks for tasks.
Performance and Scalability Implications
Async I/O frees pool threads, so a handful can serve thousands of requests; blocking undoes that. Interlocked and ConcurrentDictionary scale well; the new Lock type is slightly faster than locking on object.
Security Considerations
AsyncLocal values flow into every continuation — avoid storing mutable per-request objects in statics; limit concurrency of user-triggered work (rate limiting middleware, SemaphoreSlim).
Platform and Runtime Caveats
System.Threading.Lock: .NET 9+ (C# 13). Parallel.ForEachAsync: .NET 6+. Task.WaitAsync: .NET 6+.
UI frameworks have a SynchronizationContext; ASP.NET Core does not.
Real-World Applications
ASP.NET Core servers, Azure SDKs, Unity (with its own main-thread rules), WPF/WinUI apps, Orleans actors.
Best Practices
Async all the way; never block on tasks.
SemaphoreSlim for async locking; lock/Lock for short synchronous sections.
ConfigureAwait(false) in libraries.
Lazy<T> for exactly-once creation in concurrent dictionaries.
Watch thread-pool counters in production.
Exercises
E1 · Beginner. Why can't you await inside a lock block?
Answer: Monitor locks are owned by a thread; after an await, the method may resume on a different thread, which could not release the lock (and holding a lock across a suspension invites deadlocks). The compiler forbids it.
E2 · Intermediate. An ASP.NET Core endpoint calls httpClient.GetStringAsync(url).Result. Under load, latency jumps to seconds. Why?
Answer: Each request blocks a pool thread while waiting; with many concurrent requests the pool is exhausted and grows slowly, so queued work (including the continuations that would complete the HTTP calls) waits — thread-pool starvation. Make the endpoint async and await the call.
E3 · Advanced challenge. Implement an async "single-flight" cache: concurrent callers for the same key share one in-flight load.
Answer: ConcurrentDictionary<string, Lazy<Task<T>>>; GetOrAdd(key, k => new Lazy<Task<T>>(() => LoadAsync(k))); await lazy.Value. On failure, remove the entry so later calls retry. Lazy<T> (thread-safe by default) ensures LoadAsync starts once.
Quiz
? Which primitive provides mutual exclusion around code containing await?
+ SemaphoreSlim with WaitAsync :: Async-compatible.
- lock :: await is not allowed inside lock.
- Monitor.Enter :: Thread-affine; same problem.
- Interlocked :: Only atomic single operations.
? What does ConfigureAwait(false) change?
+ The continuation does not resume on the captured SynchronizationContext. :: It resumes on the thread pool.
- It disables the await. :: The await still happens.
- It makes the task run in parallel. :: Unrelated.
- It cancels the task on timeout. :: Unrelated.
? Which cause thread-pool starvation? (select all that apply)
+ task.Result inside many concurrent requests :: Blocks pool threads.
+ Thread.Sleep inside Task.Run work items :: Holds pool threads idle.
- await Task.Delay :: Frees the thread.
+ Synchronous file or socket I/O on pool threads under load :: Blocking.Summary
.NET centres on a shared, slowly growing thread pool, Task, and async/await. Use lock/Lock for short synchronous sections, SemaphoreSlim for async ones, Interlocked/Volatile for atomics, concurrent collections and channels for sharing, cancellation tokens everywhere — and never block pool threads.
Further Reading
Stephen Toub, "How Async/Await Really Works in C#"; "Performance Improvements in .NET 9" (Lock type).
Microsoft Docs: "Managed threading best practices", "Debug ThreadPool starvation".
Stephen Cleary, *Concurrency in C# Cookbook*, 2nd ed.
Joe Duffy, *Concurrent Programming on Windows*.