Learnixo

.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

C#
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;   // 42

async / await

C#
// 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

C#
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)

C#
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 lock

Interlocked (lock-free for simple operations)

C#
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)

C#
// 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 time

Parallel.ForEachAsync (C# 10+)

C#
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

C#
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

C#
// 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."