How do you implement async/await in a custom class or library?
Quick Answer
To implement async in a library, return `Task`/`Task<T>` (or `ValueTask` on hot paths), call genuinely async I/O with `await`, flow a `CancellationToken`, and avoid `async void` and fake-async `Task.Run` wrappers. Don't expose sync-over-async or async-over-sync; provide a real async API and let callers compose it. Name methods with the `Async` suffix and consider `ConfigureAwait(false)` on internal awaits.
Detailed Answer
Implementing async/await in custom classes requires following specific patterns to ensure proper async behavior, exception handling, and resource management. The key is to implement the async pattern correctly and provide both sync and async versions when appropriate.
Basic Async Implementation:
public class DataService
{
private readonly HttpClient httpClient;
public DataService(HttpClient httpClient)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
// Async method with proper naming convention
public async Task<string> GetDataAsync(string url, CancellationToken cancellationToken = default)
{
try
{
// Use ConfigureAwait(false) in library code
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
return content;
}
catch (HttpRequestException ex)
{
// Wrap in more specific exception
throw new DataServiceException($"Failed to retrieve data from {url}", ex);
}
}
// Async method with return value
public async Task<T> GetDataAsync<T>(string url, CancellationToken cancellationToken = default)
{
var json = await GetDataAsync(url, cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<T>(json);
}
// Async method with multiple operations
public async Task<ProcessedData> ProcessDataAsync(string input, CancellationToken cancellationToken = default)
{
// Validate input
if (string.IsNullOrEmpty(input))
throw new ArgumentException("Input cannot be null or empty", nameof(input));
// Step 1: Fetch data
var rawData = await GetDataAsync("https://api.example.com/data", cancellationToken).ConfigureAwait(false);
// Step 2: Process data (CPU-bound work)
var processedData = await Task.Run(() => ProcessRawData(rawData), cancellationToken).ConfigureAwait(false);
// Step 3: Save result
await SaveDataAsync(processedData, cancellationToken).ConfigureAwait(false);
return processedData;
}
private ProcessedData ProcessRawData(string rawData)
{
// CPU-intensive processing
Thread.Sleep(1000); // Simulate processing
return new ProcessedData { Content = rawData.ToUpper(), ProcessedAt = DateTime.UtcNow };
}
private async Task SaveDataAsync(ProcessedData data, CancellationToken cancellationToken)
{
// Simulate saving to database
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
}
public class ProcessedData
{
public string Content { get; set; }
public DateTime ProcessedAt { get; set; }
}
public class DataServiceException : Exception
{
public DataServiceException(string message) : base(message) { }
public DataServiceException(string message, Exception innerException) : base(message, innerException) { }
}
Advanced Async Patterns:
public class FileProcessor
{
private readonly SemaphoreSlim semaphore;
private readonly ILogger logger;
public FileProcessor(int maxConcurrency = 4, ILogger logger = null)
{
this.semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
this.logger = logger;
}
// Async method with concurrency control
public async Task<ProcessingResult> ProcessFileAsync(string filePath, CancellationToken cancellationToken = default)
{
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
logger?.LogInformation($"Starting to process file: {filePath}");
// Validate file exists
if (!File.Exists(filePath))
throw new FileNotFoundException($"File not found: {filePath}");
// Read file asynchronously
var content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
// Process content (CPU-bound)
var processedContent = await Task.Run(() => ProcessContent(content), cancellationToken).ConfigureAwait(false);
// Write result asynchronously
var outputPath = GetOutputPath(filePath);
await File.WriteAllTextAsync(outputPath, processedContent, cancellationToken).ConfigureAwait(false);
logger?.LogInformation($"Successfully processed file: {filePath}");
return new ProcessingResult
{
InputPath = filePath,
OutputPath = outputPath,
ProcessedAt = DateTime.UtcNow,
Success = true
};
}
catch (OperationCanceledException)
{
logger?.LogWarning($"Processing cancelled for file: {filePath}");
throw;
}
catch (Exception ex)
{
logger?.LogError(ex, $"Error processing file: {filePath}");
return new ProcessingResult
{
InputPath = filePath,
ProcessedAt = DateTime.UtcNow,
Success = false,
Error = ex.Message
};
}
finally
{
semaphore.Release();
}
}
// Batch processing with progress reporting
public async Task<BatchProcessingResult> ProcessFilesAsync(
IEnumerable<string> filePaths,
IProgress<ProcessingProgress> progress = null,
CancellationToken cancellationToken = default)
{
var tasks = filePaths.Select(async filePath =>
{
var result = await ProcessFileAsync(filePath, cancellationToken).ConfigureAwait(false);
progress?.Report(new ProcessingProgress { FilePath = filePath, Completed = true });
return result;
});
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
return new BatchProcessingResult
{
TotalFiles = results.Length,
SuccessfulFiles = results.Count(r => r.Success),
FailedFiles = results.Count(r => !r.Success),
Results = results
};
}
private string ProcessContent(string content)
{
// Simulate CPU-intensive processing
Thread.Sleep(500);
return content.ToUpper();
}
private string GetOutputPath(string inputPath)
{
return Path.ChangeExtension(inputPath, ".processed");
}
public void Dispose()
{
semaphore?.Dispose();
}
}
public class ProcessingResult
{
public string InputPath { get; set; }
public string OutputPath { get; set; }
public DateTime ProcessedAt { get; set; }
public bool Success { get; set; }
public string Error { get; set; }
}
public class BatchProcessingResult
{
public int TotalFiles { get; set; }
public int SuccessfulFiles { get; set; }
public int FailedFiles { get; set; }
public ProcessingResult[] Results { get; set; }
}
public class ProcessingProgress
{
public string FilePath { get; set; }
public bool Completed { get; set; }
}
Async Stream Implementation:
public class DataStreamer
{
private readonly HttpClient httpClient;
public DataStreamer(HttpClient httpClient)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
// Async enumerable for streaming data
public async IAsyncEnumerable<DataItem> StreamDataAsync(
string endpoint,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var response = await httpClient.GetAsync(endpoint, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
using var reader = new StreamReader(stream);
string line;
while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null)
{
cancellationToken.ThrowIfCancellationRequested();
if (!string.IsNullOrWhiteSpace(line))
{
var item = ParseDataItem(line);
yield return item;
}
}
}
// Async method with timeout
public async Task<string> GetDataWithTimeoutAsync(string url, TimeSpan timeout)
{
using var cts = new CancellationTokenSource(timeout);
try
{
var response = await httpClient.GetAsync(url, cts.Token).ConfigureAwait(false);
return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
catch (OperationCanceledException) when (cts.Token.IsCancellationRequested)
{
throw new TimeoutException($"Request timed out after {timeout.TotalSeconds} seconds");
}
}
private DataItem ParseDataItem(string line)
{
// Simple parsing logic
var parts = line.Split(',');
return new DataItem
{
Id = parts[0],
Value = parts[1],
Timestamp = DateTime.Parse(parts[2])
};
}
}
public class DataItem
{
public string Id { get; set; }
public string Value { get; set; }
public DateTime Timestamp { get; set; }
}
Async Factory Pattern:
public class DatabaseConnection
{
private readonly string connectionString;
private DatabaseConnection(string connectionString)
{
this.connectionString = connectionString;
}
// Async factory method
public static async Task<DatabaseConnection> CreateAsync(string connectionString)
{
if (string.IsNullOrEmpty(connectionString))
throw new ArgumentException("Connection string cannot be null or empty", nameof(connectionString));
var connection = new DatabaseConnection(connectionString);
// Test the connection asynchronously
await connection.TestConnectionAsync().ConfigureAwait(false);
return connection;
}
private async Task TestConnectionAsync()
{
// Simulate connection test
await Task.Delay(100).ConfigureAwait(false);
// In real implementation, you would test the actual database connection
if (connectionString.Contains("invalid"))
throw new InvalidOperationException("Invalid connection string");
}
public async Task<T> QueryAsync<T>(string sql, CancellationToken cancellationToken = default)
{
// Simulate database query
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
// Return mock data
return default(T);
}
}
Best Practices for Async Implementation:
-
Naming Convention:
- Always suffix async methods with
Async - Use descriptive names that indicate the operation
- Always suffix async methods with
-
Return Types:
- Use
Taskfor void operations - Use
Task<T>for operations that return values - Use
IAsyncEnumerable<T>for streaming data
- Use
-
Cancellation Support:
- Always accept
CancellationTokenparameters - Pass cancellation tokens to all async operations
- Check
cancellationToken.IsCancellationRequestedin loops
- Always accept
-
Exception Handling:
- Let exceptions bubble up naturally
- Wrap low-level exceptions in domain-specific exceptions
- Use
ConfigureAwait(false)in library code
-
Resource Management:
- Use
usingstatements for disposable resources - Implement
IAsyncDisposablewhen needed - Clean up resources in finally blocks
- Use
-
Performance:
- Use
ConfigureAwait(false)in library code - Avoid blocking async methods with
.Resultor.Wait() - Use
Task.Run()only for CPU-bound work
- Use