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.
- Thread vs Task - why Task is preferred
- Creating tasks with
Task.Run - Async/await for non-blocking operations
CancellationTokenfor stopping long operations- Thread safety and race conditions
lockkeyword for synchronizationInterlockedoperations 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
| Concept | Purpose | Thread Safety | Use When | Example |
|---|---|---|---|---|
| Thread | OS-level thread, manual management | Not inherently safe | Need low-level control (rare) | new Thread(action).Start() |
| Task | Abstraction over ThreadPool | Not inherently safe | Background work, parallel ops | Task.Run(() => Work()) |
| async/await | Non-blocking async operations | Safe with proper locking | I/O-bound (files, network, DB) | await File.ReadAsync() |
| lock | Mutual exclusion, one thread at a time | Thread-safe | Shared mutable state | lock (_lock) { _count++; } |
| Interlocked | Atomic operations (thread-safe) | Thread-safe | Simple increments/assignments | Interlocked.Increment(ref _count) |
| CancellationToken | Request cancellation | Safe | Long operations, timeouts | await op.Async(ct) |
| Task.WhenAll | Wait for multiple tasks | Safe (coordination) | Parallel independent work | await Task.WhenAll(t1, t2, t3) |
| Monitor | Lock with TryEnter support | Thread-safe | Timeout on lock attempt | Monitor.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 blockInterlocked.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
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
- Create
Task.Run()to calculate all student grades in background - Use
CancellationTokenwith 5-second timeout - Add
lockto increment fee payment counter safely - Use
Interlocked.Incrementfor exam result counter - Run multiple tasks with
Task.WhenAll()and report progress
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
- Prefer
TaskoverThread- Lightweight, managed by .NET - Prefer
async/awaitoverTask.Run- Non-blocking for I/O - Use private
lockobjects - Prevent external locking, avoidlock(this) - Hold locks briefly - Lock only shared state, not I/O or long operations
- Use
Interlockedfor simple ops - Faster thanlockfor increment/assignment - Always check
CancellationToken- Respect cancellation requests - Use
usingforCancellationTokenSource- Ensures cleanup - Avoid
async void- Useasync Taskexcept for event handlers - Use
Task.WhenAllfor parallel work - Wait for multiple tasks efficiently - Profile before optimizing - Lock contention isn't always the bottleneck
- Document thread safety - Comment why lock is needed, when acquired
- Test under load - Race conditions hide in normal testing, appear under stress
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.
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.
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.
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).
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.
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 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.