DEV_NET_CORE
GET_STARTED
.NETAsync programming, tasks, cancellation, and concurrency

Async and Await Semantics

Overview

async and await are C# language features used to write asynchronous code in a readable, sequential style. They are most commonly used with the Task-based Asynchronous Pattern, where asynchronous operations are represented by Task, Task<T>, ValueTask, or ValueTask<T>.

Asynchronous programming is important because many modern applications spend time waiting for external work: HTTP calls, database queries, file I/O, message queues, cloud services, timers, and other operations that do not require a thread to actively execute CPU instructions while waiting. In these situations, async and await allow the application to stay responsive and scalable by returning control to the caller while the operation is in progress.

In C#, async does not automatically create a new thread, and await does not block the current thread. Instead, the compiler transforms an async method into a state machine. When execution reaches an incomplete awaited operation, the method is suspended, control returns to the caller, and the remaining code is scheduled as a continuation when the awaited operation completes.

This topic is important for interviews because many C# developers can use async and await syntactically, but interviews often test whether the candidate understands the semantics: how tasks work, what happens before and after await, how exceptions are propagated, why deadlocks happen, when to use Task.WhenAll, when not to use Task.Run, why async void is dangerous, and how cancellation and context capture work.

Core Concepts

Asynchronous Programming in C#

Asynchronous programming allows code to start an operation and continue later when the operation finishes. It is especially useful for I/O-bound work where the application waits for something outside the current process.

Common examples include:

  • Calling an HTTP API
  • Querying a database with Entity Framework Core
  • Reading or writing files
  • Waiting for a message queue operation
  • Calling Azure Storage, Service Bus, Cosmos DB, or other cloud services
  • Keeping a desktop UI responsive while work is pending
  • Handling many concurrent web requests efficiently

Example:

Code
public async Task<string> GetUserJsonAsync(HttpClient httpClient, int userId)
{
    string url = $"https://api.example.com/users/{userId}";
    string json = await httpClient.GetStringAsync(url);
    return json;
}

This method starts an HTTP request and asynchronously waits for the result. While the HTTP request is in progress, the calling thread is not blocked by the await.

async Keyword

The async keyword marks a method, lambda expression, or anonymous method as asynchronous. It enables the use of await inside the method body and tells the compiler to transform the method into an async state machine.

Example:

Code
public async Task<int> CountUsersAsync()
{
    await Task.Delay(100);
    return 10;
}

Important points:

  • async by itself does not make the method run on another thread.
  • An async method starts executing synchronously on the calling thread.
  • Execution continues synchronously until the method reaches the first incomplete await.
  • If there is no await, the method runs synchronously and the compiler issues a warning.
  • Async methods should usually return Task, Task<T>, ValueTask, or ValueTask<T>.
  • async void should generally be avoided except for event handlers.

Common mistake:

Code
public async Task<int> GetNumberAsync()
{
    return 42; // No await. This method runs synchronously.
}

Better:

Code
public Task<int> GetNumberAsync()
{
    return Task.FromResult(42);
}

If the method has no actual asynchronous work, do not mark it async unnecessarily.

await Operator

The await operator asynchronously waits for an awaitable operation to complete.

Example:

Code
public async Task ProcessAsync()
{
    Console.WriteLine("Before await");

    await Task.Delay(1000);

    Console.WriteLine("After await");
}

The key behavior is:

  1. Code before the first incomplete await runs synchronously.
  2. When an incomplete awaited operation is reached, the async method is suspended.
  3. Control returns to the caller.
  4. When the awaited operation completes, the remainder of the method continues as a continuation.
  5. If the awaited operation has already completed, execution can continue synchronously without suspension.

await does not block the thread. This is different from .Result or .Wait(), which block the thread.

Bad:

Code
public string GetData(HttpClient client)
{
    return client.GetStringAsync("https://example.com").Result;
}

Better:

Code
public async Task<string> GetDataAsync(HttpClient client)
{
    return await client.GetStringAsync("https://example.com");
}

Task-Based Asynchronous Pattern

Most modern .NET asynchronous APIs follow the Task-based Asynchronous Pattern. A Task represents an operation that may complete in the future.

Common async return types:

Return typeMeaning
TaskAn async operation that does not return a value
Task<T>An async operation that returns a value of type T
ValueTaskA lower-allocation alternative for some performance-sensitive operations
ValueTask<T>A lower-allocation alternative for operations returning T
voidOnly for async event handlers in most cases
IAsyncEnumerable<T>An asynchronous stream of values

Example using Task:

Code
public async Task SaveAsync(Order order)
{
    await repository.SaveAsync(order);
}

Example using Task<T>:

Code
public async Task<Order?> GetOrderAsync(int id)
{
    return await repository.GetByIdAsync(id);
}

How Async Methods Execute

An async method does not immediately become asynchronous just because it has the async modifier.

Example:

Code
public async Task ExampleAsync()
{
    Console.WriteLine("A");
    await Task.Delay(1000);
    Console.WriteLine("B");
}

Execution flow:

  1. ExampleAsync() is called.
  2. Console.WriteLine("A") runs immediately on the calling thread.
  3. Task.Delay(1000) starts a timer-backed task.
  4. await sees that the task is not complete.
  5. The method is suspended.
  6. A Task is returned to the caller.
  7. After the delay completes, the continuation runs and prints B.

This behavior is important because code before the first incomplete await can still block the caller if it performs expensive synchronous work.

Bad:

Code
public async Task<byte[]> DownloadReportAsync(HttpClient client)
{
    Thread.Sleep(5000); // Blocks before the first await.
    return await client.GetByteArrayAsync("https://example.com/report.pdf");
}

Better:

Code
public async Task<byte[]> DownloadReportAsync(HttpClient client)
{
    await Task.Delay(5000); // Non-blocking delay.
    return await client.GetByteArrayAsync("https://example.com/report.pdf");
}

Compiler-Generated State Machine

The compiler transforms an async method into a state machine. This state machine stores local variables, tracks the current execution point, and resumes execution after awaited operations complete.

You write:

Code
public async Task<int> CalculateAsync()
{
    int value = await GetValueAsync();
    return value * 2;
}

Conceptually, the compiler creates logic similar to:

  • Start method execution.
  • Call GetValueAsync().
  • If the returned task is incomplete, store the current state.
  • Return a task to the caller.
  • Register a continuation.
  • Resume from the saved state when the task completes.
  • Complete the returned task with the result or exception.

Interviewers often ask about this to check whether you understand that async/await is not magic threading. It is compiler-generated continuation logic built around awaitable operations.

I/O-Bound Work vs CPU-Bound Work

async and await are most useful for I/O-bound work.

I/O-bound work waits on external systems:

Code
public async Task<Customer?> GetCustomerAsync(int id)
{
    return await dbContext.Customers.FindAsync(id);
}

CPU-bound work consumes CPU resources:

Code
public decimal CalculatePremium(Policy policy)
{
    // CPU-heavy calculation.
    return calculator.Calculate(policy);
}

For CPU-bound work, async does not make the calculation faster. If you need to move CPU-bound work away from a UI thread, Task.Run can be useful.

Example in a desktop UI app:

Code
private async void CalculateButton_Click(object sender, EventArgs e)
{
    decimal result = await Task.Run(() => CalculateLargeReport());
    resultLabel.Text = result.ToString("N2");
}

In ASP.NET Core request handling, avoid wrapping normal synchronous CPU work in Task.Run just to make it look async. It usually only moves work from one thread pool thread to another and can reduce scalability under load.

Task.Run vs True Async I/O

Task.Run schedules work on the thread pool. It is useful for CPU-bound work that should run on a background thread, especially in client applications.

True async I/O does not require a thread to sit blocked while waiting.

CPU-bound example:

Code
public Task<int> CalculateAsync()
{
    return Task.Run(() => ExpensiveCpuCalculation());
}

I/O-bound example:

Code
public async Task<string> ReadFileAsync(string path)
{
    return await File.ReadAllTextAsync(path);
}

Do not use Task.Run around already asynchronous I/O:

Code
// Poor practice
public Task<string> GetDataAsync(HttpClient client)
{
    return Task.Run(() => client.GetStringAsync("https://example.com"));
}

Better:

Code
public Task<string> GetDataAsync(HttpClient client)
{
    return client.GetStringAsync("https://example.com");
}

Async Return Types

Use the return type that matches the operation.

Use Task when the operation has no result:

Code
public async Task SendEmailAsync(EmailMessage message)
{
    await emailSender.SendAsync(message);
}

Use Task<T> when the operation returns a result:

Code
public async Task<UserDto> GetUserAsync(int id)
{
    User user = await userRepository.GetRequiredAsync(id);
    return new UserDto(user.Id, user.Name);
}

Use ValueTask<T> carefully in performance-sensitive code where the operation frequently completes synchronously:

Code
public ValueTask<User?> TryGetCachedUserAsync(int id)
{
    if (cache.TryGetValue(id, out User? user))
    {
        return ValueTask.FromResult(user);
    }

    return new ValueTask<User?>(database.GetUserAsync(id));
}

Do not use ValueTask<T> everywhere by default. It makes APIs more complex and has usage rules that developers must understand. Task<T> is the default choice for most application code.

Use async void only for event handlers:

Code
private async void SaveButton_Click(object sender, EventArgs e)
{
    await SaveAsync();
}

Avoid this in application services:

Code
public async void SaveUserAsync(User user)
{
    await repository.SaveAsync(user);
}

Better:

Code
public async Task SaveUserAsync(User user)
{
    await repository.SaveAsync(user);
}

async void Pitfalls

async void is dangerous because the caller cannot await it, cannot reliably catch its exceptions, and cannot know when it completes.

Bad:

Code
public async void ProcessOrderAsync(Order order)
{
    await paymentService.ChargeAsync(order);
    await orderRepository.SaveAsync(order);
}

A caller cannot do this:

Code
await ProcessOrderAsync(order); // Not possible because the method returns void.

Better:

Code
public async Task ProcessOrderAsync(Order order)
{
    await paymentService.ChargeAsync(order);
    await orderRepository.SaveAsync(order);
}

Acceptable use:

Code
private async void Button_Click(object sender, EventArgs e)
{
    try
    {
        await ProcessOrderAsync(currentOrder);
    }
    catch (Exception ex)
    {
        ShowError(ex.Message);
    }
}

For event handlers, async void is usually required by the event signature, but the handler should catch and handle exceptions internally.

Exception Semantics

Exceptions in async code are stored in the returned task and rethrown when awaited.

Example:

Code
public async Task<int> DivideAsync(int a, int b)
{
    await Task.Delay(100);
    return a / b;
}

Caller:

Code
try
{
    int result = await DivideAsync(10, 0);
}
catch (DivideByZeroException ex)
{
    Console.WriteLine(ex.Message);
}

The exception is observed at the await point.

Important details:

  • await rethrows the exception from a faulted task.
  • try/catch works naturally with await.
  • A task can be completed successfully, faulted, or canceled.
  • Blocking with .Wait() or .Result can wrap exceptions differently and can cause deadlocks.
  • Task.WhenAll can involve multiple exceptions.

Example with Task.WhenAll:

Code
Task first = CallServiceAAsync();
Task second = CallServiceBAsync();

try
{
    await Task.WhenAll(first, second);
}
catch
{
    if (first.Exception is not null)
    {
        // Inspect first task errors if needed.
    }

    if (second.Exception is not null)
    {
        // Inspect second task errors if needed.
    }

    throw;
}

When multiple tasks fail, the returned task contains the exception information. If you need every exception, inspect the individual tasks or the Exception property of the task returned by Task.WhenAll.

Cancellation Semantics

Cancellation in .NET async code is cooperative. A caller passes a CancellationToken, and the async operation checks or forwards that token.

Example:

Code
public async Task<string> DownloadAsync(HttpClient client, CancellationToken cancellationToken)
{
    return await client.GetStringAsync("https://example.com", cancellationToken);
}

Example with explicit checking:

Code
public async Task ProcessItemsAsync(
    IEnumerable<Item> items,
    CancellationToken cancellationToken)
{
    foreach (Item item in items)
    {
        cancellationToken.ThrowIfCancellationRequested();
        await ProcessItemAsync(item, cancellationToken);
    }
}

Important points:

  • Cancellation is not forced thread termination.
  • The operation must observe the token.
  • Canceled tasks are different from faulted tasks.
  • Cancellation usually results in OperationCanceledException or TaskCanceledException.
  • APIs should accept and pass CancellationToken where cancellation matters.

Common ASP.NET Core example:

Code
[HttpGet("{id:int}")]
public async Task<ActionResult<UserDto>> GetUser(
    int id,
    CancellationToken cancellationToken)
{
    UserDto? user = await userService.GetUserAsync(id, cancellationToken);

    return user is null ? NotFound() : Ok(user);
}

ASP.NET Core can bind the request cancellation token, allowing downstream operations to stop when the client disconnects.

SynchronizationContext and Continuations

When an async method awaits a task, the continuation may try to resume on the captured context.

A context is important in environments such as:

  • WPF
  • Windows Forms
  • older ASP.NET
  • some test frameworks

In UI applications, resuming on the UI context is useful because UI controls usually must be accessed from the UI thread.

Example:

Code
private async void LoadButton_Click(object sender, EventArgs e)
{
    string text = await httpClient.GetStringAsync("https://example.com");
    textBox.Text = text; // Resumes on UI context, so this is safe in typical UI apps.
}

In library code, you often do not need to resume on the original context. ConfigureAwait(false) tells the awaiter that the continuation does not need the captured context.

Code
public async Task<string> GetContentAsync(HttpClient client)
{
    return await client
        .GetStringAsync("https://example.com")
        .ConfigureAwait(false);
}

Practical guidance:

  • Application-level code can usually use normal await.
  • UI code often needs the captured context to update UI controls.
  • General-purpose library code often uses ConfigureAwait(false) to avoid unnecessary context capture.
  • ASP.NET Core does not rely on the same classic request SynchronizationContext as older ASP.NET, but blocking async code can still cause thread pool starvation and scalability issues.

Deadlocks and Sync-over-Async

A common async deadlock happens when code blocks on an async operation using .Result or .Wait() while the async operation tries to resume on the blocked context.

Bad:

Code
public string LoadData()
{
    return LoadDataAsync().Result;
}

public async Task<string> LoadDataAsync()
{
    string data = await httpClient.GetStringAsync("https://example.com");
    return data;
}

In a UI or classic ASP.NET context, this can deadlock:

  1. The caller blocks the context thread with .Result.
  2. The async operation completes.
  3. The continuation tries to resume on the captured context.
  4. The context is blocked waiting for .Result.
  5. Neither side can continue.

Better:

Code
public async Task<string> LoadDataAsync()
{
    return await httpClient.GetStringAsync("https://example.com");
}

Then call it asynchronously all the way up:

Code
string data = await LoadDataAsync();

Best practice: avoid mixing synchronous blocking with async code. This is often called sync-over-async.

Task.WhenAll and Concurrent Async Work

await inside a loop can accidentally run operations sequentially.

Sequential:

Code
foreach (int id in userIds)
{
    User user = await userClient.GetUserAsync(id);
    users.Add(user);
}

Concurrent:

Code
Task<User>[] tasks = userIds
    .Select(id => userClient.GetUserAsync(id))
    .ToArray();

User[] users = await Task.WhenAll(tasks);

Use Task.WhenAll when operations are independent and can safely run at the same time.

Be careful with:

  • Too many concurrent requests
  • Rate limits
  • Database connection pool limits
  • External API throttling
  • Memory pressure
  • Ordering requirements

For large workloads, limit concurrency:

Code
public async Task ProcessAllAsync(IEnumerable<int> ids, CancellationToken cancellationToken)
{
    using SemaphoreSlim semaphore = new(initialCount: 10);

    Task[] tasks = ids.Select(async id =>
    {
        await semaphore.WaitAsync(cancellationToken);
        try
        {
            await ProcessOneAsync(id, cancellationToken);
        }
        finally
        {
            semaphore.Release();
        }
    }).ToArray();

    await Task.WhenAll(tasks);
}

Task.WhenAny and Processing as Tasks Complete

Task.WhenAny completes when any task in a set completes. It is useful for timeouts, racing operations, or processing results as they arrive.

Example:

Code
Task<string> apiCall = client.GetStringAsync("https://example.com");
Task timeout = Task.Delay(TimeSpan.FromSeconds(3));

Task completed = await Task.WhenAny(apiCall, timeout);

if (completed == timeout)
{
    throw new TimeoutException();
}

string result = await apiCall;

Important detail: Task.WhenAny returns the completed task. You still need to await that task to get its result or observe its exception.

Fire-and-Forget Work

Fire-and-forget means starting async work without awaiting it.

Bad:

Code
public Task CreateOrderAsync(Order order)
{
    _ = emailSender.SendConfirmationAsync(order.Email);
    return orderRepository.SaveAsync(order);
}

Problems:

  • Exceptions may go unobserved.
  • The operation may outlive the request scope.
  • Scoped services may be disposed before the work completes.
  • The application may shut down before the work finishes.
  • There is no retry or monitoring strategy.

Better for ASP.NET Core production code:

Code
public async Task CreateOrderAsync(Order order)
{
    await orderRepository.SaveAsync(order);
    await backgroundQueue.EnqueueAsync(new SendOrderEmailMessage(order.Id));
}

Use a background queue, hosted service, message broker, or durable workflow for reliable background processing.

Asynchronous Streams with IAsyncEnumerable<T>

IAsyncEnumerable<T> represents a stream of values produced asynchronously. It is useful when values arrive over time or when loading all values into memory at once is not ideal.

Example:

Code
public async IAsyncEnumerable<string> ReadLinesAsync(string path)
{
    using StreamReader reader = File.OpenText(path);

    while (await reader.ReadLineAsync() is { } line)
    {
        yield return line;
    }
}

Consuming it:

Code
await foreach (string line in ReadLinesAsync("data.txt"))
{
    Console.WriteLine(line);
}

Useful scenarios:

  • Reading large files
  • Streaming database results
  • Streaming API responses
  • Processing messages over time

When cancellation is needed, design the async stream to accept a CancellationToken.

Async Disposal with IAsyncDisposable

Some resources require asynchronous cleanup, such as flushing buffers or closing network resources.

Example:

Code
await using var stream = new FileStream(
    path,
    FileMode.Create,
    FileAccess.Write,
    FileShare.None,
    bufferSize: 4096,
    useAsync: true);

await stream.WriteAsync(buffer);

await using works with types that implement IAsyncDisposable.

This matters in interviews because candidates should know that async code can affect resource cleanup patterns, not only method calls.

Common Mistakes

Common mistakes include:

  • Believing async automatically creates a new thread
  • Blocking on async code with .Result or .Wait()
  • Using async void outside event handlers
  • Forgetting to await a task
  • Fire-and-forget without error handling or lifecycle management
  • Using Task.Run around naturally asynchronous I/O
  • Awaiting inside a loop when Task.WhenAll is appropriate
  • Using unlimited concurrency
  • Ignoring cancellation tokens
  • Catching exceptions too broadly and hiding failures
  • Returning ValueTask<T> everywhere without a real performance reason
  • Assuming ConfigureAwait(false) is always required in application code
  • Doing expensive synchronous work before the first await

Example of a forgotten await:

Code
public async Task SaveAsync(Order order)
{
    repository.SaveAsync(order); // Missing await.
    await auditLog.WriteAsync("Order saved");
}

Better:

Code
public async Task SaveAsync(Order order)
{
    await repository.SaveAsync(order);
    await auditLog.WriteAsync("Order saved");
}

Best Practices

Use these practices in production C# code:

  • Use async APIs for I/O-bound work.
  • Use Task or Task<T> by default.
  • Avoid async void except for event handlers.
  • Avoid .Result, .Wait(), and GetAwaiter().GetResult() in normal application code.
  • Prefer async all the way through the call chain.
  • Pass CancellationToken to I/O and long-running operations.
  • Use Task.WhenAll for independent concurrent operations.
  • Limit concurrency for large workloads.
  • Handle exceptions where meaningful recovery or translation is possible.
  • Use ConfigureAwait(false) mainly in reusable library code when the original context is not needed.
  • Avoid wrapping true async I/O in Task.Run.
  • Use ValueTask<T> only when measurement or API design justifies it.
  • Use background services, queues, or message brokers for reliable background work.
  • Name asynchronous methods with the Async suffix.

Example service method:

Code
public async Task<OrderDto> GetOrderAsync(
    int orderId,
    CancellationToken cancellationToken)
{
    Order order = await orderRepository.GetRequiredAsync(orderId, cancellationToken);

    Customer customer = await customerRepository.GetRequiredAsync(
        order.CustomerId,
        cancellationToken);

    return new OrderDto(
        order.Id,
        customer.Name,
        order.TotalAmount);
}

If the order and customer calls are independent, run them concurrently:

Code
public async Task<OrderSummaryDto> GetOrderSummaryAsync(
    int orderId,
    int customerId,
    CancellationToken cancellationToken)
{
    Task<Order> orderTask = orderRepository.GetRequiredAsync(orderId, cancellationToken);
    Task<Customer> customerTask = customerRepository.GetRequiredAsync(customerId, cancellationToken);

    await Task.WhenAll(orderTask, customerTask);

    Order order = await orderTask;
    Customer customer = await customerTask;

    return new OrderSummaryDto(order.Id, customer.Name, order.TotalAmount);
}

Async in ASP.NET Core

ASP.NET Core applications commonly use async code for database access, HTTP calls, cloud SDKs, and file operations.

Example:

Code
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly AppDbContext dbContext;

    public OrdersController(AppDbContext dbContext)
    {
        this.dbContext = dbContext;
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<OrderDto>> GetById(
        int id,
        CancellationToken cancellationToken)
    {
        OrderDto? order = await dbContext.Orders
            .Where(o => o.Id == id)
            .Select(o => new OrderDto(o.Id, o.CustomerName, o.TotalAmount))
            .SingleOrDefaultAsync(cancellationToken);

        return order is null ? NotFound() : Ok(order);
    }
}

Why async matters in ASP.NET Core:

  • Frees request-handling threads while waiting for I/O
  • Improves scalability under many concurrent requests
  • Prevents thread pool starvation caused by blocking waits
  • Supports cancellation when clients disconnect
  • Integrates naturally with EF Core, HttpClient, and Azure SDKs

Avoid:

Code
OrderDto? order = dbContext.Orders
    .Where(o => o.Id == id)
    .Select(o => new OrderDto(o.Id, o.CustomerName, o.TotalAmount))
    .SingleOrDefaultAsync()
    .Result;

Prefer:

Code
OrderDto? order = await dbContext.Orders
    .Where(o => o.Id == id)
    .Select(o => new OrderDto(o.Id, o.CustomerName, o.TotalAmount))
    .SingleOrDefaultAsync(cancellationToken);

Async in Libraries

Reusable libraries should expose async APIs when they perform asynchronous work.

Example:

Code
public interface IFileStorage
{
    Task UploadAsync(
        string path,
        Stream content,
        CancellationToken cancellationToken = default);
}

Implementation:

Code
public sealed class BlobFileStorage : IFileStorage
{
    private readonly BlobContainerClient container;

    public BlobFileStorage(BlobContainerClient container)
    {
        this.container = container;
    }

    public async Task UploadAsync(
        string path,
        Stream content,
        CancellationToken cancellationToken = default)
    {
        BlobClient blob = container.GetBlobClient(path);

        await blob.UploadAsync(
            content,
            overwrite: true,
            cancellationToken: cancellationToken).ConfigureAwait(false);
    }
}

In library code, ConfigureAwait(false) is often appropriate because the library usually does not need to resume on a caller-specific UI or request context.

Performance Considerations

Async code improves scalability for I/O-bound workloads, but it is not free.

Costs can include:

  • State machine allocation or overhead
  • Task allocation
  • Continuation scheduling
  • Capturing execution context
  • More complex stack traces
  • More complex debugging

Performance guidance:

  • Do not make trivial synchronous code async without reason.
  • Avoid unnecessary async/await when directly returning a task is sufficient and exception semantics are understood.
  • Use ValueTask<T> only for hot paths where synchronous completion is common and measurement supports it.
  • Avoid creating too many tasks for huge collections without throttling.
  • Use true async I/O instead of Task.Run for I/O.

Example where returning a task directly is fine:

Code
public Task<User?> GetUserAsync(int id, CancellationToken cancellationToken)
{
    return userRepository.GetByIdAsync(id, cancellationToken);
}

Example where async/await is useful for exception handling or additional logic:

Code
public async Task<UserDto?> GetUserDtoAsync(int id, CancellationToken cancellationToken)
{
    User? user = await userRepository.GetByIdAsync(id, cancellationToken);

    return user is null
        ? null
        : new UserDto(user.Id, user.Name);
}

Interview Practice

PreviousSwitch Expressions in C#Next UpCancellationToken Propagation