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 ms

Line-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*.