- Core Concepts
- How Async Execution Works
- Does
awaitBlock the Next Line? - Essential Patterns
- CancellationToken — Always Propagate It
- ConfigureAwait
- async void — Why It's Dangerous
- ValueTask<T> — Avoiding Heap Allocations
- Async Streams — IAsyncEnumerable<T>
- Common Mistakes to Avoid
- Quick Reference
git clone https://github.com/your-username/async-dotnet-demo.git
cd async-dotnet-demo
dotnet runRequires .NET 8 SDK
Async programming in .NET Core is built around the Task-based Asynchronous Pattern (TAP), using async / await keywords.
| Concept | Description |
|---|---|
Task |
Represents an ongoing operation with no return value |
Task<T> |
Represents an ongoing operation that returns a value of type T |
async |
Marks a method as asynchronous |
await |
Suspends the method until the awaited operation completes — without blocking the thread |
public async Task<string> FetchDataAsync(string url)
{
using var client = new HttpClient();
string result = await client.GetStringAsync(url);
return result;
}The state machine: When you
await, the compiler rewrites your method into a state machine. The method returns to the caller at theawaitpoint, and resumes when the awaited task completes.
Thread 1: [Running sync work] ----await----- [suspended] ----------- [resumed on callback]
| ^
v |
Thread pool: [free to do other work while I/O is in progress] |
|
I/O device: [Network / disk / DB doing its thing] -+
Key insight:
- While waiting for I/O, the thread is released back to the pool — free to do other work.
- No thread is blocked. Scalability comes from this.
- For CPU-bound work, use
Task.Run()to offload to a thread pool thread instead of blocking the caller.
Yes — within that method, the next line waits.
var a = await GetAAsync(); // pauses HERE until GetAAsync() finishes
var b = function(); // only runs AFTER 'a' has its valueThe key distinction:
| What | Behaviour |
|---|---|
| The method | Suspended at the await line |
| The thread | NOT blocked — released back to the pool |
| The next line | Will not run until the awaited task completes |
Think of it like sending a text and waiting for a reply before writing your next message — you are paused, but you can still breathe.
// I/O-bound: await directly
public async Task<User> GetUserAsync(int id)
{
return await _db.Users.FindAsync(id);
}
// CPU-bound: use Task.Run to avoid blocking the caller
public async Task<int> ComputeAsync(int[] data)
{
return await Task.Run(() => data.Sum());
}When you have multiple independent tasks, don't await them sequentially — run them concurrently:
// Sequential — SLOW (3 seconds total)
var a = await GetAAsync();
var b = await GetBAsync();
var c = await GetCAsync();
// Concurrent — FAST (~1 second)
var taskA = GetAAsync();
var taskB = GetBAsync();
var taskC = GetCAsync();
await Task.WhenAll(taskA, taskB, taskC);
var (a, b, c) = (taskA.Result, taskB.Result, taskC.Result);Task.WhenAny returns as soon as the first task in the list completes.
var dataTask = FetchDataAsync();
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5));
if (await Task.WhenAny(dataTask, timeoutTask) == timeoutTask)
throw new TimeoutException();
return await dataTask;Breaking down await Task.WhenAny(...) == timeoutTask:
| Part | What it does |
|---|---|
Task.WhenAny(dataTask, timeoutTask) |
Starts both tasks racing; returns a Task<Task> that completes when either finishes |
await it |
Gives you back the winning Task object (not its value) |
== timeoutTask |
Checks if the timeout won the race |
Note: After the check, you still need to
await dataTaskto get the actual result —WhenAnyonly tells you who won.
Race outcome:
Scenario A — timeout fires first:
dataTask: [=======================...still running...]
timeoutTask: [=========] ← WhenAny returns this → throw TimeoutException
Scenario B — data arrives first:
dataTask: [=======] ← WhenAny returns this → return await dataTask (success)
timeoutTask: [==================...still counting...]
Without propagating the token, cancellation is ignored mid-chain. The DB query keeps running, the HTTP call keeps going, resources are wasted — even after the user cancelled.
The call chain:
Controller → ServiceA → ServiceB → HttpClient → DB query
Pass the token all the way down:
// Controller
public async Task<IActionResult> Get(CancellationToken ct)
{
var result = await _service.GetDataAsync(ct); // passes token
return Ok(result);
}
// Service
public async Task<Data> GetDataAsync(CancellationToken ct)
{
var raw = await _repo.FetchAsync(ct); // passes token
return Transform(raw);
}
// Repository
public async Task<Raw> FetchAsync(CancellationToken ct)
{
return await _db.ExecuteAsync(query, ct); // token reaches DB driver
}What happens when the token is propagated:
- User cancels (closes browser tab, request times out, etc.)
- DB query stops immediately
- HTTP call stops
- Everything cleans up — no wasted resources
Especially important for expensive operations like large DB queries or external API calls.
Creating and using a CancellationToken:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var result = await FetchAsync("https://api.example.com", cts.Token);By default, await captures the current synchronization context (e.g. UI thread, ASP.NET request context) and resumes on it.
In library code, you usually don't need this — use ConfigureAwait(false) to avoid deadlocks and improve performance:
// In library / service code — don't need the context
var data = await _repo.GetAsync(id).ConfigureAwait(false);
// In UI code or ASP.NET controllers — omit it (you need the context)
var data = await _repo.GetAsync(id);In ASP.NET Core there is no
SynchronizationContext, soConfigureAwait(false)is a no-op there — but it is still good practice in shared libraries.
// async Task — SAFE: caller can catch the exception
public async Task DoWorkAsync()
{
throw new Exception("something broke");
}
try { await DoWorkAsync(); }
catch (Exception e) { /* caught! */ }
// async void — DANGEROUS: exception goes nowhere
public async void DoWork()
{
throw new Exception("something broke");
}
DoWork(); // fire and forget — exception crashes the process or silently disappearsDoWork(); // returns immediately (void)
DoSomethingAfter(); // runs before DoWork() is actually done!// OK here: the event system is already fire-and-forget
button.Click += async (s, e) =>
{
await LoadDataAsync();
};async Task |
async void |
|
|---|---|---|
| Can be awaited | Yes | No |
| Exception observable | Yes (stored in Task) | No (crashes / disappears) |
| Know when it finishes | Yes | No |
| Valid use case | Everything | Event handlers only |
Every call to an async Task<T> method allocates a Task<T> object on the heap — even if the result was already available immediately. In high-throughput APIs, this adds up.
// Even on cache hit, a Task<User> object is still allocated on the heap
public async Task<User> GetUserAsync(int id)
{
if (_cache.TryGetValue(id, out var user))
return user; // result is ready, but Task allocation still happens
return await _db.FindAsync(id);
}ValueTask<T> is a struct (value type), not a class. When the result is synchronously available, it lives on the stack — no heap allocation.
public ValueTask<User> GetUserAsync(int id)
{
if (_cache.TryGetValue(id, out var user))
return ValueTask.FromResult(user); // no heap allocation — just a struct
return new ValueTask<User>(FetchFromDbAsync(id)); // wraps a real Task only when needed
}Analogy: Task<T> always puts the gift in a box, even if you're handing it over directly. ValueTask<T> skips the box when the gift is already in your hand.
- Do not
awaitit more than once - Do not store it in a field
- Do not use it in general code — only in hot paths where you've measured a real performance problem
- Default to
Task<T>everywhere else (simpler, safer)
For streaming data (reading a file line by line, server-sent events, paginated APIs):
// Producer
public async IAsyncEnumerable<LogEntry> ReadLogsAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var line in File.ReadLinesAsync("app.log", ct))
{
yield return ParseLogEntry(line);
}
}
// Consumer
await foreach (var entry in ReadLogsAsync(ct))
{
Console.WriteLine(entry.Message);
}// BAD
public async void SaveData() { ... }
// GOOD
public async Task SaveDataAsync() { ... }// BAD — can deadlock, blocks the thread
var result = GetDataAsync().Result;
var result = GetDataAsync().GetAwaiter().GetResult();
// GOOD
var result = await GetDataAsync();// BAD — runs synchronously, pointless async, compiler warning
public async Task<int> GetCountAsync()
{
return 42;
}
// GOOD — return a completed task directly
public Task<int> GetCountAsync()
{
return Task.FromResult(42);
}// BAD — unnecessarily slow
var a = await GetAAsync();
var b = await GetBAsync();
// GOOD — run in parallel
await Task.WhenAll(GetAAsync(), GetBAsync());| Type | Use when |
|---|---|
Task |
Async method, no return value |
Task<T> |
Async method, returns a value |
ValueTask<T> |
Hot path, result often synchronously available |
async void |
Event handlers only |
IAsyncEnumerable<T> |
Streaming / yielding multiple results |
| API | Purpose |
|---|---|
Task.WhenAll(...) |
Await all tasks; all must succeed |
Task.WhenAny(...) |
Await first task to finish |
Task.Run(...) |
Offload CPU-bound work to thread pool |
Task.FromResult(v) |
Wrap a sync value as a completed task |
Task.Delay(ms) |
Async wait / timeout |
CancellationTokenSource |
Create and control a cancellation token |
| Scenario | Recommendation |
|---|---|
| I/O-bound work | async / await directly |
| CPU-bound work | Task.Run() |
| Library / shared code | ConfigureAwait(false) |
Blocking calls (.Result, .Wait()) |
Avoid — can deadlock |
async void |
Avoid — use async Task instead |
| Independent tasks | Run with Task.WhenAll — don't await sequentially |
| High-frequency sync results | Consider ValueTask<T> |