Skip to main content

31. Multithreading in C#

Level: Advanced
Goal: Learn to run multiple tasks at the same time - speed up work, keep UI responsive.

Imagine a school server processing 100 student fee payments. Running one at a time takes 100 seconds. Running all at the same time (multithreading) takes 1 second.

Modern C# uses Task instead of Thread - simpler and faster.

ℹ️ What You'll Learn
  • Thread vs Task - why Task is preferred
  • Creating tasks with Task.Run
  • Async/await for non-blocking operations
  • CancellationToken for stopping long operations
  • Thread safety and race conditions
  • lock keyword for synchronization
  • Interlocked operations for atomic changes
  • Deadlock prevention
  • Best practices for concurrent code

Quick Definitions

  • Multithreading - Running multiple operations at same time
  • Thread - OS-level worker (1-2MB, expensive, rarely used now)
  • Task - Lightweight abstraction over thread pool (preferred)
  • Thread pool - Reusable threads managed by .NET runtime
  • Race condition - Bug where output depends on timing (unpredictable)
  • Thread safety - Code works correctly with multiple threads
  • Lock - Mutual exclusion: one thread at a time (prevent race condition)
  • Interlocked - Atomic operation: increment/decrement safely
  • CancellationToken - Request to stop long-running operation
  • Deadlock - Threads stuck waiting for each other

Multithreading Concepts Reference

ConceptPurposeThread SafetyUse WhenExample
ThreadOS-level thread, manual managementNot inherently safeNeed low-level control (rare)new Thread(action).Start()
TaskAbstraction over ThreadPoolNot inherently safeBackground work, parallel opsTask.Run(() => Work())
async/awaitNon-blocking async operationsSafe with proper lockingI/O-bound (files, network, DB)await File.ReadAsync()
lockMutual exclusion, one thread at a timeThread-safeShared mutable statelock (_lock) { _count++; }
InterlockedAtomic operations (thread-safe)Thread-safeSimple increments/assignmentsInterlocked.Increment(ref _count)
CancellationTokenRequest cancellationSafeLong operations, timeoutsawait op.Async(ct)
Task.WhenAllWait for multiple tasksSafe (coordination)Parallel independent workawait Task.WhenAll(t1, t2, t3)
MonitorLock with TryEnter supportThread-safeTimeout on lock attemptMonitor.TryEnter(_lock, 1000)

Thread vs Task vs async/await

Thread: Low-level OS thread. Expensive to create (1-2MB memory per thread). Manual start/stop.

Task: Abstraction over thread pool. Reuses threads, lightweight, managed by .NET runtime.

async/await: Non-blocking operations. Frees thread during I/O (file, network, database). Thread handles other work while waiting.

// Wrong Slow - creates new OS thread, wasteful
new Thread(() => DoWork()).Start();

// OK Fast - reuses thread from pool
Task.Run(() => DoWork());

// OK Best for I/O - doesn't block thread
await File.ReadAllTextAsync(path); // Thread free during disk I/O

Rule: Use Task.Run for background CPU work. Use async/await for I/O operations. Never use Thread in modern .NET.

Task - Preferred Approach

// Fire and forget - don't wait
Task.Run(() => Console.WriteLine($"Running on thread {Thread.CurrentThread.ManagedThreadId}"));

// Task with result - capture and await
Task<List<Student>> loadTask = Task.Run(() => LoadStudentsFromDatabase());
List<Student> students = await loadTask;

// Multiple independent operations in parallel
var generateTask = Task.Run(() => GenerateAllReports(students));
var backupTask = Task.Run(() => BackupDatabase());
var emailTask = Task.Run(() => SendNotifications());

await Task.WhenAll(generateTask, backupTask, emailTask); // wait for all
Console.WriteLine("All background jobs done.");

// Wait for fastest result
var result = await Task.WhenAny(task1, task2, task3);
Console.WriteLine("First completed task done.");

Race Conditions and Thread Safety

When multiple threads access same variable, results are unpredictable. Each thread may read stale value, overwrite others' changes.

// Wrong RACE CONDITION - data corruption
public class StudentResultCounter
{
private int _passCount = 0; // Shared state, no synchronization

public void RecordPass()
{
_passCount++; // Read-modify-write is NOT atomic
}

public int PassCount
{
get { return _passCount; }
}
}

// 100 threads incrementing same counter
var counter = new StudentResultCounter();
var tasks = Enumerable.Range(0, 100)
.Select(_ => Task.Run(() => counter.RecordPass()))
.ToArray();

await Task.WhenAll(tasks);
Console.WriteLine($"Final count: {counter.PassCount}"); // Expected 100, might be 47!

Problem: _passCount++ takes 3 steps: (1) read current value, (2) add 1, (3) write back. Between steps, another thread can overwrite. Result: lost increments.

Solutions:

  • lock (_object) - prevent multiple threads entering same code block
  • Interlocked.Increment(ref _count) - atomic increment (faster than lock for simple ops)
  • ReaderWriterLockSlim - allow multiple readers, exclusive writer
// OK THREAD-SAFE with lock
public class SafeStudentResultCounter
{
private int _passCount = 0;
private readonly object _lock = new();

public void RecordPass()
{
lock (_lock)
{
_passCount++; // Only one thread at a time
}
}
}

// OK THREAD-SAFE with Interlocked (faster for simple increment)
public class AtomicStudentResultCounter
{
private int _passCount = 0;

public void RecordPass()
{
Interlocked.Increment(ref _passCount); // Atomic, no lock needed
}
}

CancellationToken - Cancel Long Operations

static async Task ProcessAllStudentsAsync(
List<Student> students,
IProgress<int>? progress = null,
CancellationToken ct = default)
{
int processed = 0;

foreach (var student in students)
{
ct.ThrowIfCancellationRequested(); // check before each student

await ProcessStudentAsync(student, ct);
processed++;

// Report progress
progress?.Report((int)((double)processed / students.Count * 100));
}
}

// With timeout - cancel after 30 seconds
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));

var progress = new Progress<int>(pct => Console.WriteLine($"Progress: {pct}%"));

try
{
await ProcessAllStudentsAsync(students, progress, cts.Token);
Console.WriteLine("All students processed.");
}
catch (OperationCanceledException)
{
Console.WriteLine("Processing cancelled (timeout).");
}

Thread Safety - Shared State

// [X] Not thread-safe - race condition
public class AttendanceCounter
{
private int _present = 0;
private int _absent = 0;

public void Mark(bool isPresent)
{
if (isPresent) _present++; // NOT atomic - race condition
else _absent++;
}
}

// OK Thread-safe with lock
public class SafeAttendanceCounter
{
private int _present = 0;
private int _absent = 0;
private readonly object _lock = new();

public void Mark(bool isPresent)
{
lock (_lock) // only one thread enters at a time
{
if (isPresent) _present++;
else _absent++;
}
}

public (int present, int absent) GetCounts()
{
lock (_lock) { return (_present, _absent); }
}
}

// OK Interlocked - faster for simple increments
public class AtomicCounter
{
private int _count = 0;

public void Increment()
{
Interlocked.Increment(ref _count);
}

public int Value
{
get { return _count; }
}
}

Background Report Generation

💻 Try It — Console App
💡 Paste into Program.cs and press F5⌥ GitHub
public class ReportGenerationService
{
private readonly List<Student> _students;

public ReportGenerationService(List<Student> students)
{
_students = students;
}

public async Task GenerateAllAsync(
string outputPath,
IProgress<string>? progress = null,
CancellationToken ct = default)
{
Directory.CreateDirectory(outputPath);
int total = _students.Count;
int done = 0;

// Process in batches of 10 concurrently
var batches = _students.Chunk(10);

foreach (var batch in batches)
{
ct.ThrowIfCancellationRequested();

var tasks = batch.Select(s => GenerateSingleAsync(s, outputPath, ct));
await Task.WhenAll(tasks);

done += batch.Length;
progress?.Report($"Generated {done}/{total} reports");
}
}

private async Task GenerateSingleAsync(Student s, string path, CancellationToken ct)
{
await Task.Delay(50, ct); // simulate report generation
string content = $"Report: {s.Name} | {s.ClassName} | {s.Percentage:F1}%";
await File.WriteAllTextAsync(
Path.Combine(path, $"{s.RollNumber}.txt"), content, ct);
}
}

When You'll Use This in SMS

SMS processes multiple operations concurrently with tasks:

// Process fee payments in parallel - 100 payments in 1 second instead of 100
async Task ProcessAllFeePayments(List<FeeAccount> feeAccounts)
{
var tasks = feeAccounts.Select(fa => ProcessSinglePaymentAsync(fa));
await Task.WhenAll(tasks); // All payments happen together
}

// Attendance marking - multiple teachers marking simultaneously
var teacherTasks = teachers.Select(t => t.MarkAttendanceAsync(classId));
await Task.WhenAll(teacherTasks);

// Report generation with cancellation - let admin cancel long report
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
await GenerateSchoolReportAsync(cts.Token); // Cancel if takes >60 sec

// Thread-safe student count during concurrent enrollments
public class StudentEnrollmentCounter
{
private int _count = 0;
private readonly object _lock = new();

public void Enroll()
{
lock (_lock) { _count++; } // Only one enrollment at a time
}
}

Real impact: Without tasks = school processes 10 payments/second. With tasks = 1000/second. Without synchronization = lost enrollments, data corruption. With lock = safe concurrent access.


Try This Now

  1. Create Task.Run() to calculate all student grades in background
  2. Use CancellationToken with 5-second timeout
  3. Add lock to increment fee payment counter safely
  4. Use Interlocked.Increment for exam result counter
  5. Run multiple tasks with Task.WhenAll() and report progress

ℹ️ Video Tutorial

Multithreading explained: Thread vs Task, async/await, locks, race conditions, CancellationToken, real-world SMS scenarios. Video coming soon. Subscribe to NexCoding YouTube for updates.


Common Mistakes

Wrong Forgetting thread safety with shared state:

private int _count = 0;

// Multiple threads can corrupt _count
foreach (var task in tasks)
task = Task.Run(() => _count++); // Race condition!

OK Use lock or Interlocked:

// With lock
lock (_lock) { _count++; }

// With Interlocked (faster)
Interlocked.Increment(ref _count);

Wrong Using lock(this) or lock(typeof(Class)):

lock(this) { } // Bad - exposes implementation, external code can lock too
lock(typeof(Student)) { } // Bad - global lock, slow

OK Private lock object:

private readonly object _lock = new();
lock (_lock) { } // Good - internal, only this class uses it

Wrong Holding lock too long (deadlock risk):

lock (_lock)
{
var students = LoadStudentsFromDatabase(); // Long I/O under lock!
ProcessStudents(students); // More work under lock
}

OK Hold lock only for shared state:

var students = LoadStudentsFromDatabase(); // No lock needed

lock (_lock)
{
_list.AddRange(students); // Lock only for critical section
}

Wrong Not checking CancellationToken:

foreach (var student in students)
{
ProcessStudent(student); // Ignores cancellation request, keeps running
}

OK Check token periodically:

foreach (var student in students)
{
ct.ThrowIfCancellationRequested(); // Stop if cancelled
ProcessStudent(student);
}

Wrong Using async void (unobserved exception risk):

async void Process() // Exception might crash app!
{
await Task.Delay(1000);
throw new Exception("Oops!"); // Where does exception go?
}

OK Use async Task:

async Task Process() // Exception propagates properly
{
await Task.Delay(1000);
throw new Exception("Caught by caller");
}

await Process(); // Exception caught here

Wrong Not disposing CancellationTokenSource:

var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await DoWorkAsync(cts.Token);
// cts.Dispose() never called

OK Use using:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await DoWorkAsync(cts.Token);
// Disposed automatically

Best Practices

  1. Prefer Task over Thread - Lightweight, managed by .NET
  2. Prefer async/await over Task.Run - Non-blocking for I/O
  3. Use private lock objects - Prevent external locking, avoid lock(this)
  4. Hold locks briefly - Lock only shared state, not I/O or long operations
  5. Use Interlocked for simple ops - Faster than lock for increment/assignment
  6. Always check CancellationToken - Respect cancellation requests
  7. Use using for CancellationTokenSource - Ensures cleanup
  8. Avoid async void - Use async Task except for event handlers
  9. Use Task.WhenAll for parallel work - Wait for multiple tasks efficiently
  10. Profile before optimizing - Lock contention isn't always the bottleneck
  11. Document thread safety - Comment why lock is needed, when acquired
  12. Test under load - Race conditions hide in normal testing, appear under stress

🎯 Q1: What's the difference between Thread and Task?

Thread: OS-level abstraction. Creating one allocates 1-2MB memory. Expensive. Manual management.

Task: .NET abstraction over thread pool. Reuses threads. Lightweight. Managed runtime.

// Expensive - new OS thread each time
new Thread(() => DoWork()).Start();

// Cheap - reuses thread from pool
Task.Run(() => DoWork());

Always use Task in modern C#. Thread is rarely needed.

🎯 Q2: What is a race condition and how does lock prevent it?

Race condition = multiple threads read/modify shared state without synchronization, causing unpredictable results.

// RACE - both threads might increment, but only by 1 total
private int _count = 0;
_count++; // Step 1: read 0, Step 2: add 1, Step 3: write 1

// SAFE - only one thread increments at a time
private readonly object _lock = new();
lock (_lock) { _count++; }

lock makes section mutual exclusive - only one thread enters at a time. Prevents read-modify-write corruption.

🎯 Q3: When should I use Task.Run vs async/await?

Task.Run: CPU-bound work on background thread. Keeps main thread responsive.

async/await: I/O-bound work (files, network, DB). Frees thread during I/O, no thread allocated while waiting.

// CPU-bound - use Task.Run
Task.Run(() => CalculateAllGrades(students)); // Heavy computation on thread pool

// I/O-bound - use async/await
await File.ReadAllTextAsync(path); // Thread free during disk read
await _dbContext.SaveChangesAsync(); // Thread free during DB write

In web apps, always prefer async/await - releases thread during I/O, can handle more concurrent requests.

🎯 Q4: What is CancellationToken and when do I use it?

CancellationToken signals that long operation should stop. Caller can cancel without killing thread.

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));

try
{
await LongProcessAsync(cts.Token); // Pass token
}
catch (OperationCanceledException)
{
Console.WriteLine("Cancelled!");
}

Method checks token periodically: ct.ThrowIfCancellationRequested().

Use when: long operations, timeouts, UI cancel buttons, user logout (cancel pending requests).

🎯 Q5: Should I use lock(this) or lock(typeof(Class))?

Neither. Both are bad.

lock(this): Exposes implementation. External code can lock(_student), interfering with class logic.

lock(typeof(Class)): Global lock across entire class, slow, risk of deadlock.

Best: Private lock object inside class:

public class StudentGradeService
{
private readonly object _lock = new(); // Only this class uses it

public void UpdateGrade(int studentId, double grade)
{
lock (_lock)
{
// Update logic
}
}
}

Lock only what you control, inside the class.

🎯 Q6: When should I use Interlocked instead of lock?

Interlocked = atomic operation without lock overhead. Use for simple increment/assignment.

lock = general-purpose synchronization. Use for complex operations.

// Increment counter - use Interlocked (no lock contention)
Interlocked.Increment(ref _count);

// Multiple operations on shared object - use lock
lock (_lock)
{
student.Percentage = newPct;
student.Grade = CalculateGrade(newPct);
student.UpdatedAt = DateTime.Now;
}

Profile to decide. If many threads competing for same lock = bottleneck, consider Interlocked or lock-free data structures (ConcurrentQueue<T>, ConcurrentDictionary<K,V>).


🤖Use AI to Learn Faster

Use ChatGPT, Claude, or Copilot to go deeper on C# multithreading and tasks. Try these prompts:

  • "What is the difference between Thread, Task, and async/await in C#?"
  • "What is a race condition in C# and how do lock and Interlocked prevent it?"
  • "When should I use Task.Run vs async/await in C#?"
  • "Explain CancellationToken in C# - how does it work and when to use it?"

💡 Tip: After reading this article, paste your own code into AI and ask "What could go wrong here and why?" — fastest way to find edge cases and deepen understanding.

Next Article

Parallel Programming ->

nexcoding.in