.NET & C# Development · Lesson 8 of 229
Deep Dive: Multithreading & Parallelism in C#
Deep Dive: Multithreading & Parallelism in C#
Multithreading lets your application do multiple things at once — but it introduces race conditions, deadlocks, and subtle bugs. This guide covers the building blocks and how to use them safely.
Thread vs Task
using System.Threading;
using System.Threading.Tasks;
// Thread — low-level OS thread, expensive to create
var thread = new Thread(() =>
{
Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId}");
});
thread.Start();
thread.Join(); // wait for it to finish
// Task — lighter, uses ThreadPool, integrates with async/await
var task = Task.Run(() =>
{
Console.WriteLine($"Task on thread {Thread.CurrentThread.ManagedThreadId}");
});
await task;
// Task with return value
Task<int> computation = Task.Run(() => 40 + 2);
int result = await computation; // 42async / await
// async methods return Task (void-equivalent) or Task<T>
public async Task<string> FetchDataAsync(string url)
{
using var client = new HttpClient();
// await suspends the method without blocking the thread
string content = await client.GetStringAsync(url);
return content;
}
// Compose multiple async operations
public async Task<(string, string)> FetchBothAsync()
{
// Sequential — waits for first, then starts second
string a = await FetchDataAsync("https://api.example.com/a");
string b = await FetchDataAsync("https://api.example.com/b");
return (a, b);
}
// Parallel — both start simultaneously (2x faster)
public async Task<(string, string)> FetchParallelAsync()
{
Task<string> taskA = FetchDataAsync("https://api.example.com/a");
Task<string> taskB = FetchDataAsync("https://api.example.com/b");
await Task.WhenAll(taskA, taskB);
return (taskA.Result, taskB.Result);
}CancellationToken
public async Task<string> GetWithTimeoutAsync(CancellationToken ct = default)
{
using var client = new HttpClient();
// Throws OperationCanceledException if token is cancelled
return await client.GetStringAsync("https://api.example.com/data", ct);
}
// Create a token with a 5-second timeout
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
string data = await GetWithTimeoutAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Request timed out");
}
// Manual cancellation
var cts2 = new CancellationTokenSource();
// Cancel from another thread/button click:
cts2.Cancel();Thread Synchronisation
lock (Monitor)
public class ThreadSafeCounter
{
private int _count = 0;
private readonly object _lock = new();
public void Increment()
{
lock (_lock) // only one thread at a time
{
_count++;
}
}
public int Value
{
get { lock (_lock) { return _count; } }
}
}
// Race condition without lock:
var counter = new ThreadSafeCounter();
var tasks = Enumerable.Range(0, 1000)
.Select(_ => Task.Run(counter.Increment))
.ToArray();
await Task.WhenAll(tasks);
Console.WriteLine(counter.Value); // always 1000 with lockInterlocked (lock-free for simple operations)
private int _requests = 0;
// Atomic increment — no lock needed for single int/long operations
Interlocked.Increment(ref _requests);
Interlocked.Add(ref _requests, 5);
int current = Interlocked.Read(ref _requests);SemaphoreSlim (limit concurrency)
// Allow at most 3 concurrent operations
private readonly SemaphoreSlim _semaphore = new(initialCount: 3, maxCount: 3);
public async Task ProcessAsync(int id)
{
await _semaphore.WaitAsync(); // acquire slot
try
{
await DoWorkAsync(id);
}
finally
{
_semaphore.Release(); // always release
}
}
// Rate-limiting 10 parallel calls to a slow API:
var tasks = Enumerable.Range(0, 10)
.Select(i => ProcessAsync(i));
await Task.WhenAll(tasks); // max 3 run at any timeParallel.ForEachAsync (C# 10+)
var items = Enumerable.Range(1, 100).ToList();
await Parallel.ForEachAsync(
items,
new ParallelOptions { MaxDegreeOfParallelism = 4 },
async (item, ct) =>
{
await ProcessItemAsync(item, ct);
}
);Thread-Safe Collections
using System.Collections.Concurrent;
// ConcurrentDictionary — thread-safe dictionary
var dict = new ConcurrentDictionary<string, int>();
dict.TryAdd("key", 1);
dict.AddOrUpdate("key", 1, (k, old) => old + 1);
// ConcurrentQueue — producer/consumer
var queue = new ConcurrentQueue<string>();
queue.Enqueue("message");
if (queue.TryDequeue(out string? msg))
Console.WriteLine(msg);
// Channel — async producer/consumer (preferred in modern .NET)
var channel = System.Threading.Channels.Channel.CreateUnbounded<string>();
// Producer:
await channel.Writer.WriteAsync("hello");
channel.Writer.Complete();
// Consumer:
await foreach (string item in channel.Reader.ReadAllAsync())
Console.WriteLine(item);Common Pitfalls
// PITFALL 1: async void — exceptions are unobserved
// BAD:
async void HandleButton() { await DoSomethingAsync(); }
// GOOD: async Task — exceptions propagate correctly
async Task HandleButton() { await DoSomethingAsync(); }
// PITFALL 2: .Result or .Wait() on Task — deadlocks in synchronisation contexts
// BAD (blocks thread, can deadlock in ASP.NET):
string data = FetchDataAsync().Result;
// GOOD: await all the way up
string data = await FetchDataAsync();
// PITFALL 3: Capturing loop variable in closure
for (int i = 0; i < 5; i++)
{
// BAD: all lambdas capture same 'i' — prints 5,5,5,5,5
// Task.Run(() => Console.WriteLine(i));
// GOOD: capture a copy
int copy = i;
Task.Run(() => Console.WriteLine(copy));
}Interview Answer
"C# concurrency has three layers: Thread (OS-level, expensive), Task (ThreadPool-backed, cheap), and async/await (compiler-generated state machine — suspends without blocking a thread). For I/O-bound work: always use async/await with CancellationToken for timeout and cancellation. For CPU-bound parallel work: Task.Run or Parallel.ForEachAsync with MaxDegreeOfParallelism. Thread safety: use lock for exclusive access to shared state, Interlocked for atomic increments on single integers, SemaphoreSlim to cap concurrent access. Common bugs: async void hides exceptions, calling .Result blocks and can deadlock in ASP.NET, loop variable capture in lambdas captures by reference not value. For producer/consumer patterns:
Channel<T>is the modern idiomatic solution — it's async, efficient, and backpressure-aware."