Back to blog
Backend Systemsintermediate

C# Async/Await: Writing Non-Blocking Code the Right Way

Understand async/await deeply: Task<T>, ConfigureAwait, cancellation tokens, async streams, deadlock pitfalls, and a real async file processor.

Asma HafeezApril 17, 202614 min read
csharpasyncawaitdotnetconcurrencytasks
Share:𝕏

C# Async/Await: Writing Non-Blocking Code the Right Way

Asynchronous programming in C# is one of the most important skills a .NET developer can master. Get it right and your applications are fast, scalable, and responsive. Get it wrong and you face deadlocks, thread starvation, and mysterious performance issues. This guide covers the theory and practice — including a real async file processor at the end.


Why Async? The Thread Starvation Problem

The .NET thread pool has a limited number of threads (typically 2× CPU cores to start, up to thousands). When every I/O operation blocks a thread, you burn through the pool and new requests queue up waiting for a free thread. This is thread starvation.

C#
// BLOCKING — bad for server apps
// This holds a thread while waiting for the network response
public string GetData(string url)
{
    using var client = new HttpClient();
    // Thread is blocked here doing nothing useful
    return client.GetStringAsync(url).Result;  // .Result blocks!
}

// NON-BLOCKING — good
// Thread is released to the pool while waiting for the network
public async Task<string> GetDataAsync(string url)
{
    using var client = new HttpClient();
    // Thread returns to pool here; resumes when response arrives
    return await client.GetStringAsync(url);
}

Under high load with 100 concurrent requests:

  • Blocking: needs 100 threads sitting idle
  • Async: needs maybe 2–4 threads, reused across all requests

Task and Task<T>

Task represents a unit of work that may not be complete yet — the asynchronous equivalent of void. Task<T> represents a future value of type T.

C#
// Task — represents ongoing work, no return value
Task task = Task.Run(() => Console.WriteLine("Working..."));
await task;

// Task<T> — represents a future value
Task<int> futureNumber = Task.Run(() => 42);
int number = await futureNumber;
Console.WriteLine(number);  // 42

// Creating completed tasks (useful in tests and simple cases)
Task alreadyDone = Task.CompletedTask;
Task<int> alreadyFive = Task.FromResult(5);
Task<string> failed = Task.FromException<string>(new Exception("oops"));
Task<int> cancelled = Task.FromCanceled<int>(new CancellationToken(true));

The async/await Keywords

async marks a method as asynchronous. await suspends the method until the awaited task completes, without blocking the thread.

C#
// The pattern
public async Task<string> FetchUserNameAsync(int userId)
{
    // await suspends HERE — thread goes back to pool
    var user = await _repository.GetUserAsync(userId);

    // Execution resumes HERE when GetUserAsync completes
    return user.Name;
}

Rules of async/await

C#
// 1. async methods should return Task, Task<T>, ValueTask, or ValueTask<T>
//    (returning void is only for event handlers)
public async Task DoWorkAsync() { await Task.Delay(100); }
public async Task<int> ComputeAsync() { await Task.Delay(100); return 42; }

// 2. async void — only for event handlers, never call/await from normal code
private async void Button_Click(object sender, EventArgs e)
{
    await DoWorkAsync();  // OK in event handler
}
// NEVER do this in normal async code paths — exceptions cannot be caught

// 3. Name async methods with "Async" suffix by convention
public async Task<Order> GetOrderAsync(int id) { ... }

// 4. An async method that doesn't await anything gets a compiler warning
// and runs synchronously
public async Task<int> NotReallyAsync()  // WARNING
{
    return 42;  // no await — runs synchronously
}
// Better: return Task.FromResult(42);

ConfigureAwait(false)

By default, await captures the current synchronization context and resumes on it. In ASP.NET Core, this doesn't matter much (there's no synchronization context). But in desktop apps (WPF/WinForms) and libraries, it matters.

C#
// Library code — always use ConfigureAwait(false)
public async Task<byte[]> ReadFileAsync(string path)
{
    // .ConfigureAwait(false) = don't capture the synchronization context
    // Allows the continuation to run on any thread pool thread
    using var file = File.OpenRead(path);
    var buffer = new byte[file.Length];
    await file.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false);
    return buffer;
}

// Application code (ASP.NET Core controllers, etc.) — ConfigureAwait(false) is optional
// but using it is still a good habit
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(int id)
{
    var product = await _service.GetProductAsync(id).ConfigureAwait(false);
    return Ok(product);
}

Rule of thumb:

  • Library code: always ConfigureAwait(false)
  • Application code: optional, but no harm

Task.WhenAll and Task.WhenAny

WhenAll — Run Tasks in Parallel

C#
// Sequential — total time ≈ 3 seconds
public async Task SequentialExample()
{
    var user = await GetUserAsync(1);        // 1 second
    var orders = await GetOrdersAsync(1);    // 1 second
    var address = await GetAddressAsync(1);  // 1 second
    // Total: ~3 seconds
}

// Parallel with WhenAll — total time ≈ 1 second
public async Task ParallelExample()
{
    // Start all three — they run concurrently
    var userTask = GetUserAsync(1);
    var ordersTask = GetOrdersAsync(1);
    var addressTask = GetAddressAsync(1);

    // Wait for ALL to complete
    await Task.WhenAll(userTask, ordersTask, addressTask);

    // Now access results
    var user = await userTask;       // already done, just unwraps
    var orders = await ordersTask;
    var address = await addressTask;
    // Total: ~1 second (the slowest operation)
}

// WhenAll with collection of tasks
public async Task<int[]> ProcessAllFilesAsync(string[] filePaths)
{
    var tasks = filePaths.Select(path => CountLinesAsync(path));
    int[] results = await Task.WhenAll(tasks);
    return results;
}

private async Task<int> CountLinesAsync(string path)
{
    var lines = await File.ReadAllLinesAsync(path);
    return lines.Length;
}

WhenAny — First Task to Complete Wins

C#
// Useful for: timeouts, racing multiple data sources, cancellation
public async Task<string> FetchWithTimeoutAsync(string url, int timeoutMs)
{
    var fetchTask = _httpClient.GetStringAsync(url);
    var timeoutTask = Task.Delay(timeoutMs);

    var winner = await Task.WhenAny(fetchTask, timeoutTask);

    if (winner == timeoutTask)
        throw new TimeoutException($"Request to {url} timed out after {timeoutMs}ms.");

    return await fetchTask;  // already completed
}

// Race multiple endpoints, return fastest
public async Task<string> FetchFromAnyAsync(string[] urls)
{
    var tasks = urls.Select(url => _httpClient.GetStringAsync(url)).ToList();
    var first = await Task.WhenAny(tasks);
    return await first;
}

CancellationToken

CancellationToken is how you propagate cancellation through async call chains. Always thread it through your methods.

C#
// The token flows through the call chain
public async Task<List<Product>> GetProductsAsync(
    string category,
    CancellationToken cancellationToken = default)
{
    // Pass token to every awaitable operation
    var response = await _httpClient.GetAsync(
        $"/api/products?category={category}",
        cancellationToken);

    response.EnsureSuccessStatusCode();

    return await response.Content
        .ReadFromJsonAsync<List<Product>>(cancellationToken: cancellationToken)
        ?? new List<Product>();
}

// Checking cancellation manually
public async Task ProcessLargeDatasetAsync(
    IEnumerable<Record> records,
    CancellationToken cancellationToken)
{
    foreach (var record in records)
    {
        // Check for cancellation at each iteration
        cancellationToken.ThrowIfCancellationRequested();

        await ProcessRecordAsync(record, cancellationToken);
    }
}

// Creating a CancellationTokenSource
public async Task RunWithTimeoutAsync()
{
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

    try
    {
        await LongRunningOperationAsync(cts.Token);
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("Operation was cancelled (timed out or cancelled manually).");
    }
}

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

    // Cancel after user presses Ctrl+C
    Console.CancelKeyPress += (_, e) =>
    {
        e.Cancel = true;  // Don't kill the process
        cts.Cancel();
        Console.WriteLine("Cancellation requested...");
    };

    await DoWorkAsync(cts.Token);
}

ValueTask<T>

Task<T> always allocates an object on the heap. For methods that frequently complete synchronously (like cache lookups), ValueTask<T> avoids this allocation.

C#
// Use ValueTask when the hot path is synchronous
public ValueTask<User?> GetUserAsync(int id)
{
    // Cache hit — synchronous, no allocation
    if (_cache.TryGetValue(id, out var cached))
        return new ValueTask<User?>(cached);

    // Cache miss — need actual async work
    return new ValueTask<User?>(FetchUserFromDbAsync(id));
}

private async Task<User?> FetchUserFromDbAsync(int id)
{
    await Task.Delay(10);  // simulate DB call
    return new User { Id = id, Name = "Asma" };
}

// Usage is identical to Task<T>
var user = await GetUserAsync(42);

When to use ValueTask:

  • Method completes synchronously most of the time
  • Called in tight loops where allocation matters
  • Performance-sensitive hot paths

When to stick with Task:

  • General application code
  • Any time you're not sure — Task is simpler and less error-prone

Async Streams (IAsyncEnumerable<T>)

Introduced in C# 8, async streams let you yield items one at a time from an async source — like streaming results from a database.

C#
// Producer: yield items asynchronously
public async IAsyncEnumerable<LogEntry> ReadLogsAsync(
    string filePath,
    [System.Runtime.CompilerServices.EnumeratorCancellation]
    CancellationToken cancellationToken = default)
{
    using var reader = new StreamReader(filePath);
    string? line;

    while ((line = await reader.ReadLineAsync()) != null)
    {
        cancellationToken.ThrowIfCancellationRequested();

        var entry = ParseLogLine(line);
        if (entry is not null)
            yield return entry;
    }
}

// Consumer: await foreach
public async Task ProcessLogsAsync(string filePath)
{
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));

    await foreach (var entry in ReadLogsAsync(filePath, cts.Token))
    {
        if (entry.Level == "ERROR")
            await AlertAsync(entry);
    }
}

// Async stream from a paginated API
public async IAsyncEnumerable<Product> GetAllProductsAsync(
    [System.Runtime.CompilerServices.EnumeratorCancellation]
    CancellationToken cancellationToken = default)
{
    int page = 1;
    bool hasMore = true;

    while (hasMore)
    {
        var response = await _httpClient.GetFromJsonAsync<PagedResult<Product>>(
            $"/api/products?page={page}&size=100",
            cancellationToken);

        if (response is null) break;

        foreach (var product in response.Items)
            yield return product;

        hasMore = response.HasNextPage;
        page++;
    }
}

// Consume the paged stream
await foreach (var product in GetAllProductsAsync())
{
    await IndexProductAsync(product);
}

Deadlock Pitfalls

The most common async mistake: blocking on async code in a context that has a synchronization context (WPF, WinForms, ASP.NET classic).

C#
// DEADLOCK — in WPF or ASP.NET (non-core)
public string GetName()
{
    // GetNameAsync() captures the sync context
    // .Result blocks the thread
    // GetNameAsync() tries to resume on the (now blocked) sync context
    // DEADLOCK
    return GetNameAsync().Result;  // NEVER DO THIS
}

public string GetNameAlsoBad()
{
    return GetNameAsync().GetAwaiter().GetResult();  // also deadlocks
}

// SAFE in ASP.NET Core (no sync context) but still bad practice
// Use async all the way down

// CORRECT: make the caller async too
public async Task<string> GetNameAsync()
{
    await Task.Delay(100);
    return "Asma";
}

// If you truly need sync in a context with no sync context (console, background service):
// Option 1: Task.Run (fires work on thread pool, then block — OK in startup code)
var result = Task.Run(() => GetNameAsync()).GetAwaiter().GetResult();

// Option 2: better — restructure to be async all the way
await GetNameAsync();

Common Async Anti-Patterns

C#
// 1. async void (not event handlers) — exceptions are unobservable
public async void DoWork()  // BAD
{
    await Task.Delay(100);
    throw new Exception("This exception disappears into the void!");
}

// 2. Unnecessary async wrapping
public async Task<string> BadWrapper()  // BAD — pointless state machine
{
    return await GetStringAsync();  // just return the Task directly
}
public Task<string> GoodWrapper()  // GOOD
{
    return GetStringAsync();
}
// Exception: if the method has a try/catch around the await, you need async

// 3. Fire and forget without handling
public void Start()
{
    DoWorkAsync();  // BAD — exception is lost, nobody awaits this
}

// Better: at minimum, log the exception
public void Start()
{
    _ = DoWorkAsync().ContinueWith(t =>
    {
        if (t.IsFaulted)
            _logger.LogError(t.Exception, "Background task failed");
    }, TaskContinuationOptions.OnlyOnFaulted);
}

HttpClient Async Patterns

C#
// HttpClient should be reused (or use IHttpClientFactory in ASP.NET Core)
// Never instantiate HttpClient in a using block in a loop

public class WeatherService
{
    private readonly HttpClient _client;

    public WeatherService(HttpClient client)
    {
        _client = client;
    }

    // GET with deserialization
    public async Task<WeatherForecast?> GetForecastAsync(
        string city,
        CancellationToken ct = default)
    {
        var response = await _client.GetAsync($"/weather/{city}", ct);

        if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
            return null;

        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<WeatherForecast>(
            cancellationToken: ct);
    }

    // POST with JSON body
    public async Task<WeatherAlert> CreateAlertAsync(
        CreateAlertRequest request,
        CancellationToken ct = default)
    {
        var response = await _client.PostAsJsonAsync("/alerts", request, ct);
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<WeatherAlert>(
            cancellationToken: ct)
            ?? throw new InvalidOperationException("Server returned empty body");
    }

    // Download a large file with progress
    public async Task DownloadFileAsync(
        string url,
        string destinationPath,
        IProgress<double>? progress = null,
        CancellationToken ct = default)
    {
        using var response = await _client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
        response.EnsureSuccessStatusCode();

        var totalBytes = response.Content.Headers.ContentLength ?? -1L;
        long downloadedBytes = 0;

        using var stream = await response.Content.ReadAsStreamAsync(ct);
        using var fileStream = File.Create(destinationPath);

        var buffer = new byte[8192];
        int bytesRead;

        while ((bytesRead = await stream.ReadAsync(buffer, ct)) > 0)
        {
            await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct);
            downloadedBytes += bytesRead;

            if (progress != null && totalBytes > 0)
                progress.Report((double)downloadedBytes / totalBytes * 100);
        }
    }
}

Project: Async File Processor

A complete async file processor that reads and processes files concurrently:

C#
// FileProcessor.cs
public record FileProcessingResult(
    string FilePath,
    int LineCount,
    int WordCount,
    int ErrorCount,
    TimeSpan ProcessingTime,
    string? Error = null
)
{
    public bool Success => Error is null;
}

public class AsyncFileProcessor
{
    private readonly int _maxConcurrency;
    private readonly ILogger<AsyncFileProcessor> _logger;

    public AsyncFileProcessor(int maxConcurrency = 4, ILogger<AsyncFileProcessor>? logger = null)
    {
        _maxConcurrency = maxConcurrency;
        _logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<AsyncFileProcessor>.Instance;
    }

    // Process a directory of text files concurrently
    public async Task<IReadOnlyList<FileProcessingResult>> ProcessDirectoryAsync(
        string directoryPath,
        string searchPattern = "*.txt",
        CancellationToken cancellationToken = default)
    {
        if (!Directory.Exists(directoryPath))
            throw new DirectoryNotFoundException($"Directory not found: {directoryPath}");

        var files = Directory.GetFiles(directoryPath, searchPattern, SearchOption.AllDirectories);
        _logger.LogInformation("Found {Count} files to process in {Directory}", files.Length, directoryPath);

        // SemaphoreSlim limits concurrency — don't overwhelm the disk
        var semaphore = new SemaphoreSlim(_maxConcurrency, _maxConcurrency);
        var results = new System.Collections.Concurrent.ConcurrentBag<FileProcessingResult>();

        var tasks = files.Select(async file =>
        {
            await semaphore.WaitAsync(cancellationToken);
            try
            {
                var result = await ProcessFileAsync(file, cancellationToken);
                results.Add(result);
                _logger.LogDebug("Processed: {File} — {Lines} lines", file, result.LineCount);
            }
            finally
            {
                semaphore.Release();
            }
        });

        await Task.WhenAll(tasks);
        return results.OrderBy(r => r.FilePath).ToList();
    }

    // Process a single file
    public async Task<FileProcessingResult> ProcessFileAsync(
        string filePath,
        CancellationToken cancellationToken = default)
    {
        var stopwatch = System.Diagnostics.Stopwatch.StartNew();

        try
        {
            cancellationToken.ThrowIfCancellationRequested();

            if (!File.Exists(filePath))
                return new FileProcessingResult(filePath, 0, 0, 0,
                    stopwatch.Elapsed, $"File not found: {filePath}");

            int lineCount = 0;
            int wordCount = 0;
            int errorCount = 0;

            await foreach (var line in ReadLinesAsync(filePath, cancellationToken))
            {
                lineCount++;

                // Count words (split on whitespace)
                wordCount += line.Split(
                    new char[] { ' ', '\t', '\r', '\n' },
                    StringSplitOptions.RemoveEmptyEntries).Length;

                // Count lines containing error keywords
                if (line.Contains("ERROR", StringComparison.OrdinalIgnoreCase) ||
                    line.Contains("EXCEPTION", StringComparison.OrdinalIgnoreCase) ||
                    line.Contains("FATAL", StringComparison.OrdinalIgnoreCase))
                {
                    errorCount++;
                }
            }

            stopwatch.Stop();
            return new FileProcessingResult(filePath, lineCount, wordCount, errorCount, stopwatch.Elapsed);
        }
        catch (OperationCanceledException)
        {
            throw;  // always re-throw cancellation
        }
        catch (Exception ex)
        {
            stopwatch.Stop();
            _logger.LogError(ex, "Failed to process file: {File}", filePath);
            return new FileProcessingResult(filePath, 0, 0, 0, stopwatch.Elapsed, ex.Message);
        }
    }

    // Async stream of lines from a file
    private async IAsyncEnumerable<string> ReadLinesAsync(
        string filePath,
        [System.Runtime.CompilerServices.EnumeratorCancellation]
        CancellationToken cancellationToken = default)
    {
        using var reader = new StreamReader(filePath);
        string? line;

        while ((line = await reader.ReadLineAsync().WaitAsync(cancellationToken)) != null)
        {
            yield return line;
        }
    }

    // Save results to JSON
    public async Task SaveResultsAsync(
        IReadOnlyList<FileProcessingResult> results,
        string outputPath,
        CancellationToken cancellationToken = default)
    {
        var summary = new
        {
            GeneratedAt = DateTime.UtcNow,
            TotalFiles = results.Count,
            SuccessCount = results.Count(r => r.Success),
            FailureCount = results.Count(r => !r.Success),
            TotalLines = results.Sum(r => r.LineCount),
            TotalWords = results.Sum(r => r.WordCount),
            TotalErrors = results.Sum(r => r.ErrorCount),
            TotalProcessingTime = results.Aggregate(TimeSpan.Zero, (s, r) => s + r.ProcessingTime),
            Files = results
        };

        var json = System.Text.Json.JsonSerializer.Serialize(summary, new System.Text.Json.JsonSerializerOptions
        {
            WriteIndented = true
        });

        await File.WriteAllTextAsync(outputPath, json, cancellationToken);
        _logger.LogInformation("Results saved to {Path}", outputPath);
    }
}

// Program.cs
var processor = new AsyncFileProcessor(maxConcurrency: 8);
using var cts = new CancellationTokenSource();

// Allow Ctrl+C to cancel gracefully
Console.CancelKeyPress += (_, e) =>
{
    e.Cancel = true;
    Console.WriteLine("\nCancelling...");
    cts.Cancel();
};

try
{
    Console.WriteLine("Processing files...");
    var sw = System.Diagnostics.Stopwatch.StartNew();

    var results = await processor.ProcessDirectoryAsync(
        @"C:\Logs",
        "*.log",
        cts.Token);

    sw.Stop();

    // Print summary
    Console.WriteLine($"\n=== Processing Complete ({sw.Elapsed.TotalSeconds:F2}s) ===");
    Console.WriteLine($"Files processed: {results.Count}");
    Console.WriteLine($"Successful:      {results.Count(r => r.Success)}");
    Console.WriteLine($"Failed:          {results.Count(r => !r.Success)}");
    Console.WriteLine($"Total lines:     {results.Sum(r => r.LineCount):N0}");
    Console.WriteLine($"Total words:     {results.Sum(r => r.WordCount):N0}");
    Console.WriteLine($"Total errors:    {results.Sum(r => r.ErrorCount):N0}");

    // Top 5 largest files
    Console.WriteLine("\nTop 5 largest files:");
    foreach (var r in results.OrderByDescending(r => r.LineCount).Take(5))
        Console.WriteLine($"  {Path.GetFileName(r.FilePath)}: {r.LineCount:N0} lines");

    await processor.SaveResultsAsync(results, "processing-report.json", cts.Token);
    Console.WriteLine("\nReport saved to processing-report.json");
}
catch (OperationCanceledException)
{
    Console.WriteLine("Processing cancelled.");
}

Key Takeaways

  • Async prevents thread starvation — threads are released to the pool during I/O waits.
  • async all the way down — never block on async code with .Result or .GetAwaiter().GetResult() unless you truly have no choice.
  • ConfigureAwait(false) is best practice in library code; optional in ASP.NET Core apps.
  • Task.WhenAll runs tasks concurrently — much faster than sequential awaits.
  • CancellationToken flows through every async method — always accept and pass it.
  • ValueTask<T> avoids allocations for synchronous hot paths — use it in performance-sensitive code.
  • Async streams (IAsyncEnumerable<T>) let you stream data one item at a time without buffering everything in memory.
  • SemaphoreSlim limits concurrency when processing large numbers of tasks.

What's Next

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.