C# Async Academy
from await to the hardware
A hands-on tour of asynchronous programming in C# and .NET: what async and await really do, what the CPU, operating system and network card are doing while you "wait", how the thread pool reacts, and — just as important — when async is the wrong tool.
Why asynchronous code matters
Most programs spend far more time waiting — for a database, a web API, a file, a timer, a user — than computing. Async code lets a thread do other useful work during that wait instead of standing still.
🍽️ The restaurant analogy
A waiter is a thread. The kitchen is the disk, network or database. A blocking waiter stands at the kitchen door until the food is ready. An async waiter hands over a ticket and serves other tables until the bell rings.
🧭 Map it to code
- Waiter
- Thread (a thread-pool thread or the UI thread)
- Kitchen
- Hardware & remote systems: SSD, network card, database server
- Ticket
Task— a promise of a result later- Bell
- Completion signal: interrupt → OS completion queue → thread pool
- "Serve the dish"
- The continuation — your code after
await
Key idea: async is about not wasting threads while waiting. It does not make a single operation faster — the database still takes 20 ms — but one server can handle far more simultaneous requests, and a desktop app stays responsive.
🌐 Web servers
ASP.NET Core serves thousands of concurrent requests with a few dozen threads because each request releases its thread while it waits for I/O.
🖥️ Desktop & mobile
WPF, WinForms and .NET MAUI have one UI thread. Block it for 200 ms and the window freezes; await instead and it keeps painting.
☁️ Cloud cost
Fewer blocked threads means less memory (each thread reserves stack space, often 1 MB on 64-bit Windows) and fewer machines for the same traffic.
The big picture: eight layers under one await
When you write await http.GetStringAsync(url) you touch every layer from the C# compiler down to a network card. Tap a layer to see what it does, how big it is, who owns it, and how much you control.
Tap a layer
Each layer hides the complexity of the one below it.
Your code = things you write and decide. Runtime / OS = machinery decided for you.
Scale view: how long is "waiting"?
Bars use a logarithmic scale — each step right is roughly ten times longer. Switch to human scale, where 1 nanosecond becomes 1 second, to feel why parking a thread during I/O is so wasteful. Figures are typical and rounded; they vary by hardware, OS and setup.
CPU-bound work (compressing, resizing images, parsing a huge JSON) sits on the left: the core is genuinely busy. Async/await doesn't help it go faster — you need parallelism or offloading.
I/O-bound work (network, disk, database, timers) sits on the right: the core is idle while hardware or a remote machine works. This is exactly where async shines.
Core concepts, one at a time
Eight ideas cover 90% of everyday async C#. Each tab has a tiny hands-on demo.
📦 Task = a promise for later
A Task (System.Threading.Tasks.Task) is an object representing an operation that may not be finished yet. Task<T> also carries a result of type T. A Task is not a thread — it's a status board.
- Running / waiting — not finished (states like
WaitingForActivation) - RanToCompletion — finished;
Resultis ready - Faulted — an exception was stored inside
- Canceled — stopped via a
CancellationToken
Try it
Drive a Task through its lifecycle.
Awaiting this task now would pause the caller until it finishes.
⏸️ await = "pause me, free the thread"
async on a method lets you use await inside it (and makes the compiler wrap the result in a Task). await checks: is the awaited Task finished? If yes, keep going. If not, register the rest of the method as a callback, return an incomplete Task to the caller, and give the thread back.
Async is contagious ("async all the way"): callers usually await too, up to the framework entry point (a controller action, an event handler, async Task Main).
Try it
Watch which thread runs each line (simulated thread IDs).
⚡ Already done? No pause at all
If the awaited operation has already completed (a cache hit, data already buffered), await doesn't suspend. The method keeps running on the same thread with no callback registration and, often, no allocation. This "synchronous fast path" is why async code can be nearly as cheap as sync code.
Try it
🔗 Continuation = "the rest of the method"
A continuation is the code after an await, packaged as a callback. Where does it run?
- UI apps (WPF, WinForms, MAUI): back on the UI thread, via the captured SynchronizationContext.
- ASP.NET Core & console apps: on any thread-pool thread — there is no SynchronizationContext.
- With
ConfigureAwait(false): on the thread pool, ignoring any context.
The old way was task.ContinueWith(...). Prefer await: it's easier to read, flows exceptions naturally and respects the context.
Try it
💥 Exceptions travel inside the Task
An exception thrown in an async method doesn't crash the caller immediately — it's stored in the Task (status Faulted). await rethrows the original exception with its stack trace. Blocking with .Wait() or .Result wraps it in an AggregateException instead (.GetAwaiter().GetResult() doesn't wrap, but still blocks).
With Task.WhenAll, await rethrows the first exception; all of them remain in task.Exception.InnerExceptions.
Try it
⚠️ async void: fire, forget, crash
async void methods return nothing to await, so the caller can't know when they finish or catch their errors. An unhandled exception is raised on the SynchronizationContext (or thread pool) and usually terminates the process.
Only use async void for event handlers (button.Click += async (s, e) => …) and wrap the body in try/catch. Everything else should return Task.
Try it
🫥 "There is no thread"
A famous line by Stephen Cleary: during a truly asynchronous I/O operation no thread is waiting for it. The request sits in the OS and the device; a thread is only borrowed briefly at the end to run your continuation.
Exceptions: Task.Run work (uses a thread by design), and some "async" APIs that internally do blocking work on the pool — for example regular-file reads on Linux, which epoll can't wait on.
Try it
Start 1,000 simultaneous downloads and count threads that are waiting.
| Return type | Use it for | Notes |
|---|---|---|
Task | Async work with no result | The default. Await-able many times, works with WhenAll/WhenAny. |
Task<T> | Async work producing a T | The default for results. |
ValueTask / ValueTask<T> | Hot paths that usually complete synchronously | Can avoid allocations. Await only once. See Chapter 11. |
IAsyncEnumerable<T> | Streams of items arriving over time | Consume with await foreach. C# 8 / .NET Core 3.0+. |
void | Event handlers only | Can't be awaited; exceptions can crash the process. |
| Custom "task-like" types | Libraries with special needs | Any type with [AsyncMethodBuilder]; rare in app code. |
async Task Main | Console entry points | Since C# 7.1; top-level statements can use await directly. |
What happens in the hardware during await
Follow one socket read from your code through the kernel to the network card and back. Windows uses a completion model (I/O Completion Ports, IOCP). Linux .NET uses a readiness model (epoll). Step through both.
await socket.ReceiveAsync(buffer) on its journey.Glossary for this trip
- DMA
- Direct Memory Access: the device copies bytes into RAM without the CPU.
- Interrupt
- A hardware signal telling the CPU "something finished".
- IRP
- I/O Request Packet — Windows' record of a pending I/O.
- IOCP
- I/O Completion Port — a kernel queue of finished operations.
- epoll
- Linux API that reports which sockets are ready to read/write.
Completion vs readiness: IOCP says "your read is done, bytes are in your buffer". epoll says "this socket is ready, go read it now". Either way, no thread sits blocked during the wait.
What's changing (2026): an io_uring socket engine for Linux — a completion model with fewer system calls — has been proposed for .NET as an opt-in experiment. epoll remains the default in .NET 10.
Files are different: on Windows, FileStream opened for async uses overlapped I/O with IOCP. On Linux, regular files are always "ready" to epoll, so .NET performs async file reads as normal reads on a thread-pool thread. They still don't block your thread, but they do occupy a pool thread.
The compiler's trick: the async state machine
The CPU has no idea what "await" means. The C# compiler rewrites your method into a state machine: a struct with a state field, your locals as fields, and a MoveNext() method that can resume from where it left off. Step through it.
GetLengthAsync(url).Inside the state machine
Show the (simplified) code the compiler generates
.NET 11 "runtime async": instead of the compiler emitting this state machine, the runtime itself understands async methods (marked with MethodImplOptions.Async) and suspends/resumes them. Benefits reported so far: cleaner stack traces, better debugging and fewer allocations. In .NET 10 it was experimental behind a switch; in .NET 11 (GA expected November 2026) CoreCLR support is on and the core libraries are compiled with it. Your C# source does not change.
The thread pool under load
The .NET thread pool runs continuations, Task.Run work and timers on a small set of reusable worker threads — by default it starts with about one per CPU core. Feed it requests and watch what healthy async, blocking (.Result) and CPU-heavy code do to it.
Model: 4 cores, ~150 ms of I/O per request, ~3 ms of CPU. Starvation response follows Microsoft's documented behaviour: a quick burst up to a few × the core count, then roughly 1–2 new threads per second. Simplified for teaching.
✅ Healthy async
Threads only touch each request for a few milliseconds. Four threads easily handle hundreds of requests per second; CPU stays low.
❌ Starvation
Each .Result parks a thread and its inner continuation needs another thread. The queue grows, latency explodes, yet CPU stays low — the classic signature.
🔥 CPU-bound
When cores are saturated, more threads don't help — they just take turns. That's a capacity problem, not an async problem.
SynchronizationContext and the classic deadlock
UI frameworks have a SynchronizationContext that sends continuations back to the single UI thread. Block that thread while waiting for a continuation that needs it, and nothing can ever move. Pick a strategy for a WPF button click.
✅ Rules that prevent it
- Don't block on async code: no
.Result,.Wait(),.GetAwaiter().GetResult()on UI or legacy ASP.NET threads. - In reusable libraries, use
ConfigureAwait(false)— they may run under a UI context. - In UI code, keep the default so you return to the UI thread.
🌐 What about ASP.NET Core?
ASP.NET Core has no SynchronizationContext, so this exact deadlock can't happen there and ConfigureAwait(false) changes nothing in app code. Blocking is still harmful: it causes thread-pool starvation (Chapter 7) instead.
When to use async — and when not to
Async/await is a tool for waiting efficiently. Parallelism is a tool for computing faster on several cores. Mixing them up is the root of most performance bugs. Walk the decision tree.
Quick rules
- 🌐 Network, database, file, timer → await the async API.
- 🖥️ Heavy CPU on a UI thread →
await Task.Run(...)to keep the UI alive. - 🌍 Heavy CPU in ASP.NET Core → just run it;
Task.Runonly swaps one pool thread for another. - 📊 Same CPU work on many items →
Parallel.For/ForEachor PLINQ. - 🔁 Many I/O calls with a limit →
Parallel.ForEachAsyncorSemaphoreSlim. - ⚡ Tiny, always-fast operations → stay synchronous.
✅ Great fits for async
- Web APIs and microservice calls (
HttpClient) - Database queries (EF Core
ToListAsync, DapperQueryAsync) - Cloud SDKs: blob storage, queues, Cosmos DB
- Reading request bodies, streaming responses
- Waiting on timers (
Task.Delay,PeriodicTimer) - UI event handlers that load data
- Real-time: SignalR, WebSockets, gRPC streams
❌ Poor fits (or anti-patterns)
- Pure computation (math, image filters) — async adds overhead, no speed
- Wrapping sync code in
Task.Runinside a library and calling it "Async" ("async over sync") - Very hot, tiny methods where state-machine overhead shows up in profiling
lock-protected sections (you can'tawaitinsidelock)- Constructors and property getters (can't be async — use factories)
- Code that must finish in strict real-time — use dedicated threads
| Workload | Best tool | Why |
|---|---|---|
| Call 1 REST API | await http.GetAsync() | Thread released during network wait |
| Call 5 independent APIs | await Task.WhenAll(...) | Waits overlap; total ≈ slowest call |
| Call an API for 10,000 items | Parallel.ForEachAsync (MaxDegreeOfParallelism) | Concurrency with a limit; avoids overwhelming the server |
| Resize 500 images | Parallel.ForEach / PLINQ | CPU-bound: uses all cores |
| Long CPU job in a WPF app | await Task.Run(() => Work()) | Keeps UI thread free |
| Long CPU job in a web request | Queue to a BackgroundService via Channel<T> | Returns fast; work happens off the request path |
| Process a stream of messages | await foreach + IAsyncEnumerable<T> | Items handled as they arrive |
| Thread that runs for hours (legacy blocking API) | new Thread(...) or TaskCreationOptions.LongRunning | Don't tie up a pool thread forever |
Running many tasks: combinators, throttling, cancellation
Six API calls with different durations. Compare running them one by one, all at once, with a concurrency limit, racing them, or processing results as they finish. Bars are drawn to scale.
🛑 Cancellation & timeouts
Cancellation in .NET is cooperative: a CancellationTokenSource raises a flag, and code that received its CancellationToken checks it or passes it on to I/O APIs, which then abort and throw OperationCanceledException.
Try it: download with a timeout
The simulated download needs 4 seconds.
Advanced APIs and internals
The power tools. You won't need all of them daily, but knowing they exist — and their sharp edges — separates "it works" from "it scales". First, how the platform got here.
| Release | Year | Async milestones |
|---|---|---|
| C# 5 / .NET Framework 4.5 | 2012 | async/await, Task-based Asynchronous Pattern (TAP) |
| C# 7 – 7.1 | 2017 | ValueTask<T>, custom task-like return types, async Task Main |
| C# 8 / .NET Core 3.0 | 2019 | IAsyncEnumerable<T>, await foreach, await using, Channels in-box |
| .NET 6 | 2021 | Parallel.ForEachAsync, Task.WaitAsync, PeriodicTimer, improved starvation heuristics |
| .NET 7 | 2022 | Windows I/O completions handled by the managed (portable) thread pool |
| .NET 8 (LTS) | 2023 | ConfigureAwaitOptions, CancellationTokenSource.CancelAsync, TimeProvider |
| .NET 9 / C# 13 | 2024 | Task.WhenEach, System.Threading.Lock, ref locals & Span<T> in async methods |
| .NET 10 (LTS) / C# 14 | Nov 2025 | Runtime async available as an experiment; current LTS release |
| .NET 11 / C# 15 | Nov 2026 (RC now) | Runtime async on in CoreCLR, framework libraries compiled with it; opt-in for your projects |
Status as of October 2026. Preview-era details for .NET 11 can still change; check the official release notes.
ValueTask<T>
A struct that holds either a ready result or a real Task (or a pooled IValueTaskSource). When a method usually completes synchronously, returning ValueTask<T> avoids allocating a Task each call.
- ✅ Use for hot paths that mostly finish synchronously (caches, buffered readers).
- ❌ Await it only once; never call
.Resultbefore it completes; never await concurrently. - Need WhenAll or multiple awaits? Call
.AsTask()once. - Microsoft's guidance: default to
Task; switch only when profiling shows it matters. Analyzer CA2012 flags misuse.
ConfigureAwait
ConfigureAwait(false) says: "I don't need to resume on the captured SynchronizationContext/TaskScheduler." It doesn't change results or exceptions.
- Library code (NuGet packages, shared layers): use
ConfigureAwait(false)on every await — you don't know who calls you. - UI code: don't, if the code after await touches controls.
- ASP.NET Core app code: unnecessary (no context). The ASP.NET Core team itself dropped it.
- .NET 8+
ConfigureAwaitOptions:SuppressThrowing(wait without throwing),ForceYielding(always yield, even if completed),ContinueOnCapturedContext.
SynchronizationContext & TaskScheduler
When you await, the awaiter captures SynchronizationContext.Current (or, if none, a non-default TaskScheduler.Current) and posts the continuation there.
| App model | Context | Effect |
|---|---|---|
| WPF | DispatcherSynchronizationContext | Back to the UI thread |
| WinForms | WindowsFormsSynchronizationContext | Back to the UI thread |
| .NET MAUI | Platform main-thread context | Back to the main thread |
| Classic ASP.NET | AspNetSynchronizationContext | One thread at a time per request |
| ASP.NET Core / console / workers | none (null) | Any thread-pool thread, possibly in parallel |
| xUnit (v2) | Custom context | Limits concurrency in tests |
ExecutionContext & AsyncLocal<T>
ExecutionContext is ambient data that always flows across awaits (unlike SynchronizationContext, which you can opt out of). It carries culture, security principal and AsyncLocal<T> values — the mechanism behind logging scopes, Activity.Current tracing and IHttpContextAccessor.
- Values flow down into awaited/child calls, not back up to the caller.
- Keep AsyncLocal values small and immutable; each change copies the context.
ExecutionContext.SuppressFlow()exists for rare low-level cases.
IAsyncEnumerable<T> & await foreach
An async stream: each MoveNextAsync() may wait. Perfect for paging through an API, reading lines from a socket, EF Core AsAsyncEnumerable() or streaming JSON responses in ASP.NET Core.
- Write one with
async IAsyncEnumerable<T>+yield return. - Pass cancellation with
[EnumeratorCancellation]and.WithCancellation(ct). - LINQ over async streams:
System.Linq.AsyncEnumerableis in-box from .NET 10.
System.Threading.Channels
A fast, async-friendly producer/consumer queue. Producers await writer.WriteAsync(); consumers await foreach (var x in reader.ReadAllAsync()).
- Bounded channels apply back-pressure: writers wait when full (or drop, per
BoundedChannelFullMode). - Unbounded channels never block writers — watch memory.
- Ideal for background work queues in ASP.NET Core (
BackgroundService).
TaskCompletionSource<T>
Creates a Task you complete manually — the bridge from callbacks, events or legacy APIs into the await world.
- Always pass
TaskCreationOptions.RunContinuationsAsynchronously, otherwise awaiting code may run inline inside yourSetResultcall and cause surprising re-entrancy or deadlocks. - Use
TrySetResult/TrySetException/TrySetCanceledwhen several paths may race. - Non-generic
TaskCompletionSourceexists since .NET 5.
Locks and await
You can't await inside a lock block (compiler error CS1996): the continuation might run on a different thread than the one holding the monitor. For mutual exclusion around awaits use SemaphoreSlim(1, 1) with WaitAsync.
- .NET 9's
System.Threading.Lockis a faster synchronous lock — still no awaits inside. - Always release in
finally. SemaphoreSlim(n)also throttles concurrency (e.g. max 10 outgoing calls).
IAsyncDisposable & await using
Some resources need async cleanup — flushing a stream, closing a network connection. await using calls DisposeAsync() at the end of the scope.
- Use for
DbContext,FileStream,Utf8JsonWriter, SignalR connections, etc. - Implement both
IDisposableandIAsyncDisposablein libraries when possible. - Careful with "eliding" async: returning a Task from inside a
usingwithout awaiting disposes the resource too early.
The awaitable pattern
await is pattern-based: anything with a GetAwaiter() method (even an extension method) whose result has IsCompleted, OnCompleted (via INotifyCompletion) and GetResult() can be awaited. That's how Task.Yield(), ConfigureAwait and ValueTask work.
For return types, [AsyncMethodBuilder] lets you build custom task-like types; .NET 6+ also allows per-method builders such as PoolingAsyncValueTaskMethodBuilder.
Task.Yield, Task.Run, StartNew
await Task.Yield()forces the method to suspend and resume later — useful to let a UI repaint or to make sure a caller gets control back. It is not a "low priority" switch.Task.Run(func)queues work to the thread pool. Right for offloading CPU work from a UI thread.Task.Factory.StartNewis low-level: with an async lambda it returnsTask<Task>(you'd need.Unwrap()), and it usesTaskScheduler.Current. PreferTask.Run.TaskCreationOptions.LongRunninghints for a dedicated thread — only for synchronous, long blocking loops.
Timers without threads
Task.Delayregisters with the runtime's timer queue — no thread sleeps. Never useThread.Sleepin async code.PeriodicTimer(.NET 6+) gives a cleanawait timer.WaitForNextTickAsync(ct)loop, without overlapping ticks.TimeProvider(.NET 8+) abstracts time so tests can fast-forward delays and timeouts (FakeTimeProvider).task.WaitAsync(timeout, ct)(.NET 6+) stops waiting after a timeout — it doesn't cancel the underlying work.
ref locals and Span<T> in async (C# 13+)
Before C# 13, async methods couldn't declare ref locals or ref struct locals such as Span<T>. Now they can — as long as the variable isn't used across an await (it can't be stored in the heap-allocated state machine).
Pattern: do the span-based parsing in a block between awaits, or move it into a small synchronous helper.
Runtime async (.NET 11)
For 14 years the C# compiler did all the async work (Chapter 6). Runtime async moves suspension and resumption into the CLR: the compiler emits much simpler IL for methods marked MethodImplOptions.Async, and the runtime captures and restores the stack frames itself.
- Reported benefits: readable stack traces, accurate breakpoints, fewer allocations along deep await chains.
- .NET 10: experimental, enabled via config switches.
- .NET 11: CoreCLR support on by default; System.* libraries ship compiled with it; your projects opt in with a project feature flag.
- Semantics (contexts, exceptions, ConfigureAwait) are intended to stay the same.
What you control vs what's decided for you
Async code is a partnership. You make a handful of high-impact choices; the compiler, runtime and OS handle the rest.
✅ You decide
- Whether a method is async, and its return type (
Task,ValueTask,IAsyncEnumerable) - Awaiting vs blocking (
awaitvs.Result) - Sequential vs concurrent (
awaiteach vsWhenAll) and the concurrency limit - Cancellation: accepting and passing
CancellationTokens; timeouts ConfigureAwait(false)in library code- Offloading CPU work (
Task.Run) from UI threads - Error handling: try/catch around awaits, never
async void - Lifetime of shared resources (one
HttpClient/IHttpClientFactory) - Thread-pool minimums (
ThreadPool.SetMinThreads) — rarely, as a stop-gap
⚙️ Decided for you
- State-machine code generation (compiler, or the runtime in .NET 11)
- Which pool thread runs a continuation
- Thread injection and hill-climbing in the thread pool
- Work-stealing between per-thread local queues
- IOCP / epoll / kqueue plumbing and kernel buffers
- DMA transfers and interrupts in hardware
- Timer queue management behind
Task.Delay - SynchronizationContext installed by the app model (WPF, MAUI…)
- CPU scheduling and context switches by the OS
How the pieces link
Every C# construct maps to a runtime mechanism, which maps to an OS or hardware mechanism. Tap any cell to highlight its row and read how it connects.
Practical tooling: see what your async code is doing
Async bugs rarely show up as exceptions. They show up as slow responses, timeouts and a slowly climbing thread count. These tools make them visible.
| Tool | What it shows | Use it when |
|---|---|---|
dotnet-counters | Live thread-pool thread count, queue length, completed items, CPU, GC | Suspect starvation: threads climbing slowly, CPU low, latency high |
dotnet-stack | Managed stacks of every thread in a running process | Find what blocked threads are waiting on (.Result, Wait) |
dotnet-trace + PerfView / VS | Events and CPU samples over time | Deeper investigation, async causality |
dotnet-dump | Memory dump with SOS commands (incl. async state machines) | Production hangs; "what's every task waiting for?" |
| Visual Studio Parallel Stacks & Tasks windows | Async call chains as a graph; task status | Debugging deadlocks locally |
| Roslyn analyzers | CS4014 un-awaited call · CA2007 ConfigureAwait · CA2012 ValueTask misuse · CA1849 sync call in async method · CA2016 forward CancellationToken | Always on in CI |
| Microsoft.VisualStudio.Threading.Analyzers | VSTHRD rules: sync-over-async, async void, missing awaits | UI apps and libraries |
| BenchmarkDotNet | Time and allocations per call ([MemoryDiagnoser]) | Deciding Task vs ValueTask, measuring overhead |
| OpenTelemetry / Application Insights | Distributed traces across awaits (via Activity + ExecutionContext) | Microservices latency breakdown |
Async patterns you'll reuse
Five shapes cover most production async code. Each diagram shows the flow; the code shows a .NET 10 implementation.
In production, prefer the resilience pipeline in Microsoft.Extensions.Http.Resilience (built on Polly) — AddStandardResilienceHandler() adds retries, timeouts and a circuit breaker to HttpClient.
Problems & fixes
The bugs that show up in real code reviews and production incidents — what you see, why it happens, and the fix.
Fix it: rescue the checkout API
A shop's POST /checkout endpoint calls three services (inventory, payment, shipping, ~100 ms each). It collapses every Black Friday. Turn on fixes one by one and re-run the load test. The model is illustrative, but the effects match real incidents.
Load test results
Show the endpoint before and after
Real-world scenarios
Twenty situations from web APIs, desktop apps, background workers, data pipelines and the cloud. Filter by category.
Cheat sheet & glossary
Everything on one page. Print it or save it as a PDF for your desk.
The golden rules
- Async all the way — don't mix blocking and awaiting
- Return
Task, neverasync void(except event handlers) - Accept and pass
CancellationToken - Await every task you start (or track it)
- Use I/O async APIs; don't fake them with
Task.Run
Never do
.Result/.Wait()on incomplete tasksThread.Sleepin async codeawaitinsidelock- Await a
ValueTasktwice new HttpClient()per requestParallel.ForEachwith an async lambda
Pick the tool
- I/O wait →
await - Many I/O →
WhenAll/ForEachAsync - Results as they come →
WhenEach - CPU on UI →
Task.Run - CPU, many items →
Parallel/ PLINQ - Queue of work →
Channel<T>
ConfigureAwait
- Library:
ConfigureAwait(false) - UI code touching controls: default
- ASP.NET Core app: not needed
- .NET 8+:
ConfigureAwaitOptions.SuppressThrowingto wait without throwing
Cancellation
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));- Link:
CreateLinkedTokenSource(a, b) - Check:
ct.ThrowIfCancellationRequested() - Catch
OperationCanceledException - Prefer
CancelAsync()in async code (.NET 8+)
Starvation checklist
- Threads rising ~1–2/s, CPU low, latency high
dotnet-counters monitor→ thread pool queue lengthdotnet-stack report→ find.Result/Wait- Fix the blocking;
SetMinThreadsis only a bandage
Under the hood
- async method → state machine (C#) / runtime async (.NET 11)
- await →
GetAwaiter,IsCompleted,OnCompleted,GetResult - Windows → IOCP · Linux → epoll · macOS → kqueue
- Device → DMA → interrupt → completion → pool thread
Exceptions
awaitrethrows the original exception.Result/.Wait()wrap inAggregateExceptionWhenAll: await throws first; inspecttask.Exceptionfor all- Unobserved faulted tasks raise
TaskScheduler.UnobservedTaskException
📖 Glossary
Async Academy · Built for learning. Numbers are typical and rounded; behaviour described for .NET 10 (LTS) with .NET 11 notes, October 2026.