On this page

Asynchronous programming with async/await in C#

14 min read TextCh. 3 — Modern C#

Why asynchronous programming?

I/O operations (network, disk, database) are slow compared to the CPU. Without async, the thread is blocked waiting for the response, wasting resources:

Synchronous:  Thread → [WAITS 500ms] → Processes result
Asynchronous: Thread → Starts I/O → does other work → Resumes when complete

In ASP.NET Core, a server with blocked threads can handle very few concurrent requests. With async/await, the same server can serve thousands.

Task and Task\

Task represents an asynchronous operation. Task<T> represents an asynchronous operation that returns a value:

// Task — operation with no return value
Task task = Task.Delay(1000);
await task;

// Task<T> — operation that returns a value
Task<int> taskWithResult = Task.Run(() => 42);
int result = await taskWithResult;

// Create already-completed tasks (useful in tests and mocks)
Task        done  = Task.CompletedTask;
Task<int>   val   = Task.FromResult(42);
Task<string> err  = Task.FromException<string>(new Exception("Error"));

async / await

async marks a method as asynchronous. await suspends execution without blocking the thread:

// Without async — synchronous, blocks the thread
static string ReadFileSync(string path)
{
    return File.ReadAllText(path); // blocks the thread until done
}

// With async — asynchronous, frees the thread while waiting
static async Task<string> ReadFileAsync(string path)
{
    return await File.ReadAllTextAsync(path); // frees the thread
}

// Sequential awaiting (slower)
static async Task<(string, string)> SequentialAsync()
{
    var r1 = await GetData1Async(); // wait for R1
    var r2 = await GetData2Async(); // wait for R2 — total = T1 + T2
    return (r1, r2);
}

// Parallel awaiting (faster)
static async Task<(string, string)> ParallelAsync()
{
    var t1 = GetData1Async(); // start T1 without await
    var t2 = GetData2Async(); // start T2 without await
    var (r1, r2) = await Task.WhenAll(t1, t2); // total = max(T1, T2)
    return (r1, r2);
}

Task.WhenAll and Task.WhenAny

// Task.WhenAll — wait for ALL to complete
static async Task ConcurrentExample()
{
    var urls = new[]
    {
        "https://api.github.com/users/dotnet",
        "https://api.github.com/users/microsoft",
        "https://api.github.com/users/google",
    };

    using var http = new HttpClient();
    http.DefaultRequestHeaders.Add("User-Agent", "DotNetApp");

    // Start all requests concurrently
    var tasks = urls.Select(url => http.GetStringAsync(url)).ToList();
    string[] responses = await Task.WhenAll(tasks);

    for (int i = 0; i < responses.Length; i++)
        Console.WriteLine($"URL {i + 1}: {responses[i].Length} bytes");
}

// Task.WhenAny — take the FIRST to complete
static async Task<string> FirstResultAsync(IEnumerable<Task<string>> tasks)
{
    Task<string> first = await Task.WhenAny(tasks);
    return await first; // unwrap to get the result or rethrow exception
}

Asynchronous error handling

// try-catch with await works exactly like synchronous code
static async Task ProcessAsync()
{
    try
    {
        var data = await FetchDataAsync("https://api.example.com/resource");
        Console.WriteLine($"Data: {data.Length} bytes");
    }
    catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
    {
        Console.WriteLine("Resource not found (404)");
    }
    catch (TaskCanceledException)
    {
        Console.WriteLine("Request cancelled or timed out");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Unexpected error: {ex.Message}");
    }
    finally
    {
        Console.WriteLine("Cleanup always runs");
    }
}

// AggregateException with WhenAll
static async Task HandleWhenAllErrorsAsync()
{
    var tasks = new[]
    {
        Task.FromException<string>(new Exception("Task 1 error")),
        Task.FromResult("OK"),
        Task.FromException<string>(new Exception("Task 3 error")),
    };

    try
    {
        await Task.WhenAll(tasks);
    }
    catch
    {
        // Inspect all errors
        foreach (var task in tasks.Where(t => t.IsFaulted))
            Console.WriteLine($"Error: {task.Exception?.InnerException?.Message}");
    }
}

CancellationToken

The standard mechanism for cancelling asynchronous operations:

// Standard pattern for cancellable methods
static async Task<List<Article>> GetArticlesAsync(
    string category,
    CancellationToken ct = default) // always with default
{
    using var http = new HttpClient();
    var url = $"https://api.example.com/articles?category={category}";

    // Pass the token to nested operations
    var response = await http.GetAsync(url, ct);
    response.EnsureSuccessStatusCode();

    var json = await response.Content.ReadAsStringAsync(ct);
    return System.Text.Json.JsonSerializer.Deserialize<List<Article>>(json) ?? new();
}

// Cancel manually
using var cts = new CancellationTokenSource();

// Configure automatic timeout
cts.CancelAfter(TimeSpan.FromSeconds(10));

// Or cancel from another thread
Task.Run(() =>
{
    Thread.Sleep(3000);
    cts.Cancel();
    Console.WriteLine("Cancellation requested");
});

try
{
    var articles = await GetArticlesAsync("technology", cts.Token);
    Console.WriteLine($"Articles: {articles.Count}");
}
catch (OperationCanceledException)
{
    Console.WriteLine("Operation cancelled");
}

Async Streams (IAsyncEnumerable\)

For processing asynchronous data sequences element by element:

// Async stream producer
static async IAsyncEnumerable<Reading> ReadSensorsAsync(
    string deviceId,
    [System.Runtime.CompilerServices.EnumeratorCancellation]
    CancellationToken ct = default)
{
    while (!ct.IsCancellationRequested)
    {
        await Task.Delay(1000, ct); // simulates sensor read
        yield return new Reading(
            DeviceId:  deviceId,
            Value:     Random.Shared.NextDouble() * 100,
            Timestamp: DateTime.UtcNow
        );
    }
}

record Reading(string DeviceId, double Value, DateTime Timestamp);

// Consumer
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await foreach (Reading reading in ReadSensorsAsync("sensor-01", cts.Token))
{
    Console.WriteLine($"[{reading.Timestamp:HH:mm:ss}] Value: {reading.Value:F2}");
}

Practice

  1. Parallel vs sequential: Create a method that simulates 5 HTTP calls with Task.Delay(1000). Measure execution time for sequential vs parallel with Task.WhenAll.
  2. CancellationToken: Implement DownloadWithTimeoutAsync(string url, int seconds) that cancels the operation if it exceeds the time limit.
  3. Async stream: Create a GenerateFibonacciAsync() generator that returns Fibonacci numbers with IAsyncEnumerable<long>, with a 100ms delay between each number.

In the next lesson we will explore records and pattern matching — the most modern C# features for modeling immutable data and expressing conditional logic.

Task.WhenAll for true parallelism
await task1; await task2; executes tasks SEQUENTIALLY. To run them in parallel, start both (without await) and then use await Task.WhenAll(task1, task2). Parallelism can dramatically reduce total execution time when operations are independent.
ConfigureAwait in libraries
In class libraries, use .ConfigureAwait(false) after each await to avoid capturing the synchronization context. In applications (ASP.NET Core, WPF) this is not necessary because the runtime handles the context automatically.
Never use async void
async void is only acceptable for UI event handlers (such as Click). Everywhere else, always return Task. async void methods cannot be awaited, errors they throw cannot be caught easily, and they can crash the application.
using System.Net.Http;

// Async method — always returns Task or Task<T>
static async Task<string> FetchDataAsync(string url)
{
    using var http = new HttpClient();
    // await does not block the thread — yields control and resumes when done
    string json = await http.GetStringAsync(url);
    return json;
}

// Call multiple tasks IN PARALLEL with WhenAll
static async Task RunParallelAsync()
{
    var urls = new[]
    {
        "https://jsonplaceholder.typicode.com/posts/1",
        "https://jsonplaceholder.typicode.com/posts/2",
        "https://jsonplaceholder.typicode.com/posts/3",
    };

    // Start all tasks at once (NO await here)
    Task<string>[] tasks = urls.Select(u => FetchDataAsync(u)).ToArray();

    // Wait for ALL to complete
    string[] results = await Task.WhenAll(tasks);

    Console.WriteLine($"Received {results.Length} responses");
}

// Async entry point
await RunParallelAsync();