Skip to main content

Async/Await

Definition

async/await is a language-level feature in C# that simplifies asynchronous programming. The async modifier enables the use of await within a method, and await asynchronously waits for a Task or Task<T> to complete without blocking the calling thread. The compiler transforms the method into a state machine that yields control back to the caller at each await point and resumes when the awaited operation finishes.

Why Async/Await Exists

Problem without async/awaitSolution with async/await
Thread-blocking calls waste thread pool resourcesawait releases the thread while waiting
Callback chains (continuations) become deeply nested ("callback hell")Linear, top-to-bottom code flow
Manual Task.ContinueWith() is hard to read and composeawait handles continuation wiring automatically
SynchronizationContext bugs with nested callbacksCompiler-generated state machine handles context correctly
// Without async/await — blocking, wastes a thread
public string GetData()
{
var response = httpClient.GetAsync(url).Result; // blocks the thread
return response.Content.ReadAsStringAsync().Result;
}

// With async/await — non-blocking, efficient
public async Task<string> GetDataAsync()
{
var response = await httpClient.GetAsync(url); // thread is released
return await response.Content.ReadAsStringAsync(); // thread is released again
}
Key Benefits
  • Non-blocking — threads are freed while waiting for I/O
  • Readable — asynchronous code reads like synchronous code
  • Scalable — fewer threads serve more concurrent requests
  • Composable — async methods can call other async methods naturally

Core Concepts

How Async Works Under the Hood

State Machine Transformation

When the C# compiler encounters an async method, it transforms it into a state machine struct (IAsyncStateMachine). Each await point becomes a state transition. The method is broken into segments separated by await boundaries.

// What you write
public async Task<string> FetchAsync()
{
var response = await httpClient.GetAsync(url); // State 0 → State 1
var content = await response.Content.ReadAsStringAsync(); // State 1 → State 2
return content.Trim();
}
// Simplified version of what the compiler generates
private struct StateMachine : IAsyncStateMachine
{
public int State;
public AsyncTaskMethodBuilder<string> Builder;
public HttpClient HttpClient;
private string url;
private HttpResponseMessage response;
private string content;
private TaskAwaiter<HttpResponseMessage> awaiter1;
private TaskAwaiter<string> awaiter2;

void MoveNext()
{
try
{
if (State == 0)
{
awaiter1 = HttpClient.GetAsync(url).GetAwaiter();
if (!awaiter1.IsCompleted)
{
State = 1;
Builder.AwaitUnsafeOnCompleted(ref awaiter1, ref this);
return; // yields control back to caller
}
response = awaiter1.GetResult();
}
// State 1: resume after first await
if (State == 1)
{
response = awaiter1.GetResult();
awaiter2 = response.Content.ReadAsStringAsync().GetAwaiter();
if (!awaiter2.IsCompleted)
{
State = 2;
Builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);
return; // yields control again
}
content = awaiter2.GetResult();
}
// State 2: resume after second await
content = awaiter2.GetResult();
Builder.SetResult(content.Trim()); // complete the Task
}
catch (Exception ex)
{
Builder.SetException(ex);
}
}
}

Key points about the state machine:

  • It's a struct — allocated on the stack initially, only boxed to the heap if the method actually suspends
  • Each await is a potential suspension point where the method yields control
  • The MoveNext() method is called each time the awaited task completes
  • No thread is blocked while waiting — the continuation is scheduled by the awaiter

I/O-Bound Async: No Thread While Waiting

A common misconception is that async I/O "uses a thread from the thread pool to wait." In reality, no thread is involved during the I/O wait.

  1. The application calls an async I/O method (e.g., ReadAsync)
  2. The OS kernel initiates the I/O operation and returns immediately
  3. The application thread is released back to the thread pool
  4. When the hardware completes the operation, it triggers a hardware interrupt
  5. The OS kernel signals the I/O Completion Port (IOCP)
  6. A thread pool thread picks up the completion notification and resumes the state machine

This is why async scales: 10,000 concurrent HTTP requests don't need 10,000 threads. They all issue I/O and release their threads, then resume when I/O completes.

CPU-Bound vs I/O-Bound Async

AspectI/O-BoundCPU-Bound
ExampleHTTP request, file read, database queryImage processing, compression, encryption
How it worksNo thread while waiting (IOCP)Task.Run offloads to thread pool
ScalabilityExcellent — thread freed during waitLimited — still uses a thread
Use asyncAlwaysOnly to offload from UI thread
Patternawait networkStream.ReadAsync()await Task.Run(() => Compute())
// I/O-bound — no thread used during wait
public async Task<string> FetchAsync(string url)
{
var response = await httpClient.GetAsync(url); // thread released
return await response.Content.ReadAsStringAsync(); // thread released again
}

// CPU-bound — thread pool thread does the work
public async Task<byte[]> CompressAsync(byte[] data)
{
return await Task.Run(() => Compress(data)); // different thread does CPU work
}

SynchronizationContext

SynchronizationContext is an abstraction that determines where a continuation runs after an await. Different application models provide different implementations:

Application TypeSynchronizationContextContinuation Behavior
ASP.NET CoreNone (null)Resumes on any thread pool thread
ASP.NET (legacy)AspNetSynchronizationContextResumes on the original request thread
WPF / WinFormsDispatcherSynchronizationContextResumes on the UI thread
Console appNone (null)Resumes on any thread pool thread
// In WPF — await captures UI SynchronizationContext by default
private async void Button_Click(object sender, EventArgs e)
{
var data = await FetchDataAsync(); // resumes on UI thread
Label.Text = data; // safe — we're on UI thread
}

// With ConfigureAwait(false) — resumes on thread pool thread
private async Task<string> LibraryMethodAsync()
{
var data = await FetchDataAsync().ConfigureAwait(false); // resumes on any thread
return data.Trim(); // NOT on UI thread — can't update UI here
}
Why ASP.NET Core Has No SynchronizationContext

ASP.NET Core was designed to be stateless and thread-agnostic. Request state is stored in HttpContext, not in thread-local storage. This eliminates the need for context switching and avoids the deadlock problems that plagued legacy ASP.NET when developers called .Result on async methods.

Task Lifecycle and States

A Task goes through specific states during its lifetime:

StateStatusIsCompletedIsFaultedIsCanceled
CreatedTaskStatus.Createdfalsefalsefalse
RunningTaskStatus.Runningfalsefalsefalse
SuccessTaskStatus.RanToCompletiontruefalsefalse
ExceptionTaskStatus.Faultedtruetruefalse
CancelledTaskStatus.Canceledtruefalsetrue
var task = FetchDataAsync();

if (task.IsCompleted) { /* done (success, fault, or cancel) */ }
if (task.IsCompletedSuccessfully) { /* only success */ }
if (task.IsFaulted) { /* exception occurred */ }
if (task.IsCanceled) { /* was cancelled */ }

// Access the result (only if RanToCompletion)
if (task.IsCompletedSuccessfully)
{
string result = task.Result; // safe — no blocking
}

Async Programming Models Evolution

C# has gone through three major async programming models:

ModelEraApproachComposableCancellationError Handling
APM.NET 1.xBegin/End + IAsyncResultNoManualEndXxx throws
EAP.NET 2.0Events + XxxCompletedNoLimitedEvent args
TAP.NET 4.0+Task + async/awaitYesCancellationTokentry/catch

APM — the original callback-based pattern:

stream.BeginRead(buffer, 0, buffer.Length, ar =>
{
int bytesRead = stream.EndRead(ar);
// nested callbacks → "callback hell"
}, null);

EAP — event-based pattern:

webClient.DownloadDataCompleted += (sender, e) =>
{
byte[] data = e.Result; // hard to compose, chain, or cancel
};
webClient.DownloadDataAsync(url);

TAP — the modern pattern we use today:

public async Task<byte[]> DownloadDataAsync(Uri url)
{
var response = await httpClient.GetAsync(url);
return await response.Content.ReadAsByteArrayAsync();
}

Thread Pool and Task Scheduler

Task objects execute on the thread pool by default. The thread pool manages a set of worker threads and I/O completion port threads, dynamically adjusting the count based on workload.

// Task.Run schedules work on the thread pool
var task = Task.Run(() => ExpensiveComputation());

// Long-running tasks get a dedicated thread
var task = Task.Run(() => LongWork(), TaskCreationOptions.LongRunning);

// Custom TaskScheduler for control over execution
public class OrderedTaskScheduler : TaskScheduler
{
private readonly Queue<Task> _tasks = new();

protected override void QueueTask(Task task) => _tasks.Enqueue(task);
protected override bool TryExecuteTaskInline(Task task, bool wasQueued) => TryExecuteTask(task);
protected override IEnumerable<Task> GetScheduledTasks() => _tasks;
}
Thread Pool vs Dedicated Threads
  • Thread pool — reused, managed by the runtime. Ideal for short-lived work units. Don't block thread pool threads with long-running operations.
  • Long-running tasks — use TaskCreationOptions.LongRunning to hint the scheduler to create a dedicated thread instead of using the pool.
  • Thread pool starvation — if too many thread pool threads are blocked (e.g., synchronous waits on async code), new tasks queue up and the entire application becomes unresponsive.

Task and Task<T>

Task represents an asynchronous operation that may not have completed yet — think of it as a "promise" that work will finish at some point in the future. You don't get a result value, just a signal that the operation completed (or failed).

Task<T> is the generic version that produces a result of type T when it completes. After awaiting it, you get the T value directly.

Tasks are the fundamental building block of the TAP pattern. They are not threads — a Task is a data structure that tracks the state of an operation, while a Thread is an OS-level execution unit. A Task may use a thread (for CPU-bound work via Task.Run) or may use no thread at all (for I/O-bound work).

// Task — no return value (like void)
public async Task DoWorkAsync()
{
await Task.Delay(1000); // simulate async work
Console.WriteLine("Done");
}

// Task<T> — returns a value
public async Task<string> GetNameAsync()
{
await Task.Delay(500);
return "Alice";
}

// Creating completed tasks
Task.CompletedTask // completed Task with no result
Task.FromResult(42) // completed Task<int> with value 42
Task.FromException(ex) // faulted task

Async/Await Keywords

public async Task<int> CalculateAsync()
{
// 'await' suspends the method and returns to the caller
int a = await FetchValueAsync(); // async I/O
int b = await ComputeAsync(a); // async computation
return a + b; // result is wrapped in Task<int>
}

// Multiple awaits execute sequentially
public async Task ChainAsync()
{
await Step1Async(); // wait for step 1
await Step2Async(); // then step 2
await Step3Async(); // then step 3
}
Async Method Signatures
  • Use async Task for async methods with no return value. Never use async void except for event handlers.
  • The return type is Task<T>, not T — the compiler wraps the return value automatically.
  • Adding async to a method that doesn't use await generates a compiler warning (CS1998).

Concurrent Execution

When you have multiple independent async operations, you can run them sequentially (one after another) or concurrently (all at once). Sequential execution is the default with multiple await statements — each waits for the previous one to finish before starting the next. Concurrent execution starts all operations immediately and waits for all to complete, which is much faster when operations are independent.

Use Task.WhenAll when you need all results (e.g., fetch data from 3 APIs simultaneously). Use Task.WhenAny when you only need the first result (e.g., query multiple mirrors and take the fastest response).

// Sequential — total time = sum of all operations
public async Task SequentialAsync()
{
await Task.Delay(1000); // 1s
await Task.Delay(1000); // 1s
await Task.Delay(1000); // 1s → total: 3s
}

// Concurrent — all tasks run simultaneously
public async Task ConcurrentAsync()
{
var t1 = Task.Delay(1000);
var t2 = Task.Delay(1000);
var t3 = Task.Delay(1000);
await Task.WhenAll(t1, t2, t3); // total: ~1s
}

// WhenAll — wait for all, get all results
public async Task<int[]> GetAllResultsAsync()
{
var tasks = new[]
{
GetScoreAsync("Alice"),
GetScoreAsync("Bob"),
GetScoreAsync("Charlie"),
};
return await Task.WhenAll(tasks); // int[] with all results
}

// WhenAny — wait for the first to complete
public async Task<string> GetFastestAsync()
{
var tasks = urls.Select(url => FetchAsync(url)).ToArray();
Task<string> first = await Task.WhenAny(tasks);
return await first;
}

Cancellation Tokens

CancellationToken is a struct that enables cooperative cancellation — the caller signals "please stop," and the async operation checks this signal and decides how to respond. It's cooperative because neither side is forced: the caller requests cancellation, and the operation decides when and how to honor it.

The typical pattern: create a CancellationTokenSource (the sender), pass its Token property to async methods (the receiver), and call .Cancel() when you want to stop. Async BCL methods (HTTP, file I/O, EF Core) already accept and check tokens — you just need to pass them through. For your own loops, call ct.ThrowIfCancellationRequested() to check between iterations.

// Defining a cancellable async method
public async Task<Data> FetchDataAsync(CancellationToken cancellationToken)
{
var response = await httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Data>(cancellationToken);
}

// Cancelling from the caller
public async Task ExampleAsync()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); // timeout

try
{
var data = await FetchDataAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Request was cancelled or timed out");
}
}

// Manual cancellation
public async Task ProcessAsync()
{
using var cts = new CancellationTokenSource();

// Cancel from another thread or event
_ = Task.Run(async () =>
{
await Task.Delay(5000);
cts.Cancel(); // triggers cancellation after 5 seconds
});

await LongRunningOperationAsync(cts.Token);
}

// Checking cancellation in your own code
public async Task ProcessItemsAsync(IEnumerable<Item> items, CancellationToken ct)
{
foreach (var item in items)
{
ct.ThrowIfCancellationRequested(); // throws OperationCanceledException
await ProcessItemAsync(item, ct);
}
}
Best Practices for Cancellation
  • Accept CancellationToken as the last parameter in async methods.
  • Pass the token through to all downstream async calls.
  • Use CancellationTokenSource(TimeSpan) for timeouts.
  • Use ct.ThrowIfCancellationRequested() in CPU-bound loops.

Exception Handling

Exceptions from async operations behave the same as synchronous ones — you catch them with try/catch around the await. The key difference is with Task.WhenAll: when multiple tasks fail, only the first exception is thrown by await. The remaining exceptions are captured in the returned task's Exception property as an AggregateException. If you need to observe all failures (e.g., log all errors), you must access task.Exception.InnerExceptions.

// Exceptions are caught on the await
public async Task SafeFetchAsync()
{
try
{
var data = await FetchDataAsync();
}
catch (HttpRequestException ex)
{
Console.WriteLine($"HTTP error: {ex.Message}");
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation was cancelled");
}
finally
{
Cleanup();
}
}

// Observing all exceptions from WhenAll
public async Task ObserveAllErrorsAsync()
{
var tasks = urls.Select(url => FetchAsync(url));
var allTasks = Task.WhenAll(tasks);

try
{
await allTasks;
}
catch
{
// allTasks.Exception contains all exceptions as AggregateException
foreach (var ex in allTasks.Exception!.InnerExceptions)
{
Console.WriteLine($"Error: {ex.Message}");
}
}
}

ConfigureAwait

By default, when you await a Task, the code after the await (the continuation) tries to resume on the same context it was on before — e.g., the UI thread in WPF, or the request thread in legacy ASP.NET. This is called capturing the SynchronizationContext.

ConfigureAwait(false) tells the awaiter: "Don't capture the context. Resume on any thread pool thread." This matters because:

  • In library code, you don't need any specific context, so avoiding the capture is faster and prevents deadlocks.
  • In UI code, you need the UI thread to update controls, so you should NOT use it.
  • In ASP.NET Core, there is no context to capture, so it makes no difference — but it's harmless to include.
// Library code — always use ConfigureAwait(false)
public async Task<string> LibraryMethodAsync()
{
var data = await httpClient.GetStringAsync(url).ConfigureAwait(false);
var result = await ParseAsync(data).ConfigureAwait(false);
return result;
}

// UI/ASP.NET app code — use default (continue on captured context)
public async Task<IActionResult> ControllerActionAsync()
{
var data = await _service.GetDataAsync(); // ConfigureAwait(true) is default
return Ok(data);
}
When to Use ConfigureAwait(false)
  • Library code: Always use .ConfigureAwait(false) — you don't know the caller's context, and avoiding context capture improves performance and prevents deadlocks.
  • Application code (ASP.NET Core, console apps): Not needed — ASP.NET Core has no SynchronizationContext, so it makes no difference. Still harmless to include.
  • UI applications (WPF, WinForms, MAUI): Do NOT use it if you need to update UI after the await, because you'll lose the UI thread context.

ValueTask

Task<T> is a class (reference type), so every time you create one, it allocates on the heap. For most code this is fine, but in hot paths where the result is usually available immediately (e.g., a cache hit), the allocation is wasted.

ValueTask<T> is a struct (value type) that wraps either a T result directly (no allocation) or a Task<T> (allocates only when the async path is needed). This avoids heap allocation on the synchronous fast path.

When to use ValueTask<T>: when you have an async method where the result is available immediately most of the time — caches, buffered stream reads, or methods that short-circuit. For everything else, stick with Task<T>.

// Use ValueTask when the result is often available immediately
public ValueTask<string> GetCachedValueAsync(string key)
{
if (_cache.TryGetValue(key, out string? value))
{
return new ValueTask<string>(value); // synchronous — no allocation
}

return new ValueTask<string>(FetchFromDbAsync(key)); // async — allocates
}
ValueTask Guidelines
  • Use ValueTask<T> only when the synchronous path is the common case (e.g., caches, buffered reads).
  • Do not await a ValueTask twice — it's not reusable like Task.
  • Do not store a ValueTask in a field or return it from a property — convert to Task first with .AsTask().
  • When in doubt, use Task<T> — allocation cost is minimal for most scenarios.

Async Streams (IAsyncEnumerable)

IEnumerable<T> lets you iterate a collection with foreach, pulling one element at a time. But each element is produced synchronously — if getting an element requires I/O (e.g., reading the next page from a database), the entire thread blocks.

IAsyncEnumerable<T> solves this by allowing each element to be produced asynchronously. With await foreach, the consumer awaits each element — the producer can do async I/O (database reads, API calls, file reads) to produce each item without blocking any thread.

This is useful for streaming large datasets from a database, consuming real-time event feeds, or paginated API responses where you don't want to load all data into memory at once.

// Producer — yield elements asynchronously
public async IAsyncEnumerable<int> GenerateNumbersAsync(
int count,
[EnumeratorCancellation] CancellationToken ct = default)
{
for (int i = 0; i < count; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(100, ct);
yield return i;
}
}

// Consumer — await foreach
public async Task ConsumeAsync()
{
await foreach (var number in GenerateNumbersAsync(10))
{
Console.WriteLine(number);
}
}

// With cancellation
public async Task ConsumeWithCancellationAsync(CancellationToken ct)
{
await foreach (var number in GenerateNumbersAsync(10).WithCancellation(ct))
{
Console.WriteLine(number);
}
}

Channels

System.Threading.Channels provides thread-safe, async-compatible producer/consumer queues. Think of a Channel as a thread-safe pipe: producers write messages in, consumers read messages out. Multiple producers and consumers can operate concurrently without explicit locking.

There are two kinds: bounded channels have a fixed capacity and apply backpressure (producers wait when full), while unbounded channels accept unlimited items. Channels are lighter than BlockingCollection<T> and fully async — they use ValueTask for reads/writes, so no thread blocks while waiting.

Channels are ideal when you need to decouple a fast producer from a slow consumer, build processing pipelines with multiple stages, or implement in-memory background job queues in ASP.NET Core.

using System.Threading.Channels;

// Create a bounded channel (capacity limit, backpressure)
var channel = Channel.Bounded<string>(capacity: 100);

// Create an unbounded channel (no limit)
var channel = Channel.CreateUnbounded<string>();

// Producer
async Task ProduceAsync(ChannelWriter<string> writer, CancellationToken ct)
{
for (int i = 0; i < 1000; i++)
{
ct.ThrowIfCancellationRequested();
await writer.WriteAsync($"Item {i}", ct);
}
writer.Complete(); // signal no more items
}

// Consumer
async Task ConsumeAsync(ChannelReader<string> reader, CancellationToken ct)
{
await foreach (var item in reader.ReadAllAsync(ct))
{
Console.WriteLine($"Consumed: {item}");
}
}

// Usage
var channel = Channel.Bounded<string>(100);
var cts = new CancellationTokenSource();

var producer = ProduceAsync(channel.Writer, cts.Token);
var consumer = ConsumeAsync(channel.Reader, cts.Token);

await Task.WhenAll(producer, consumer);
When to Use Channels
  • Producer/consumer patterns — decouple data production from consumption
  • Pipeline processing — chain multiple stages, each reading from one channel and writing to another
  • Rate limiting — bounded channels apply backpressure to fast producers
  • Background job queues — enqueue work items and process them on a separate task

TaskCompletionSource

TaskCompletionSource<T> is the manual way to create and control a Task<T>. Normally, tasks are created by the compiler (via async methods) or by Task.Run. But sometimes you need to produce a Task from an event, a callback, a timer, or any external signal — that's where TCS comes in.

You create a TaskCompletionSource<T>, expose its Task property to callers, and then call TrySetResult, TrySetException, or TrySetCanceled when your external operation completes. This bridges older async patterns (APM, EAP) or any callback-based API into the modern async/await world.

// Wrapping an event-based API into a Task
public Task<string> WaitForUserInputAsync()
{
var tcs = new TaskCompletionSource<string>();

_inputReceived += (sender, args) =>
{
tcs.TrySetResult(args.Text);
};

_inputCanceled += (sender, args) =>
{
tcs.TrySetCanceled();
};

return tcs.Task;
}

// Wrapping EAP (Event-based Async Pattern) to TAP
public Task<byte[]> DownloadDataAsync(Uri url)
{
var tcs = new TaskCompletionSource<byte[]>();
var webClient = new WebClient();

webClient.DownloadDataCompleted += (sender, e) =>
{
if (e.Error is not null)
tcs.TrySetException(e.Error);
else if (e.Cancelled)
tcs.TrySetCanceled();
else
tcs.TrySetResult(e.Result);
};

webClient.DownloadDataAsync(url);
return tcs.Task;
}
Use TrySetResult, TrySetException, TrySetCanceled (the Try variants) to avoid InvalidOperationException if the task has already been completed by another source.

Parallel.ForEachAsync

Parallel.ForEachAsync (.NET 6+) combines parallel iteration with async support and concurrency limiting — replacing manual SemaphoreSlim + Task.WhenAll patterns.

public async Task ProcessUrlsAsync(IEnumerable<string> urls)
{
await Parallel.ForEachAsync(
urls,
new ParallelOptions
{
MaxDegreeOfParallelism = 5,
CancellationToken = cancellationToken
},
async (url, ct) =>
{
var data = await httpClient.GetStringAsync(url, ct);
await ProcessDataAsync(data, ct);
});
}

Async Coordination Primitives

SemaphoreSlim — Rate Limiting

public class RateLimitedService
{
private readonly SemaphoreSlim _semaphore = new(3); // max 3 concurrent

public async Task<string> FetchAsync(string url)
{
await _semaphore.WaitAsync();
try
{
return await httpClient.GetStringAsync(url);
}
finally
{
_semaphore.Release();
}
}
}

AsyncLock (Custom)

// C# has no built-in async lock — implement with SemaphoreSlim(1, 1)
public class AsyncLock
{
private readonly SemaphoreSlim _semaphore = new(1, 1);

public async Task<IDisposable> LockAsync(CancellationToken ct = default)
{
await _semaphore.WaitAsync(ct);
return new Releaser(_semaphore);
}

private sealed class Releaser(SemaphoreSlim semaphore) : IDisposable
{
public void Dispose() => semaphore.Release();
}
}

// Usage
public class Cache
{
private readonly AsyncLock _lock = new();
private readonly Dictionary<string, string> _data = new();

public async Task<string> GetOrAddAsync(string key, Func<Task<string>> factory)
{
using (await _lock.LockAsync())
{
if (_data.TryGetValue(key, out var value))
return value;

value = await factory();
_data[key] = value;
return value;
}
}
}

Background Tasks

BackgroundService

BackgroundService is the recommended way to run long-running async work in ASP.NET Core.

public class EmailQueueProcessor : BackgroundService
{
private readonly Channel<EmailMessage> _channel;
private readonly ILogger<EmailQueueProcessor> _logger;

public EmailQueueProcessor(Channel<EmailMessage> channel, ILogger<EmailQueueProcessor> logger)
{
_channel = channel;
_logger = logger;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var email in _channel.Reader.ReadAllAsync(stoppingToken))
{
try
{
await SendEmailAsync(email, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send email to {Recipient}", email.To);
}
}
}
}

// Registration in Program.cs
builder.Services.AddSingleton(Channel.CreateUnbounded<EmailMessage>());
builder.Services.AddHostedService<EmailQueueProcessor>();

IHostedService

For one-time startup/shutdown async logic, use IHostedService directly.

public class DatabaseMigrationService : IHostedService
{
private readonly IServiceProvider _services;

public DatabaseMigrationService(IServiceProvider services) => _services = services;

public async Task StartAsync(CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync(ct);
}

public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

Periodic Background Task

public class CacheRefreshService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly TimeSpan _interval = TimeSpan.FromMinutes(5);

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(_interval);

await RefreshCacheAsync(stoppingToken); // run once immediately

while (await timer.WaitForNextTickAsync(stoppingToken))
{
await RefreshCacheAsync(stoppingToken);
}
}
}

Async in ASP.NET Core

Async Middleware

app.Use(async (context, next) =>
{
var stopwatch = Stopwatch.StartNew();
await next(context);
stopwatch.Stop();
logger.LogInformation("Request took {Ms}ms", stopwatch.ElapsedMilliseconds);
});

Async Minimal API Endpoints

app.MapGet("/api/users", async (IUserService userService, CancellationToken ct) =>
{
var users = await userService.GetAllAsync(ct);
return Results.Ok(users);
});

app.MapPost("/api/users", async (CreateUserRequest request, IUserService userService, CancellationToken ct) =>
{
var user = await userService.CreateAsync(request, ct);
return Results.Created($"/api/users/{user.Id}", user);
});

Async Filters

public class ValidationFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (!context.ModelState.IsValid)
{
context.Result = new BadRequestObjectResult(context.ModelState);
return;
}

await next();
}
}

Async in EF Core

// Async queries
var users = await dbContext.Users
.Where(u => u.IsActive)
.OrderBy(u => u.Name)
.ToListAsync(cancellationToken);

// Async save
dbContext.Users.Add(newUser);
await dbContext.SaveChangesAsync(cancellationToken);

// Async transaction
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
await dbContext.Products.AddAsync(product, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}

// Execute raw SQL async
var affected = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE Products SET Price = Price * 1.1 WHERE CategoryId = {0}", categoryId);

Retry and Resilience

// Manual retry with exponential backoff
public async Task<string> FetchWithRetryAsync(string url, int maxRetries = 3)
{
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
return await httpClient.GetStringAsync(url);
}
catch (HttpRequestException) when (attempt < maxRetries)
{
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // 1s, 2s, 4s
await Task.Delay(delay);
}
}

throw new HttpRequestException($"Failed after {maxRetries} retries");
}

// Using Polly
var retryPolicy = Policy
.Handle<HttpRequestException>()
.Or<TaskCanceledException>()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));

var result = await retryPolicy.ExecuteAsync(async () =>
{
var response = await httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
});

AsyncLocal<T>

AsyncLocal<T> stores a value that automatically flows across async boundaries. When you set a value in an async method, all downstream await calls and child tasks can read that same value — even though they may run on different threads.

This is different from ThreadLocal<T>, which is tied to a specific OS thread. Since async continuations can hop between threads, ThreadLocal doesn't work across await boundaries. AsyncLocal solves this by storing the value in the ExecutionContext, which flows with the async operation regardless of which thread it runs on.

Common uses: flowing correlation IDs through a request pipeline, passing tenant context without method parameters, or propagating activity/tracing IDs for observability.

// Store correlation ID that flows through async calls
public static class CorrelationContext
{
private static readonly AsyncLocal<string> _correlationId = new();

public static string Current
{
get => _correlationId.Value ?? string.Empty;
set => _correlationId.Value = value;
}
}

// Middleware sets it
app.Use(async (context, next) =>
{
CorrelationContext.Current = context.TraceIdentifier;
await next(context);
});

// Any downstream async call can read it
public async Task ProcessOrderAsync(Order order)
{
var correlationId = CorrelationContext.Current; // flows automatically
logger.LogInformation("Processing order {Id} with correlation {CorrelationId}",
order.Id, correlationId);
}

When to Use

  • I/O-bound operations — network calls, file I/O, database queries — always use async
  • UI applications — keep the UI thread responsive by offloading work with async
  • Web APIs — async controllers/services scale better under load
  • Concurrent operations — when you need to run multiple independent operations simultaneously with Task.WhenAll
  • Background processingBackgroundService for long-running async work in ASP.NET Core
  • Producer/consumerChannels for decoupled async data pipelines
  • Avoid for CPU-bound computation on the server — Task.Run offloads to the thread pool but doesn't reduce total work; use it only on the client side to keep UI responsive

Common Pitfalls

Async Void

// Bad — async void: exceptions crash the process, can't be awaited
public async void SaveAsync()
{
await _db.SaveChangesAsync(); // exception → unobserved, crashes app
}

// Good — async Task: exceptions propagate to the caller
public async Task SaveAsync()
{
await _db.SaveChangesAsync();
}

// Exception: event handlers are the ONLY valid use of async void
button.Click += async (sender, e) =>
{
await LoadDataAsync(); // async void is unavoidable here
};

Blocking on Async Code

// Bad — causes deadlocks in ASP.NET (non-Core) and UI apps
public string GetData()
{
return GetDataAsync().Result; // blocks the thread
}

public string GetData()
{
return GetDataAsync().GetAwaiter().GetResult(); // also blocks
}

// Good — propagate async all the way up
public async Task<string> GetDataAsync()
{
return await GetDataAsyncCore();
}
Deadlock Scenario

In applications with a SynchronizationContext (ASP.NET non-Core, WPF, WinForms), calling .Result or .Wait() on an async method causes a deadlock: the async method awaits and tries to resume on the captured context, but the context thread is blocked waiting for the result. ASP.NET Core avoids this because it has no SynchronizationContext.

Missing Await

// Bad — fire-and-forget, exceptions are silently swallowed
public Task ProcessAsync()
{
_ = SendEmailAsync(); // not awaited, errors are lost
return SaveAsync();
}

// Good — await all tasks unless you intentionally want fire-and-forget
public async Task ProcessAsync()
{
await SendEmailAsync();
await SaveAsync();
}

// Intentional fire-and-forget (acknowledge with a comment)
public async Task ProcessAsync()
{
_ = SendEmailAsync(); // fire-and-forget: email failure doesn't affect the main flow
await SaveAsync();
}

Accessing Result Before Completion

// Bad — accessing .Result on incomplete Task blocks
public async Task<int> GetValueAsync()
{
var task = FetchAsync();
return task.Result; // blocks until task completes (defeats the purpose)
}

// Good — await the task
public async Task<int> GetValueAsync()
{
return await FetchAsync();
}

Mixing Sync and Async

// Bad — synchronous method calling async with .Result
public List<User> GetUsers()
{
return _dbContext.Users.ToListAsync().Result; // blocking
}

// Good — make the method async
public async Task<List<User>> GetUsersAsync()
{
return await _dbContext.Users.ToListAsync();
}

Key Takeaways

  • async/await transforms methods into state machines that release threads while waiting for I/O.
  • I/O-bound async uses no thread while waiting — the OS handles I/O via completion ports, and a thread pool thread picks up the result when done.
  • CPU-bound async (Task.Run) still uses a thread — use it only to offload from the UI thread, not to "make things faster" on the server.
  • SynchronizationContext determines where continuations run — ASP.NET Core has none, UI apps have one. This is why ConfigureAwait(false) matters in libraries.
  • Always return Task or Task<T> from async methods — never async void (except event handlers).
  • Use CancellationToken for cooperative cancellation and timeouts.
  • Use ConfigureAwait(false) in library code to avoid unnecessary context captures.
  • Use Task.WhenAll for concurrent execution and Task.WhenAny for racing.
  • Never block on async code with .Result or .Wait() — propagate async all the way up.
  • Use ValueTask<T> when the synchronous path is the common case.
  • Use IAsyncEnumerable<T> for streaming data and Channels for producer/consumer patterns.
  • Use TaskCompletionSource<T> to bridge callback/event APIs into the async world.
  • Use Parallel.ForEachAsync for concurrent processing with bounded parallelism.
  • Use BackgroundService for long-running async background tasks in ASP.NET Core.
  • Apply retry with exponential backoff (Polly or manual) for transient failures.

Interview Questions

Q: What is the difference between Task and Thread? Thread is an OS-level construct representing a thread of execution. Task represents an asynchronous operation that may or may not use a thread. For I/O-bound work, a Task completes without holding any thread. async/await works with Task, not Thread.

Q: What happens when you await a Task? The method execution is suspended, and control returns to the caller. The thread is free to do other work. When the awaited Task completes, the state machine resumes execution after the await point.

Q: Why should you avoid async void? async void methods cannot be awaited, and unhandled exceptions crash the process instead of propagating to the caller. They also make testing and composition impossible. The only valid use is event handlers.

Q: What is ConfigureAwait(false) and when should you use it? It tells the awaiter not to capture the current SynchronizationContext and allows the continuation to run on any thread pool thread. Use it in library code to avoid deadlocks and improve performance. Don't use it in UI code when you need to update UI elements after the await.

Q: How do you handle exceptions from Task.WhenAll? Task.WhenAll throws the first exception and the rest are captured in the returned task's Exception property as an AggregateException. To observe all exceptions, access task.Exception.InnerExceptions after catching.

Q: What is the difference between Task.Delay and Thread.Sleep? Thread.Sleep blocks the current thread for the specified duration. Task.Delay returns a Task that completes after the duration without blocking any thread — it uses a timer internally. Always prefer Task.Delay in async code.

Q: How does async I/O work without threads? When you call an async I/O method, the OS kernel initiates the operation and returns immediately. The calling thread is released. When the hardware completes the I/O, it signals the OS via a hardware interrupt. The OS notifies the thread pool via an I/O Completion Port (IOCP), and a thread pool thread resumes the state machine. No thread is blocked during the wait.

Q: What is SynchronizationContext and why does it matter? It's an abstraction that determines where an async continuation runs after await. UI frameworks (WPF, WinForms) use it to ensure continuations run on the UI thread. Legacy ASP.NET used it to ensure continuations run on the request thread. ASP.NET Core has none, so continuations run on any thread pool thread. Understanding this is key to avoiding deadlocks.

Q: What is TaskCompletionSource<T> and when would you use it? It's a manual producer for Task<T>. You use it to bridge non-Task-based asynchronous patterns (events, callbacks, older APIs) into the async/await world. You call TrySetResult, TrySetException, or TrySetCanceled to complete the task when the underlying operation finishes.

Q: How does Parallel.ForEachAsync differ from Task.WhenAll? Task.WhenAll runs all tasks simultaneously with no concurrency limit. Parallel.ForEachAsync processes items with a configurable MaxDegreeOfParallelism, making it better for throttled I/O operations where you want to limit concurrent requests.

Q: What is BackgroundService and when do you use it? It's a base class in .NET for implementing IHostedService with a long-running async loop. Override ExecuteAsync(CancellationToken) to run continuous background work like queue processing, cache refreshing, or event listening. Register it with services.AddHostedService<T>().

Q: What is AsyncLocal<T> and how is it different from ThreadLocal<T>? AsyncLocal<T> stores data that flows across async call contexts — child tasks inherit the value and changes don't propagate back to the parent. ThreadLocal<T> is per-thread and doesn't flow across async boundaries because async continuations can run on different threads.

Q: What are the three async programming models in .NET? APM (.NET 1.x) uses BeginXxx/EndXxx with callbacks — prone to callback hell. EAP (.NET 2.0) uses events — hard to compose. TAP (.NET 4.0+) uses Task + async/await — linear code flow, natural error handling, built-in cancellation. TAP is the only pattern used in modern .NET.

References