Asynchronous programming in C# enables building responsive, scalable applications by allowing operations to run without blocking threads. Understanding the Task Parallel Library, async/await patterns, and proper exception handling is essential for modern .NET development.
The Task Parallel Library provides the foundation for async in .NET. Tasks represent async operations; TaskCompletionSource creates tasks from non-Task-based async patterns; ValueTask avoids allocation for synchronous-path operations.
Task and Task<T> are promise-like objects that wrap an async operation. Task.Run() schedules CPU-bound work on the ThreadPool. Task.WhenAll/WhenAny compose multiple tasks.
WhenAll awaits multiple tasks concurrently and completes when all finish.
// Run multiple async operations concurrentlyvar task1 = FetchUserAsync(userId);var task2 = FetchOrdersAsync(userId);var task3 = FetchPreferencesAsync(userId);// Wait for all to completeawait Task.WhenAll(task1, task2, task3);// Access resultsvar user = task1.Result;var orders = task2.Result;var prefs = task3.Result;// Or use tuple deconstructionvar (user, orders, prefs) = await Task.WhenAll(task1, task2, task3);
WhenAny returns when the first task completes — useful for timeouts and racing.
// Timeout pattern via Task.WhenAnyvar workTask = DoWorkAsync(token);var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5), token);if (await Task.WhenAny(workTask, timeoutTask) == timeoutTask) throw new TimeoutException();await workTask; // unwrap result or exception
Task.Run offloads CPU-bound work to the ThreadPool.
// CPU-bound work: use Task.Runvar result = await Task.Run(() =>{ // Heavy computation return ComputePrimes(1000000);});// DO NOT use Task.Run for I/O// BAD: await Task.Run(() => httpClient.GetAsync(url));// GOOD: await httpClient.GetAsync(url);
ValueTask<T> eliminates the Task allocation when the result is often synchronously available.
// ValueTask: avoids alloc on hot cache pathpublic ValueTask<User> GetUserAsync(int id){ if (_cache.TryGet(id, out var u)) return new ValueTask<User>(u); // sync path, no allocation return new ValueTask<User>(FetchFromDbAsync(id)); // async path}// Usage is identical to Taskvar user = await GetUserAsync(123);
ValueTask<T> MUST NOT be awaited multiple times — it may be backed by a pooled object that gets reused. Store as Task<T> if you need multiple awaits: Task\<T\> task = valueTask.AsTask();
// Proper cancellation token usagepublic async Task<Data> FetchDataAsync( string url, CancellationToken cancellationToken = default){ // Check at entry cancellationToken.ThrowIfCancellationRequested(); var response = await _httpClient.GetAsync(url, cancellationToken); // Check during processing cancellationToken.ThrowIfCancellationRequested(); var content = await response.Content.ReadAsStringAsync(cancellationToken); return ParseData(content);}// Caller controls cancellationusing var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));try{ var data = await FetchDataAsync(url, cts.Token);}catch (OperationCanceledException){ Console.WriteLine("Operation was cancelled");}
Use ValueTask<T> for methods that are synchronously completed in the hot path (cache hits) and asynchronously completed in the cold path — this eliminates the Task allocation on every cache hit.
The C# compiler transforms every async method into a state machine struct. Each await is a yield point — if the awaitable is already complete, execution continues synchronously (no thread switch).
// This async method:public async Task<string> GetDataAsync(){ var response = await _httpClient.GetAsync(url); return await response.Content.ReadAsStringAsync();}// Is transformed by compiler to approximately:public Task<string> GetDataAsync(){ var stateMachine = new <GetDataAsync>d__0(); stateMachine.<>t__builder = AsyncTaskMethodBuilder<string>.Create(); stateMachine.<>4__this = this; stateMachine.<>1__state = -1; stateMachine.<>t__builder.Start(ref stateMachine); return stateMachine.<>t__builder.Task;}
ConfigureAwait(false) tells the runtime not to resume on the captured SynchronizationContext — critical for library code to avoid deadlocks.
// Library code: always ConfigureAwait(false)public async Task\<T\> ReadAsync\<T\>(string key){ var data = await _cache.GetAsync(key).ConfigureAwait(false); return await Deserialize\<T\>(data).ConfigureAwait(false);}// UI code: use default (captures SynchronizationContext)private async void Button_Click(object sender, EventArgs e){ var data = await _service.GetDataAsync(); // resumes on UI thread textBox.Text = data; // safe to update UI}
Always use ConfigureAwait(false) in library and infrastructure code — without it, your code captures the ASP.NET SynchronizationContext and can cause deadlocks when consumed from synchronous code.
Async streams (IAsyncEnumerable<T>) enable lazy, async iteration over data sources.
// Async stream: lazy DB streamingpublic async IAsyncEnumerable<Order> GetOrdersAsync( [EnumeratorCancellation] CancellationToken ct){ await foreach (var row in _db.QueryAsync(ct)) yield return Map(row);}// Consumer: await foreachawait foreach (var order in _repo.GetOrdersAsync(cancellationToken)){ Console.WriteLine($"Order: {order.Id}"); // Process incrementally, not all at once}
Use IAsyncEnumerable<T> to stream large result sets instead of loading all rows into memory — it provides backpressure and allows consumers to cancel early.
Async exception handling requires understanding how exceptions propagate through await chains, how Task.WhenAll aggregates multiple exceptions, and how to safely observe task exceptions.
await unwraps AggregateException and re-throws the first inner exception — matching synchronous behavior. Task.WhenAll can fail multiple tasks simultaneously; the AggregateException from the Task.Exception property contains all failures.
// Capturing all exceptions from WhenAllvar tasks = jobs.Select(j => ProcessAsync(j)).ToList();Task? allTasks = null;try{ allTasks = Task.WhenAll(tasks); await allTasks;}catch when (allTasks!.IsFaulted){ // allTasks.Exception contains ALL failures foreach (var ex in allTasks.Exception!.InnerExceptions) _log.Error(ex, "Job failed");}// Alternative: check each taskforeach (var task in tasks){ if (task.IsFaulted) { _log.Error(task.Exception, "Task failed"); }}
To capture all exceptions from Task.WhenAll (not just the first one await re-throws), catch on the WhenAll Task variable directly — Task.Exception.InnerExceptions contains them all.