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.
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.
// 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.
// 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.
// 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
// 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.
// 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
// 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
// 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.
// 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.
// 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.
// 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).
// 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
// 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
// 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:
// 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.
asyncall the way down — never block on async code with.Resultor.GetAwaiter().GetResult()unless you truly have no choice.ConfigureAwait(false)is best practice in library code; optional in ASP.NET Core apps.Task.WhenAllruns tasks concurrently — much faster than sequential awaits.CancellationTokenflows 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. SemaphoreSlimlimits concurrency when processing large numbers of tasks.
What's Next
- C# LINQ — query results from async operations
- C# Bank Project — async file I/O with JSON serialization
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.