Build Async Pipelines with C# async/await and Task.Run
Build async pipelines in C# using async/await, Task.Run, Task.WhenAll, Task.WhenAny, CancellationTokenSource, Channels, and Parallel.ForEachAsync for concurrent I/O and CPU work.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
C#‘s async/await model provides a compiler-based state machine that transforms async methods into callbacks. Task and Task<T> represent async operations. The following demonstrates how to async/await basics, Task.WhenAll for parallel execution, Task.WhenAny for first-to-complete, CancellationTokenSource for cancellation, System.Threading.Channels for producer-consumer, and Parallel.ForEachAsync for bounded parallel I/O.
When to Use This
- Async I/O operations (HTTP calls, database queries, file I/O)
- Concurrent API calls with result aggregation
- Long-running operations with cancellation support
- Producer-consumer pipelines with backpressure
Prerequisites
- .NET 8+
System.Threading.Channelspackage (included in .NET 8+)
Solution
1. Basic async/await
using System.Diagnostics;
public class BasicAsync
{
public static async Task<string> FetchDataAsync(string url)
{
using var client = new HttpClient();
var response = await client.GetStringAsync(url);
return response;
}
public static async Task RunAsync()
{
var sw = Stopwatch.StartNew();
// Sequential — 2s total
var data1 = await FetchDataAsync("https://httpbin.org/delay/1");
var data2 = await FetchDataAsync("https://httpbin.org/delay/1");
sw.Stop();
Console.WriteLine($"Sequential: {sw.ElapsedMilliseconds}ms");
}
}
2. Task.WhenAll — Parallel Execution
using System.Diagnostics;
public class ParallelAsync
{
public static async Task<string> FetchDataAsync(string url)
{
using var client = new HttpClient();
return await client.GetStringAsync(url);
}
public static async Task RunWhenAllAsync()
{
var sw = Stopwatch.StartNew();
var urls = new[]
{
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1"
};
// Parallel — ~1s total instead of 5s
var tasks = urls.Select(FetchDataAsync);
var results = await Task.WhenAll(tasks);
sw.Stop();
Console.WriteLine($"Parallel: {sw.ElapsedMilliseconds}ms");
Console.WriteLine($"Fetched {results.Length} responses");
}
}
3. Task.WhenAny — First to Complete
public class FirstResultAsync
{
public static async Task<string> FetchWithTimeoutAsync(string url, TimeSpan timeout)
{
using var client = new HttpClient();
var fetchTask = client.GetStringAsync(url);
var timeoutTask = Task.Delay(timeout);
// Returns when either completes
var completedTask = await Task.WhenAny(fetchTask, timeoutTask);
if (completedTask == timeoutTask)
{
throw new TimeoutException($"Request to {url} timed out after {timeout}");
}
return await fetchTask;
}
public static async Task RunWhenAnyAsync()
{
try
{
var result = await FetchWithTimeoutAsync(
"https://httpbin.org/delay/5",
TimeSpan.FromSeconds(2)
);
Console.WriteLine(result);
}
catch (TimeoutException ex)
{
Console.WriteLine(ex.Message);
}
}
}
4. CancellationToken — Cooperative Cancellation
using System.Threading;
public class CancellationAsync
{
public static async Task ProcessWithCancellationAsync(
CancellationToken cancellationToken)
{
for (int i = 0; i < 100; i++)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(100, cancellationToken);
Console.WriteLine($"Processing item {i}");
}
}
public static async Task RunCancellationAsync()
{
using var cts = new CancellationTokenSource();
// Cancel after 500ms
cts.CancelAfter(TimeSpan.FromMilliseconds(500));
try
{
await ProcessWithCancellationAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation was cancelled");
}
}
}
5. Task.Run for CPU-Bound Work
public class CpuBoundAsync
{
public static long ComputeSum(long start, long end)
{
long sum = 0;
for (long i = start; i < end; i++)
{
sum += i;
}
return sum;
}
public static async Task RunCpuBoundAsync()
{
var sw = Stopwatch.StartNew();
// Run CPU-bound work on thread pool — don't block the async state machine
var task1 = Task.Run(() => ComputeSum(0, 50_000_000));
var task2 = Task.Run(() => ComputeSum(50_000_000, 100_000_000));
var results = await Task.WhenAll(task1, task2);
sw.Stop();
Console.WriteLine($"Sum: {results[0] + results[1]}");
Console.WriteLine($"Time: {sw.ElapsedMilliseconds}ms");
}
}
6. System.Threading.Channels — Producer-Consumer
using System.Threading.Channels;
public class ChannelExample
{
public static async Task RunChannelAsync()
{
// Bounded channel with capacity 10
var channel = Channel.CreateBounded<int>(10);
// Producer
async Task ProduceAsync()
{
for (int i = 0; i < 100; i++)
{
await channel.Writer.WriteAsync(i);
Console.WriteLine($"Produced: {i}");
}
channel.Writer.Complete();
}
// Consumer
async Task ConsumeAsync(int workerId)
{
await foreach (var item in channel.Reader.ReadAllAsync())
{
await Task.Delay(50); // Simulate processing
Console.WriteLine($"Worker {workerId} consumed: {item}");
}
}
// Start 3 consumers and 1 producer
var consumers = Enumerable.Range(0, 3)
.Select(id => ConsumeAsync(id));
var producer = ProduceAsync();
await Task.WhenAll(
Task.WhenAll(consumers),
producer
);
Console.WriteLine("Channel processing complete");
}
}
7. Parallel.ForEachAsync — Bounded Parallel I/O
public class ParallelForEachAsync
{
public static async Task RunParallelForEachAsync()
{
var urls = Enumerable.Range(1, 20)
.Select(i => $"https://httpbin.org/delay/1?page={i}")
.ToList();
using var client = new HttpClient();
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 5 // Max 5 concurrent
};
int successCount = 0;
await Parallel.ForEachAsync(urls, options, async (url, ct) =>
{
try
{
var response = await client.GetStringAsync(url, ct);
Interlocked.Increment(ref successCount);
}
catch (Exception ex)
{
Console.WriteLine($"Failed {url}: {ex.Message}");
}
});
Console.WriteLine($"Successfully fetched {successCount}/{urls.Count}");
}
}
8. SemaphoreSlim — Async Rate Limiting
public class SemaphoreAsync
{
private static readonly HttpClient client = new();
public static async Task RunSemaphoreAsync()
{
var urls = Enumerable.Range(1, 50)
.Select(i => $"https://httpbin.org/delay/1?id={i}")
.ToList();
using var semaphore = new SemaphoreSlim(5); // Max 5 concurrent
var sw = Stopwatch.StartNew();
var tasks = urls.Select(async url =>
{
await semaphore.WaitAsync();
try
{
var response = await client.GetStringAsync(url);
return response.Length;
}
finally
{
semaphore.Release();
}
});
var results = await Task.WhenAll(tasks);
sw.Stop();
Console.WriteLine($"Fetched {results.Length} URLs in {sw.ElapsedMilliseconds}ms");
}
}
9. IAsyncEnumerable — Async Streams
public class AsyncStreamExample
{
public static async IAsyncEnumerable<int> GenerateNumbersAsync(
int count,
[System.Runtime.CompilerServices.EnumeratorCancellation]
CancellationToken cancellationToken = default)
{
for (int i = 0; i < count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(100, cancellationToken);
yield return i;
}
}
public static async Task RunAsyncStreamAsync()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
try
{
await foreach (var num in GenerateNumbersAsync(100, cts.Token))
{
Console.WriteLine($"Received: {num}");
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Stream cancelled");
}
}
}
How It Works
async/await: The C# compiler transforms anasyncmethod into a state machine. At eachawait, the method suspends and returns control to the caller. When the awaited task completes, the continuation resumes on a thread pool thread (or the synchronization context if one exists).Task.WhenAll: Creates a task that completes when all input tasks complete. If any task throws,WhenAllre-throws the first exception. Tasks run concurrently on the thread pool.Task.WhenAny: Returns the first task to complete (whether it succeeded, faulted, or was cancelled). Useful for timeouts and racing multiple sources.CancellationTokenSource: Creates cancellation tokens that propagate to async operations.Cancel()triggers cancellation.CancelAfterauto-cancels after a delay. Async methods checkThrowIfCancellationRequested()or pass the token to downstream calls.System.Threading.Channels: A thread-safe queue for async producer-consumer scenarios.WriteAsyncblocks when the channel is full (bounded).ReadAllAsyncreturns anIAsyncEnumerablethat yields items as they arrive.Parallel.ForEachAsync: Introduced in .NET 6. Executes an async delegate for each item with bounded parallelism.MaxDegreeOfParallelismcontrols the concurrency limit.
Variants
TaskCompletionSource — Bridge Callbacks to Tasks
public static Task<string> FromEventAsync()
{
var tcs = new TaskCompletionSource<string>();
// Some event-based API
var timer = new System.Timers.Timer(1000);
timer.Elapsed += (s, e) =>
{
timer.Dispose();
tcs.SetResult("Timer fired");
};
timer.Start();
return tcs.Task;
}
// Usage
var result = await FromEventAsync();
ConfigureAwait(false)
// In library code, use ConfigureAwait(false) to avoid
// capturing the synchronization context (improves performance
// and prevents deadlocks in UI apps)
public static async Task<string> FetchAsync(string url)
{
using var client = new HttpClient();
var response = await client.GetAsync(url).ConfigureAwait(false);
return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
Async Lazy Initialization
public class AsyncLazy<T>
{
private readonly Lazy<Task<T>> _lazy;
public AsyncLazy(Func<Task<T>> factory)
{
_lazy = new Lazy<Task<T>>(() => Task.Run(factory));
}
public Task<T> Value => _lazy.Value;
}
// Usage
var lazyConfig = new AsyncLazy<string>(LoadConfigAsync);
var config = await lazyConfig.Value; // Loads once, caches result
Best Practices
-
For a deeper guide, see Concurrent Patterns with Go Goroutines and Channels.
-
Use
async/awaitover.Resultand.Wait(): Blocking on async code can cause deadlocks, especially in applications with a synchronization context (ASP.NET classic, WPF, WinForms). -
Use
ConfigureAwait(false)in library code: Prevents capturing the synchronization context, improving performance and avoiding deadlocks. -
Pass
CancellationTokenthrough the call chain: Every async method should accept and propagate aCancellationToken. Check it withThrowIfCancellationRequested()in loops. -
Use
Task.Runonly for CPU-bound work: Don’t useTask.Runfor I/O-bound async methods — they already yield the thread. Use it to offload CPU work to the thread pool. -
Prefer
Task.WhenAllover sequential awaits:await task1; await task2;runs sequentially.await Task.WhenAll(task1, task2)runs concurrently. -
Use
SemaphoreSlimfor async rate limiting:SemaphoreSlim.WaitAsyncis async-friendly.Semaphore(the sync version) blocks the thread.
Common Mistakes
- Using
.Resultor.Wait(): Blocks the thread and can deadlock. Alwaysawaitasync operations. - Missing
ConfigureAwait(false)in libraries: Captures the synchronization context unnecessarily, causing performance issues and potential deadlocks. - Not propagating
CancellationToken: If you accept a token but don’t pass it to downstream calls, cancellation won’t work. - Using
async void:async voidmethods can’t be awaited and exceptions crash the process. Useasync Taskinstead. The only exception is event handlers. - Forgetting to
Complete()channel writers: If you don’t callWriter.Complete(),ReadAllAsynchangs forever waiting for more items.
FAQ
What is the difference between Task and Task<T>?
Task represents an async operation with no return value (like void). Task<T> represents an async operation that returns a value of type T. Use Task for fire-and-forget operations and Task<T> when you need the result.
Should I use Task.Run for async I/O methods?
No. Async I/O methods already yield the thread while waiting. Task.Run would just add an unnecessary thread pool hop. Use Task.Run only for CPU-bound work that would block the async state machine.
What is ConfigureAwait(false) and when should I use it?
ConfigureAwait(false) tells the awaiter not to capture the current synchronization context. Use it in library code to improve performance and avoid deadlocks. In UI applications (WPF, WinForms), the synchronization context is the UI thread — capturing it means continuations run on the UI thread, which may not be desirable.
How do channels compare to BlockingCollection?
System.Threading.Channels is async-friendly — WriteAsync and ReadAllAsync don’t block threads. BlockingCollection blocks threads, which is wasteful in async applications. Use channels for async producer-consumer patterns.
What is IAsyncEnumerable and how does it differ from Task<IEnumerable>?
IAsyncEnumerable<T> yields items asynchronously as they become available. Task<IEnumerable<T>> returns all items at once when the task completes. Use IAsyncEnumerable for streaming data (e.g., database rows, real-time events).
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
Related Resources
Compose Asynchronous Pipelines with Java CompletableFuture
Build non-blocking async pipelines in Java using CompletableFuture with thenCompose, thenCombine, allOf, anyOf, exception handling, timeouts, and custom thread pools.
RecipeConcurrent Async Tasks with asyncio.gather and Task Groups
Execute multiple async operations concurrently in Python using asyncio.gather, asyncio.TaskGroup, error handling with return_exceptions, timeouts, and semaphores for rate limiting.
GuideConcurrency Patterns Guide
A guide to common concurrency patterns and what works for writing safe, efficient concurrent code.
RecipeScale Concurrent Applications with Java Virtual Threads
Scale Java applications with virtual threads from Project Loom. Use Thread.ofVirtual, Executors.newVirtualThreadPerTaskExecutor, structured concurrency, and scoped values.
RecipeBuild Async Systems with Rust Tokio Runtime
Build async systems in Rust using the Tokio runtime with tasks, channels, select, synchronization primitives, graceful shutdown, and structured concurrency patterns.
RecipeConcurrent Patterns with Go Goroutines and Channels
Build concurrent systems in Go using goroutines, channels, select statements, worker pools, fan-out/fan-in, pipelines, context cancellation, and rate limiting with tickers.