DEV Community

Cover image for A No-Nonsense C# Async, Threading & Concurrency Guide
RecurPixel
RecurPixel

Posted on

A No-Nonsense C# Async, Threading & Concurrency Guide

Master modern C# concurrency by understanding the differences between Concurrency (overlapping time periods) and Parallelism (simultaneous execution on multiple cores).
Learn to leverage the Task-based Asynchronous Pattern (TAP) with async/await for non-blocking I/O-bound work, and use the Parallel class and PLINQ for CPU-bound computations.
The guide also details thread safety with lock, Interlocked, and Concurrent Collections, and explores advanced topics like CancellationTokenand Async Streams (IAsyncEnumerable), complete with best practices and common pitfalls.

🎯 The Big Picture: How Everything Connects

┌─────────────────────────────────────────────────────────┐
│                    CONCURRENCY                          │
│  (Doing multiple things in overlapping time periods)    │
│                                                         │
│  ┌──────────────────────┐  ┌──────────────────────┐     │
│  │  PARALLELISM         │  │  ASYNCHRONOUS        │     │
│  │  (True simultaneous  │  │  (Non-blocking,      │     │
│  │   execution on       │  │   waiting without    │     │
│  │   multiple cores)    │  │   blocking threads)  │     │
│  │                      │  │                      │     │
│  │  • Parallel class    │  │  • async/await       │     │
│  │  • PLINQ             │  │  • Task (I/O bound)  │     │
│  │  • Task (CPU bound)  │  │  • TAP pattern       │     │
│  └──────────────────────┘  └──────────────────────┘     │
│                                                         │
│              BOTH Built on: Thread & Task               │
└─────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key Relationships

  • Thread: OS-level execution unit (expensive, limited)
  • Task: High-level abstraction over threads (cheap, efficient)
  • async/await: Language feature to write asynchronous code that looks synchronous
  • Parallel: For CPU-intensive work across multiple cores
  • Concurrency: Umbrella term for all of the above

📚 Core Concepts Explained

Synchronous vs Asynchronous

Synchronous: Code executes line-by-line, blocking until each operation completes.

string data = DownloadData(); // Waits here, blocks thread
ProcessData(data);
Enter fullscreen mode Exit fullscreen mode

Asynchronous: Code can continue without waiting for operation to complete.

Task<string> dataTask = DownloadDataAsync(); // Starts and returns immediately
// Do other work here
string data = await dataTask; // Wait only when you need the result
ProcessData(data);
Enter fullscreen mode Exit fullscreen mode

Blocking vs Non-Blocking

Blocking: Thread sits idle, waiting for operation (wasteful).

  • Example: Thread.Sleep(), Task.Wait(), Task.Result

Non-Blocking: Thread is released to do other work while waiting.

  • Example: await, ContinueWith()

CPU-Bound vs I/O-Bound

CPU-Bound: Work that requires computation (math, processing, encryption).

  • Use: Task.Run(), Parallel.For(), multi-threading

I/O-Bound: Work that waits for external resources (file, network, database).

  • Use: async/await, asynchronous methods

Thread (Legacy Approach)

What it is: Low-level OS thread for executing code concurrently.

Use: Legacy code or when you need absolute control (rare in modern C#).

Modern alternative: Use Task instead.

Key Properties

  • IsAlive - Boolean if thread is running
  • IsBackground - If true, doesn't prevent app from terminating
  • Name - Thread name for debugging
  • Priority - ThreadPriority enum (Lowest to Highest)
  • ThreadState - Current state (Running, Stopped, etc.)
  • ManagedThreadId - Unique ID

Key Methods

  • Start() - Begin execution
  • Join() - Block until thread completes
  • Join(timeout) - Wait with timeout
  • Abort() - ⚠️ DEPRECATED: Force stop (dangerous!)
  • Sleep(ms) - Pause current thread
  • Interrupt() - Interrupt waiting thread

Static Methods

  • Thread.CurrentThread - Get current thread
  • Thread.Sleep(ms) - Pause current thread
  • Thread.Yield() - Give up CPU to other threads

Example (Legacy)

Thread thread = new Thread(() => {
    Console.WriteLine("Running in background");
});
thread.IsBackground = true;
thread.Start();
thread.Join(); // Wait for completion
Enter fullscreen mode Exit fullscreen mode

⚠️ Problems with Threads

  • Expensive (1MB stack per thread)
  • Limited number available
  • Hard to manage
  • No easy way to get return values
  • No built-in error handling

Task (Modern Approach)

What it is: High-level abstraction representing an asynchronous operation.

Use: Default choice for all async/concurrent work in modern C#.

Benefit: Lightweight, composable, better error handling, works with async/await.

Key Properties

  • Status - Current state (Running, RanToCompletion, Faulted, Canceled)
  • IsCompleted - Boolean if finished (success, fault, or cancel)
  • IsCompletedSuccessfully - Boolean if finished successfully
  • IsFaulted - Boolean if threw exception
  • IsCanceled - Boolean if was canceled
  • Exception - AggregateException if faulted
  • Result - Return value (⚠️ blocks until complete)
  • Id - Unique identifier

Key Methods

  • Wait() - ⚠️ Block until complete (avoid, use await)
  • Wait(timeout) - Block with timeout
  • ContinueWith(action) - Chain another task
  • GetAwaiter() - Get awaiter (used by await keyword)
  • ConfigureAwait(false) - Don't capture context

Static Methods

  • Task.Run(action) - Run code on thread pool
  • Task.Delay(ms) - Async delay (like async Sleep)
  • Task.WhenAll(tasks) - Wait for all tasks
  • Task.WhenAny(tasks) - Wait for first task
  • Task.FromResult(value) - Create completed task
  • Task.FromException(ex) - Create faulted task
  • Task.FromCanceled(token) - Create canceled task
  • Task.CompletedTask - Pre-completed task

Task vs Task<T>

  • Task - No return value (like void)
  • Task<T> - Returns value of type T

Example

// Start CPU-bound work
Task<int> task = Task.Run(() => {
    // Heavy computation
    return 42;
});

// Do other work...

// Get result when needed
int result = await task;
Enter fullscreen mode Exit fullscreen mode

async / await (The Game Changer)

What it is: C# language keywords that make asynchronous code look synchronous.

Use: Any I/O-bound operation (file, network, database).

Benefit: Non-blocking, easy to read, automatic error handling.

Rules & Syntax

// Method signature
public async Task<string> DownloadDataAsync()
{
    string data = await httpClient.GetStringAsync(url);
    return data; // Returns Task<string> automatically
}

// Calling async method
string result = await DownloadDataAsync();
Enter fullscreen mode Exit fullscreen mode

Key Rules

  1. Methods with await must be marked async
  2. Async methods should return Task or Task<T> (or ValueTask<T>)
  3. By convention, name async methods with "Async" suffix
  4. Cannot use await in:
    • Synchronous methods
    • catch/finally blocks (pre-C# 6)
    • Lock statements
    • Unsafe code

Return Types

  • Task - Async method with no return value
  • Task<T> - Async method returning T
  • ValueTask<T> - Performance optimization (avoid allocations)
  • void - ⚠️ Only for event handlers (no error handling!)
  • IAsyncEnumerable<T> - Async streams (C# 8.0+)

Example: Async vs Sync

// ❌ Synchronous (blocks thread)
public string GetData()
{
    Thread.Sleep(1000); // Thread sits idle
    return "data";
}

// ✅ Asynchronous (releases thread)
public async Task<string> GetDataAsync()
{
    await Task.Delay(1000); // Thread does other work
    return "data";
}
Enter fullscreen mode Exit fullscreen mode

ConfigureAwait

// Capture context (default) - for UI apps
await SomeMethodAsync();

// Don't capture context - for libraries, better performance
await SomeMethodAsync().ConfigureAwait(false);
Enter fullscreen mode Exit fullscreen mode

TAP (Task-based Asynchronous Pattern) ⭐ Modern Standard

What it is: The recommended pattern for async programming in .NET.

Use: All new asynchronous APIs should follow this pattern.

TAP Rules

  1. Method returns Task or Task<T>
  2. Method name ends with "Async"
  3. Method should have an overload accepting CancellationToken
  4. Should be truly asynchronous (not just wrapping sync code)

Example

public async Task<string> ReadFileAsync(string path, 
    CancellationToken cancellationToken = default)
{
    using (StreamReader reader = new StreamReader(path))
    {
        return await reader.ReadToEndAsync(cancellationToken);
    }
}
Enter fullscreen mode Exit fullscreen mode

TAP vs Other Patterns

Pattern Era Example
TAP Modern (.NET 4.5+) Task<T> MethodAsync()
APM Legacy (.NET 1.0) IAsyncResult BeginMethod() + EndMethod()
EAP Legacy (.NET 2.0) MethodAsync() + MethodCompleted event

Always use TAP for new code.


Parallel Programming (CPU-Bound Work)

What it is: Execute CPU-intensive work across multiple cores simultaneously.

Use: When you have work that can be divided and processed independently.

Don't use for: I/O-bound work (use async/await instead).

Parallel Class

What it is: Static class for parallel loops and invocations.

Use: Process collections in parallel.

Key Methods

  • Parallel.For(from, to, action) - Parallel for loop
  • Parallel.ForEach(collection, action) - Parallel foreach
  • Parallel.Invoke(actions) - Execute multiple actions in parallel

Example

// Sequential (slow)
for (int i = 0; i < 1000; i++)
{
    ProcessItem(i);
}

// Parallel (fast)
Parallel.For(0, 1000, i => {
    ProcessItem(i);
});

// Parallel ForEach
Parallel.ForEach(items, item => {
    ProcessItem(item);
});

// Execute multiple methods in parallel
Parallel.Invoke(
    () => Method1(),
    () => Method2(),
    () => Method3()
);
Enter fullscreen mode Exit fullscreen mode

ParallelOptions

var options = new ParallelOptions
{
    MaxDegreeOfParallelism = 4, // Limit to 4 threads
    CancellationToken = cancellationToken
};

Parallel.ForEach(items, options, item => {
    ProcessItem(item);
});
Enter fullscreen mode Exit fullscreen mode

PLINQ (Parallel LINQ)

What it is: Parallel version of LINQ for query processing.

Use: Parallelize LINQ queries on large datasets.

Key Methods

  • .AsParallel() - Convert to parallel query
  • .AsOrdered() - Maintain order (slower)
  • .WithDegreeOfParallelism(n) - Limit parallelism
  • .WithCancellation(token) - Enable cancellation
  • .ForAll(action) - Parallel action on results

Example

// Sequential LINQ
var results = items
    .Where(x => x.IsValid)
    .Select(x => ProcessItem(x))
    .ToList();

// Parallel LINQ
var results = items
    .AsParallel()
    .Where(x => x.IsValid)
    .Select(x => ProcessItem(x))
    .ToList();

// With options
var results = items
    .AsParallel()
    .WithDegreeOfParallelism(4)
    .WithCancellation(cancellationToken)
    .Where(x => x.IsValid)
    .Select(x => ProcessItem(x))
    .ToList();
Enter fullscreen mode Exit fullscreen mode

Partitioner

What it is: Controls how work is divided among threads.

Use: Optimize parallel performance for specific scenarios.

var partitioner = Partitioner.Create(0, items.Length, 100);
Parallel.ForEach(partitioner, range => {
    for (int i = range.Item1; i < range.Item2; i++)
    {
        ProcessItem(items[i]);
    }
});
Enter fullscreen mode Exit fullscreen mode

CancellationToken & CancellationTokenSource

What it is: Cooperative cancellation mechanism for async operations.

Use: Allow users to cancel long-running operations.

CancellationTokenSource

What it is: Creates and controls cancellation tokens.

Key Properties

  • Token - Get CancellationToken
  • IsCancellationRequested - Boolean if cancellation requested

Key Methods

  • Cancel() - Request cancellation
  • CancelAfter(ms) - Auto-cancel after timeout
  • Dispose() - Release resources

CancellationToken

What it is: Token passed to async methods to check for cancellation.

Key Properties

  • IsCancellationRequested - Boolean if canceled
  • CanBeCanceled - Boolean if can be canceled

Key Methods

  • ThrowIfCancellationRequested() - Throw if canceled
  • Register(callback) - Execute callback on cancellation

Example

CancellationTokenSource cts = new CancellationTokenSource();

// Pass token to async method
Task task = LongRunningOperationAsync(cts.Token);

// Cancel after 5 seconds
cts.CancelAfter(5000);

// Or cancel immediately
// cts.Cancel();

try
{
    await task;
}
catch (OperationCanceledException)
{
    Console.WriteLine("Operation canceled");
}

// In the async method
async Task LongRunningOperationAsync(CancellationToken token)
{
    for (int i = 0; i < 1000; i++)
    {
        token.ThrowIfCancellationRequested();
        await Task.Delay(100, token);
    }
}
Enter fullscreen mode Exit fullscreen mode

ThreadPool

What it is: Managed pool of worker threads reused for tasks.

Use: Understanding what's under the hood (Task uses this internally).

Direct use: Rarely needed in modern C#.

Key Static Methods

  • QueueUserWorkItem(callback) - Queue work
  • GetMaxThreads(out worker, out io) - Get thread limits
  • SetMaxThreads(worker, io) - Set thread limits
  • GetAvailableThreads(out worker, out io) - Get available threads

Example (Legacy)

// Legacy approach
ThreadPool.QueueUserWorkItem(state => {
    Console.WriteLine("Work item");
});

// Modern approach (same thing)
Task.Run(() => {
    Console.WriteLine("Work item");
});
Enter fullscreen mode Exit fullscreen mode

SynchronizationContext

What it is: Abstraction for scheduling work back to original context (UI thread).

Use: Understanding how async/await works with UI frameworks.

Direct use: Rare, handled automatically by async/await.

Key Scenarios

  • UI apps (WPF, WinForms): Await resumes on UI thread
  • ASP.NET (legacy): Await resumes on request context
  • ASP.NET Core: No context (better performance)
  • Console apps: No context

ConfigureAwait Explained

// Capture context (default)
await SomeMethodAsync(); // Resumes on original context (UI thread)

// Don't capture context
await SomeMethodAsync().ConfigureAwait(false); // Can resume on any thread
Enter fullscreen mode Exit fullscreen mode

Rule of thumb:

  • UI code: Use default (capture context)
  • Library code: Use ConfigureAwait(false) (better performance)

Event-Driven Programming

What it is: Respond to events/notifications rather than polling.

Use: UI interactions, file system monitoring, message queues.

Events and Delegates

Delegate

public delegate void NotificationHandler(string message);
Enter fullscreen mode Exit fullscreen mode

Event

public class Publisher
{
    public event NotificationHandler OnNotification;

    public void Notify(string message)
    {
        OnNotification?.Invoke(message);
    }
}

// Usage
Publisher pub = new Publisher();
pub.OnNotification += (msg) => Console.WriteLine(msg);
pub.Notify("Hello!");
Enter fullscreen mode Exit fullscreen mode

Async Event Handlers

public event Func<string, Task> OnNotificationAsync;

public async Task NotifyAsync(string message)
{
    if (OnNotificationAsync != null)
    {
        await OnNotificationAsync(message);
    }
}

// Usage
pub.OnNotificationAsync += async (msg) => {
    await Task.Delay(100);
    Console.WriteLine(msg);
};
Enter fullscreen mode Exit fullscreen mode

Built-in Event Pattern

public class Publisher
{
    public event EventHandler<MessageEventArgs> MessageReceived;

    protected virtual void OnMessageReceived(MessageEventArgs e)
    {
        MessageReceived?.Invoke(this, e);
    }
}

public class MessageEventArgs : EventArgs
{
    public string Message { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Thread Safety & Synchronization

The Problem: Race Conditions

int counter = 0;

// ❌ NOT thread-safe
Parallel.For(0, 1000, i => {
    counter++; // Race condition!
});
Enter fullscreen mode Exit fullscreen mode

Solution 1: lock (Monitor)

What it is: Ensures only one thread can execute code block at a time.

Use: Protect shared state in multi-threaded code.

object lockObj = new object();
int counter = 0;

Parallel.For(0, 1000, i => {
    lock (lockObj)
    {
        counter++; // Safe
    }
});
Enter fullscreen mode Exit fullscreen mode

Key Points

  • Lock on private object, never public
  • Keep locked sections small
  • Never lock on this, typeof(MyClass), or strings
  • Can cause deadlocks if not careful

Solution 2: Interlocked

What it is: Atomic operations for simple types.

Use: When you only need to increment/decrement/compare-exchange.

Key Methods

  • Interlocked.Increment(ref value) - Atomic increment
  • Interlocked.Decrement(ref value) - Atomic decrement
  • Interlocked.Add(ref location, value) - Atomic add
  • Interlocked.Exchange(ref location, value) - Atomic set
  • Interlocked.CompareExchange(ref location, value, comparand) - Atomic compare and set
int counter = 0;

Parallel.For(0, 1000, i => {
    Interlocked.Increment(ref counter); // Safe and fast
});
Enter fullscreen mode Exit fullscreen mode

Solution 3: Concurrent Collections

What it is: Thread-safe collection classes.

Use: Shared collections in multi-threaded code.

Available Collections

  • ConcurrentBag<T> - Unordered collection
  • ConcurrentQueue<T> - FIFO queue
  • ConcurrentStack<T> - LIFO stack
  • ConcurrentDictionary<TKey, TValue> - Thread-safe dictionary
  • BlockingCollection<T> - Blocking producer-consumer
ConcurrentBag<int> bag = new ConcurrentBag<int>();

Parallel.For(0, 1000, i => {
    bag.Add(i); // Safe
});

// ConcurrentDictionary
ConcurrentDictionary<int, string> dict = new();
dict.TryAdd(1, "one");
dict.TryGetValue(1, out string value);
dict.AddOrUpdate(1, "one", (key, old) => "ONE");
Enter fullscreen mode Exit fullscreen mode

Solution 4: SemaphoreSlim

What it is: Limits number of threads accessing a resource.

Use: Rate limiting, connection pooling.

Key Methods

  • Wait() / WaitAsync() - Acquire permit (block if none available)
  • Release() - Release permit
SemaphoreSlim semaphore = new SemaphoreSlim(3); // Max 3 concurrent

await semaphore.WaitAsync(); // Acquire
try
{
    // Do work (max 3 threads here at once)
}
finally
{
    semaphore.Release(); // Always release
}
Enter fullscreen mode Exit fullscreen mode

Solution 5: ReaderWriterLockSlim

What it is: Allows multiple readers or one writer.

Use: When reads are common, writes are rare.

ReaderWriterLockSlim rwLock = new ReaderWriterLockSlim();

// Reading
rwLock.EnterReadLock();
try
{
    // Multiple readers can be here simultaneously
}
finally
{
    rwLock.ExitReadLock();
}

// Writing
rwLock.EnterWriteLock();
try
{
    // Only one writer, blocks all readers
}
finally
{
    rwLock.ExitWriteLock();
}
Enter fullscreen mode Exit fullscreen mode

Async Coordination Primitives

SemaphoreSlim (Async)

See above - preferred for async scenarios.

ManualResetEventSlim / AutoResetEvent

What it is: Signal between threads.

Use: Rarely needed with async/await.

ManualResetEventSlim mre = new ManualResetEventSlim(false);

// Thread 1: Wait for signal
Task.Run(() => {
    mre.Wait(); // Blocks until signaled
    Console.WriteLine("Signaled!");
});

// Thread 2: Send signal
Task.Run(() => {
    Thread.Sleep(1000);
    mre.Set(); // Signal waiting threads
});
Enter fullscreen mode Exit fullscreen mode

TaskCompletionSource<T>

What it is: Manually control Task completion.

Use: Wrap callbacks or events into Task.

TaskCompletionSource<int> tcs = new TaskCompletionSource<int>();

// Simulate async operation
Task.Run(async () => {
    await Task.Delay(1000);
    tcs.SetResult(42); // Complete the task
});

// Wait for result
int result = await tcs.Task;
Enter fullscreen mode Exit fullscreen mode

Key Methods

  • SetResult(result) - Complete successfully
  • SetException(exception) - Complete with error
  • SetCanceled() - Complete as canceled
  • TrySetResult() / TrySetException() / TrySetCanceled() - Try variants

Common Patterns & Best Practices

Pattern 1: Async All The Way

// ✅ Good: Async all the way
public async Task<string> ControllerActionAsync()
{
    var data = await _service.GetDataAsync();
    return await _service.ProcessDataAsync(data);
}

// ❌ Bad: Mixing sync and async
public string ControllerAction()
{
    var data = _service.GetDataAsync().Result; // DEADLOCK RISK!
    return _service.ProcessDataAsync(data).Result;
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Fire and Forget (Careful!)

// ❌ Bad: Exceptions are lost
public void StartWork()
{
    _ = DoWorkAsync(); // Fire and forget
}

// ✅ Better: Handle exceptions
public void StartWork()
{
    _ = Task.Run(async () => {
        try
        {
            await DoWorkAsync();
        }
        catch (Exception ex)
        {
            // Log exception
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Lazy Initialization (Thread-Safe)

private readonly Lazy<ExpensiveObject> _lazyObj = 
    new Lazy<ExpensiveObject>(() => new ExpensiveObject());

public ExpensiveObject Instance => _lazyObj.Value;
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Async Lazy

private readonly AsyncLazy<ExpensiveObject> _asyncLazy = 
    new AsyncLazy<ExpensiveObject>(async () => 
    {
        await Task.Delay(100);
        return new ExpensiveObject();
    });

public Task<ExpensiveObject> GetInstanceAsync() => _asyncLazy.Value;

// Helper class
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;
}
Enter fullscreen mode Exit fullscreen mode

Pattern 5: Progress Reporting

public async Task ProcessDataAsync(IProgress<int> progress)
{
    for (int i = 0; i < 100; i++)
    {
        await Task.Delay(10);
        progress?.Report(i + 1); // Report progress
    }
}

// Usage
var progress = new Progress<int>(percent => {
    Console.WriteLine($"Progress: {percent}%");
});
await ProcessDataAsync(progress);
Enter fullscreen mode Exit fullscreen mode

Pattern 6: Timeout Pattern

public async Task<string> GetDataWithTimeoutAsync(int timeoutMs)
{
    using (var cts = new CancellationTokenSource(timeoutMs))
    {
        try
        {
            return await GetDataAsync(cts.Token);
        }
        catch (OperationCanceledException)
        {
            throw new TimeoutException();
        }
    }
}

// Or using Task.WhenAny
public async Task<string> GetDataWithTimeoutAsync2(int timeoutMs)
{
    var dataTask = GetDataAsync();
    var timeoutTask = Task.Delay(timeoutMs);

    var completedTask = await Task.WhenAny(dataTask, timeoutTask);

    if (completedTask == timeoutTask)
        throw new TimeoutException();

    return await dataTask;
}
Enter fullscreen mode Exit fullscreen mode

Pattern 7: Retry Logic

public async Task<T> RetryAsync<T>(
    Func<Task<T>> operation, 
    int maxAttempts = 3, 
    int delayMs = 1000)
{
    for (int i = 0; i < maxAttempts; i++)
    {
        try
        {
            return await operation();
        }
        catch (Exception) when (i < maxAttempts - 1)
        {
            await Task.Delay(delayMs);
        }
    }
    throw new Exception("Max retry attempts reached");
}

// Usage
var result = await RetryAsync(() => DownloadDataAsync());
Enter fullscreen mode Exit fullscreen mode

Real-World Applications

1. Web API Calls (I/O-Bound)

public async Task<string> GetWeatherAsync(string city)
{
    using (var client = new HttpClient())
    {
        string url = $"https://api.weather.com/{city}";
        return await client.GetStringAsync(url);
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Database Operations (I/O-Bound)

public async Task<List<User>> GetUsersAsync()
{
    using (var context = new MyDbContext())
    {
        return await context.Users.ToListAsync();
    }
}
Enter fullscreen mode Exit fullscreen mode

3. File Processing (I/O-Bound)

public async Task<string> ReadFileAsync(string path)
{
    using (var reader = new StreamReader(path))
    {
        return await reader.ReadToEndAsync();
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Image Processing (CPU-Bound)

public void ProcessImages(List<string> imagePaths)
{
    Parallel.ForEach(imagePaths, imagePath => {
        var image = LoadImage(imagePath);
        var processed = ApplyFilters(image);
        SaveImage(processed);
    });
}
Enter fullscreen mode Exit fullscreen mode

5. Data Analysis (CPU-Bound)

public double[] AnalyzeData(double[] data)
{
    return data
        .AsParallel()
        .Select(x => ComplexCalculation(x))
        .ToArray();
}
Enter fullscreen mode Exit fullscreen mode

6. Responsive UI

private async void Button_Click(object sender, EventArgs e)
{
    // UI remains responsive
    var data = await LoadDataAsync();
    DisplayData(data);
}
Enter fullscreen mode Exit fullscreen mode

7. Background Worker

public class BackgroundWorker
{
    private readonly Timer _timer;

    public BackgroundWorker()
    {
        _timer = new Timer(async _ => await DoWorkAsync(), 
            null, TimeSpan.Zero, TimeSpan.FromMinutes(5));
    }

    private async Task DoWorkAsync()
    {
        // Background work every 5 minutes
    }
}
Enter fullscreen mode Exit fullscreen mode

8. Producer-Consumer Pattern

BlockingCollection<string> queue = new BlockingCollection<string>();

// Producer
Task.Run(() => {
    for (int i = 0; i < 100; i++)
    {
        queue.Add($"Item {i}");
        Thread.Sleep(10);
    }
    queue.CompleteAdding();
});

// Consumer
Task.Run(() => {
    foreach (var item in queue.GetConsumingEnumerable())
    {
        Console.WriteLine($"Processing {item}");
    }
});
Enter fullscreen mode Exit fullscreen mode

⚠️ Common Pitfalls & How to Avoid

1. Deadlock with .Result or .Wait()

// ❌ DEADLOCK in UI or ASP.NET (legacy)
public void Method()
{
    var result = GetDataAsync().Result; // BLOCKS!
}

// ✅ Solution: Use async all the way
public async Task MethodAsync()
{
    var result = await GetDataAsync();
}
Enter fullscreen mode Exit fullscreen mode

2. Async Void

// ❌ Bad: Can't catch exceptions
public async void ProcessDataAsync()
{
    await Task.Delay(1000);
    throw new Exception(); // Lost!
}

// ✅ Good: Return Task
public async Task ProcessDataAsync()
{
    await Task.Delay(1000);
    throw new Exception(); // Can be caught
}

// ⚠️ Exception: Event handlers (only place for async void)
private async void Button_Click(object sender, EventArgs e)
{
    await ProcessDataAsync();
}
Enter fullscreen mode Exit fullscreen mode

3. Not Disposing Resources

// ❌ Bad: Resource leak
var stream = new FileStream("file.txt", FileMode.Open);
await stream.ReadAsync(buffer, 0, buffer.Length);

// ✅ Good: Always dispose
using (var stream = new FileStream("file.txt", FileMode.Open))
{
    await stream.ReadAsync(buffer, 0, buffer.Length);
}

// ✅ C# 8.0+: Using declaration
await using var stream = new FileStream("file.txt", FileMode.Open);
await stream.ReadAsync(buffer, 0, buffer.Length);
Enter fullscreen mode Exit fullscreen mode

4. Capturing Loop Variables

// ❌ Bad: All tasks capture same variable
for (int i = 0; i < 10; i++)
{
    Task.Run(() => Console.WriteLine(i)); // Prints 10 ten times!
}

// ✅ Good: Capture local copy
for (int i = 0; i < 10; i++)
{
    int local = i;
    Task.Run(() => Console.WriteLine(local)); // Prints 0-9
}

// ✅ C# 5.0+: foreach is safe
foreach (var item in items)
{
    Task.Run(() => Console.WriteLine(item)); // Safe
}
Enter fullscreen mode Exit fullscreen mode

5. Parallel.ForEach on UI Collections

// ❌ Bad: UI collections aren't thread-safe
Parallel.ForEach(listBox.Items, item => {
    ProcessItem(item); // CRASH!
});

// ✅ Good: Copy to array first
var items = listBox.Items.Cast<string>().ToArray();
Parallel.ForEach(items, item => {
    ProcessItem(item);
});
Enter fullscreen mode Exit fullscreen mode

6. Starting Too Many Tasks

// ❌ Bad: Creates 1 million tasks!
var tasks = Enumerable.Range(0, 1000000)
    .Select(i => Task.Run(() => DoWork(i)))
    .ToArray();
await Task.WhenAll(tasks);

// ✅ Good: Use Parallel.ForEach (manages thread pool)
Parallel.ForEach(Enumerable.Range(0, 1000000), i => {
    DoWork(i);
});

// ✅ Good: Batch tasks with SemaphoreSlim
var semaphore = new SemaphoreSlim(10); // Max 10 concurrent
var tasks = Enumerable.Range(0, 1000000)
    .Select(async i => {
        await semaphore.WaitAsync();
        try
        {
            await DoWorkAsync(i);
        }
        finally
        {
            semaphore.Release();
        }
    })
    .ToArray();
await Task.WhenAll(tasks);
Enter fullscreen mode Exit fullscreen mode

7. Mixing CPU and I/O Work

// ❌ Bad: Using async for CPU-bound work
public async Task<int> CalculateAsync(int n)
{
    await Task.Delay(1); // Pointless
    return HeavyCalculation(n); // Blocks thread anyway
}

// ✅ Good: Use Task.Run for CPU-bound
public Task<int> CalculateAsync(int n)
{
    return Task.Run(() => HeavyCalculation(n));
}

// ❌ Bad: Using Parallel for I/O-bound
Parallel.ForEach(urls, url => {
    DownloadFile(url).Wait(); // Wastes threads
});

// ✅ Good: Use async for I/O-bound
var tasks = urls.Select(url => DownloadFileAsync(url));
await Task.WhenAll(tasks);
Enter fullscreen mode Exit fullscreen mode

Decision Tree: What to Use When?

Is the work I/O-bound (network, file, database)?
│
├─ YES: Use async/await
│   ├─ Single operation: await SomeMethodAsync()
│   ├─ Multiple operations (sequential): await one, then await another
│   ├─ Multiple operations (parallel): await Task.WhenAll(tasks)
│   └─ Need cancellation: Pass CancellationToken
│
└─ NO: Is it CPU-bound (computation, processing)?
    │
    ├─ YES: Can the work be parallelized?
    │   ├─ YES: Loop over collection?
    │   │   ├─ YES: Use Parallel.ForEach or Parallel.For
    │   │   └─ NO: Use Parallel.Invoke
    │   │
    │   └─ NO: Use Task.Run() to offload to thread pool
    │
    └─ NO: Use synchronous code (normal method)

Special cases:
- Event handling: Event-driven programming
- Rate limiting: SemaphoreSlim
- Shared state: lock, Interlocked, or Concurrent collections
- Background work: Timer + async Task
- Producer-Consumer: BlockingCollection
Enter fullscreen mode Exit fullscreen mode

Performance Comparison

Task Creation Cost

Method Relative Cost Use Case
await existing Task 1x (baseline) Always prefer
Task.CompletedTask ~1x Synchronous async methods
Task.FromResult() ~5x Return cached result
new Task() + Start() ~50x Legacy, avoid
new Thread() ~500x Legacy, avoid

Async vs Sync File I/O (1000 files)

  • Synchronous: ~30 seconds (one at a time)
  • Asynchronous: ~3 seconds (parallel I/O operations)

Parallel.ForEach vs Sequential (CPU-bound, 4 cores)

  • Sequential: 10 seconds
  • Parallel: ~2.5 seconds (4x faster)

Async Streams (C# 8.0+)

What it is: Asynchronously iterate over data as it arrives.

Use: Large datasets, real-time data, pagination.

IAsyncEnumerable<T>

// Producer
public async IAsyncEnumerable<int> GetNumbersAsync()
{
    for (int i = 0; i < 10; i++)
    {
        await Task.Delay(100); // Simulate async work
        yield return i;
    }
}

// Consumer
await foreach (var number in GetNumbersAsync())
{
    Console.WriteLine(number); // Process as they arrive
}

// With cancellation
public async IAsyncEnumerable<int> GetNumbersAsync(
    [EnumeratorCancellation] CancellationToken token = default)
{
    for (int i = 0; i < 10; i++)
    {
        token.ThrowIfCancellationRequested();
        await Task.Delay(100, token);
        yield return i;
    }
}
Enter fullscreen mode Exit fullscreen mode

Real-World Example: Paginated API

public async IAsyncEnumerable<User> GetAllUsersAsync()
{
    int page = 1;
    while (true)
    {
        var users = await GetUserPageAsync(page);
        if (users.Count == 0) break;

        foreach (var user in users)
            yield return user;

        page++;
    }
}

// Usage
await foreach (var user in GetAllUsersAsync())
{
    Console.WriteLine(user.Name); // Process one at a time
}
Enter fullscreen mode Exit fullscreen mode

ValueTask<T> (Performance Optimization)

What it is: Struct-based Task alternative to avoid allocations.

Use: High-performance scenarios where result is often available synchronously.

Caution: More restrictions than Task.

When to Use

// ❌ Use Task<T>: Usually async
public async Task<string> DownloadAsync(string url)
{
    return await httpClient.GetStringAsync(url);
}

// ✅ Use ValueTask<T>: Often cached/synchronous
public ValueTask<User> GetUserAsync(int id)
{
    if (_cache.TryGetValue(id, out var user))
        return new ValueTask<User>(user); // No allocation

    return new ValueTask<User>(LoadUserAsync(id));
}
Enter fullscreen mode Exit fullscreen mode

Restrictions

  • Can only await once
  • Cannot use .Result or .Wait()
  • Cannot await simultaneously from multiple threads
  • If in doubt, use Task<T>

ConfigureAwait Deep Dive

What It Does

// Default: ConfigureAwait(true)
await SomeMethodAsync();
// After await, execution resumes on captured context (UI thread)

// ConfigureAwait(false)
await SomeMethodAsync().ConfigureAwait(false);
// After await, execution resumes on any available thread
Enter fullscreen mode Exit fullscreen mode

When to Use ConfigureAwait(false)

  • ✅ Library code (never touches UI)
  • ✅ ASP.NET Core (no synchronization context)
  • ✅ Background services
  • ✅ Performance-critical code

When NOT to Use ConfigureAwait(false)

  • ❌ UI code (WPF, WinForms, Xamarin)
  • ❌ Code that accesses UI elements after await
  • ❌ ASP.NET Framework (before Core)

Example

// Library code - always use ConfigureAwait(false)
public async Task<string> GetDataAsync()
{
    var response = await httpClient
        .GetAsync(url)
        .ConfigureAwait(false);

    var content = await response.Content
        .ReadAsStringAsync()
        .ConfigureAwait(false);

    return content;
}

// UI code - don't use ConfigureAwait(false)
private async void Button_Click(object sender, EventArgs e)
{
    var data = await GetDataAsync(); // Default: stays on UI thread
    textBox.Text = data; // Safe to access UI
}
Enter fullscreen mode Exit fullscreen mode

Advanced: Custom Awaiter

What it is: Make any type awaitable.

Use: Rare, but shows how await works under the hood.

public static class IntExtensions
{
    public static TaskAwaiter GetAwaiter(this int milliseconds)
    {
        return Task.Delay(milliseconds).GetAwaiter();
    }
}

// Usage
await 1000; // Wait 1 second!
Enter fullscreen mode Exit fullscreen mode

Channels (System.Threading.Channels)

What it is: Modern producer-consumer queue for async scenarios.

Use: Better alternative to BlockingCollection for async code.

Channel Types

  • Channel.CreateUnbounded<T>() - No capacity limit
  • Channel.CreateBounded<T>(capacity) - Limited capacity

Example

var channel = Channel.CreateUnbounded<int>();

// Producer
var writer = channel.Writer;
_ = Task.Run(async () => {
    for (int i = 0; i < 100; i++)
    {
        await writer.WriteAsync(i);
        await Task.Delay(10);
    }
    writer.Complete();
});

// Consumer
var reader = channel.Reader;
await foreach (var item in reader.ReadAllAsync())
{
    Console.WriteLine(item);
}

// Or manual consumption
while (await reader.WaitToReadAsync())
{
    while (reader.TryRead(out var item))
    {
        Console.WriteLine(item);
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing Async Code

Basic Test

[Test]
public async Task TestAsyncMethod()
{
    var result = await GetDataAsync();
    Assert.AreEqual("expected", result);
}
Enter fullscreen mode Exit fullscreen mode

Testing with Timeout

[Test, Timeout(5000)] // 5 second timeout
public async Task TestWithTimeout()
{
    await LongRunningOperationAsync();
}
Enter fullscreen mode Exit fullscreen mode

Testing Cancellation

[Test]
public async Task TestCancellation()
{
    var cts = new CancellationTokenSource();
    var task = LongRunningOperationAsync(cts.Token);

    cts.Cancel();

    await Assert.ThrowsAsync<OperationCanceledException>(
        async () => await task);
}
Enter fullscreen mode Exit fullscreen mode

Testing Parallel Code

[Test]
public void TestThreadSafety()
{
    int counter = 0;
    object lockObj = new object();

    Parallel.For(0, 1000, i => {
        lock (lockObj)
        {
            counter++;
        }
    });

    Assert.AreEqual(1000, counter);
}
Enter fullscreen mode Exit fullscreen mode

Quick Reference Summary

Legacy (Pre-.NET 4.5) ❌

  • Thread - Manual thread management
  • BackgroundWorker - UI threading helper
  • ThreadPool.QueueUserWorkItem - Queue work items
  • APM (Begin/End) - IAsyncResult pattern
  • EAP (Events) - MethodAsync + Completed event

Modern (.NET 4.5+) ✅

  • async/await - Write async code naturally
  • Task/Task<T> - Represent async operations
  • Task.Run() - Offload CPU work
  • Task.WhenAll/WhenAny - Coordinate tasks
  • CancellationToken - Cooperative cancellation
  • Parallel.ForEach/For - Data parallelism
  • PLINQ - Parallel LINQ
  • SemaphoreSlim - Async coordination
  • IProgress<T> - Progress reporting

Cutting Edge (.NET Core 3.0+) 🚀

  • IAsyncEnumerable<T> - Async streams
  • Channels - Modern producer-consumer
  • ValueTask<T> - Zero-allocation async
  • IAsyncDisposable - Async resource disposal

Cheat Sheet: Syntax Quick Look

// Async method signatures
public async Task MethodAsync() { }
public async Task<int> MethodAsync() { }
public async ValueTask<int> MethodAsync() { }
public async IAsyncEnumerable<int> MethodAsync() { }

// Awaiting
var result = await SomeMethodAsync();
await Task.Delay(1000);
await Task.WhenAll(task1, task2);
var first = await Task.WhenAny(task1, task2);

// Fire and forget (avoid!)
_ = SomeMethodAsync();

// Parallel loops
Parallel.For(0, 100, i => { });
Parallel.ForEach(items, item => { });
Parallel.Invoke(() => Method1(), () => Method2());

// PLINQ
var results = items.AsParallel()
    .Where(x => x.IsValid)
    .Select(x => Process(x))
    .ToList();

// Cancellation
var cts = new CancellationTokenSource();
cts.CancelAfter(5000);
await MethodAsync(cts.Token);

// Thread safety
lock (lockObj) { counter++; }
Interlocked.Increment(ref counter);
var dict = new ConcurrentDictionary<int, string>();

// Semaphore
await semaphore.WaitAsync();
try { } finally { semaphore.Release(); }

// Async streams
await foreach (var item in GetItemsAsync()) { }

// Progress
var progress = new Progress<int>(p => Console.WriteLine(p));
await ProcessAsync(progress);
Enter fullscreen mode Exit fullscreen mode

Mental Model: The Right Tool for the Job

Scenario Use This Why
Web API call async/await I/O-bound, non-blocking
Database query async/await I/O-bound, non-blocking
File reading async/await I/O-bound, non-blocking
Image processing Parallel.ForEach CPU-bound, data parallel
Heavy calculation Task.Run() CPU-bound, offload from UI
Data analysis PLINQ CPU-bound, query processing
Rate limiting SemaphoreSlim Control concurrency
Shared counter Interlocked Simple atomic operations
Shared dictionary ConcurrentDictionary Complex shared state
Background timer Timer + async Task Periodic work
Producer-consumer Channel Modern async queue
Event handling event + async void UI interactions
Streaming data IAsyncEnumerable Process as data arrives

Final Tips

  1. Start with async/await - It's the right default for most scenarios
  2. Use Task.Run only for CPU-bound work - Don't wrap I/O operations
  3. Async all the way - Don't block on async code with .Result or .Wait()
  4. Avoid async void - Except for event handlers
  5. Always dispose - Use using for disposable resources
  6. Configure await in libraries - Use .ConfigureAwait(false) in library code
  7. Cancellation tokens everywhere - Support cancellation in async methods
  8. Handle exceptions - Always try-catch in fire-and-forget scenarios
  9. Test thread safety - Use concurrent collections or locks
  10. Profile before parallel - Parallelism has overhead; measure actual gains

Remember: Async doesn't mean parallel, and parallel doesn't mean async!

  • Async = Non-blocking (I/O-bound)
  • Parallel = Simultaneous execution (CPU-bound)

Top comments (0)