Multithreading and Synchronization
Thread safety means a type behaves correctly when used from several threads at once.
Concept
Threads, the Thread Pool and Tasks A thread is an independent path of execution. new Thread(...) creates a dedicated OS thread (expensive; use for long-running, special-purpose work).
The thread pool reuses a set of worker threads for short work items.
Tasks (Task.Run) are the modern abstraction: they run on the thread pool and compose with async/await.
Unsynchronized read-modify-write (count++) loses updates Two threads each hold a lock the other needs
A thread never gets the resource (e.g. pool threads all blocked)
One thread does not see another's write without a memory barrier
Thread safety means a type behaves correctly when used from several threads at once.
lock (obj) / System.Threading.Lock (C# 13) Mutual exclusion within a process — the default choice
What lock uses; adds TryEnter with timeouts, Wait/Pulse
Atomic Increment, Add, Exchange, CompareExchange on a single variable Read/write with visibility guarantees
Limit concurrency to N (async-friendly WaitAsync) Like SemaphoreSlim, can be named across processes
Mutual exclusion, can be named across processes (single-instance apps) Many concurrent readers or one writer
Why It Matters
Test benches, data loggers and servers do many things at once. Concurrency bugs are intermittent and hardware-dependent; knowing the primitives and their costs is essential.
Syntax
lock (_gate) { shared++; }
Interlocked.Increment(ref counter);
await semaphore.WaitAsync(); try { ... } finally { semaphore.Release(); }Example
// ---- Race condition: unsynchronized counter ----
int unsafeCount = 0;
var threads = Enumerable.Range(0, 4).Select(_ => new Thread(() =>
{
for (int i = 0; i < 1_000_000; i++) unsafeCount++; // read-modify-write is not atomic
})).ToList();
threads.ForEach(t => t.Start());
threads.ForEach(t => t.Join());
Console.WriteLine($"unsynchronized: expected 4000000, lost updates: {unsafeCount < 4_000_000}");
// ---- lock ----
int lockedCount = 0;
var gate = new Lock(); // C# 13 / .NET 9 System.Threading.Lock
Parallel.For(0, 4, _ =>
{
for (int i = 0; i < 1_000_000; i++)
lock (gate) { lockedCount++; }
});
Console.WriteLine($"lock: {lockedCount}");
// ---- Interlocked ----
int atomicCount = 0;
Parallel.For(0, 4, _ =>
{
for (int i = 0; i < 1_000_000; i++) Interlocked.Increment(ref atomicCount);
});
Console.WriteLine($"Interlocked: {atomicCount}");
// ---- Monitor with timeout ----
object resource = new();
bool acquired = false;
try
{
Monitor.TryEnter(resource, TimeSpan.FromMilliseconds(100), ref acquired);
Console.WriteLine($"Monitor.TryEnter acquired: {acquired}");
}
finally
{
if (acquired) Monitor.Exit(resou
…Expected Result
The unsynchronized counter lost updates (its exact value varies from run to run — that non-determinism is the bug), while lock and Interlocked always produce exactly 4 000 000.
unsynchronized: expected 4000000, lost updates: True
lock: 4000000
Interlocked: 4000000
Monitor.TryEnter acquired: True
SemaphoreSlim(2): max concurrent sessions = 2
ReaderWriterLockSlim: gain = 1.05
Mutex acquired: True
TryLockBoth: True
dedicated Thread: IsThreadPoolThread = False
Task.Run: IsThreadPoolThread = TrueCommon Mistakes
Locking on this, on a Type or on a string (other code can lock the same object).
Holding a lock while awaiting (await inside lock does not compile — CS1996 — use SemaphoreSlim).
Acquiring locks in different orders in different places (deadlocks).
Using Thread.Sleep to "fix" races.
Assuming List<T> or Dictionary are thread-safe.
Best Practices
Avoid shared mutable state: prefer immutable data, message passing (channels) and per-thread data.
Lock on a private dedicated object, keep critical sections short, never call unknown code inside a lock.
Use Interlocked for simple counters, SemaphoreSlim for async throttling, concurrent collections for shared collections.
Always acquire multiple locks in a consistent order.
Interview Relevance
Very high: "what is a race condition?", "lock vs Monitor vs Mutex vs Semaphore", "how do you prevent deadlocks?", "what does Interlocked do?", "thread vs task".
Practice Questions
Make a Statistics class (count, sum, min, max) thread-safe with lock.
Limit simultaneous flash operations to 3 with SemaphoreSlim.
Create a deadlock with two locks, then fix it with lock ordering.
Use a named Mutex to allow only one instance of a tool to run.
Quick Quiz
Q1. (Multiple choice) Which primitive is best for an async-friendly concurrency limit? A) lock B) Mutex C) SemaphoreSlim D) Thread.Sleep Answer: C.
Q2. (True/False) count++ on a shared int is atomic.
Answer: False (verified: updates were lost).
Q3. (Predict the output) SemaphoreSlim(2) with 6 concurrent sessions — the maximum active at once?
Answer: 2 (verified).
Q4. (Find the bug) lock (_gate) { await SaveAsync(); }
Answer: await cannot appear inside lock (error CS1996); use await _semaphore.WaitAsync() with try/finally.
Coding Challenge
Implement a thread-safe RollingCounter that counts events per ECU code and returns a snapshot dictionary, then hammer it from Parallel.For and verify the total.
var counter = new RollingCounter();
string[] ecus = ["VCU", "BMS", "ABS"];
Parallel.For(0, 30_000, i => counter.Add(ecus[i % 3]));
var snap = counter.Snapshot();
Console.WriteLine($"{string.Join(", ", snap.OrderBy(k => k.Key).Select(k => $"{k.Key}={k.Value}"))} total={snap.Values.Sum()}");
class RollingCounter
{
private readonly Lock _gate = new();
private readonly Dictionary<string, int> _counts = [];
public void Add(string ecu)
{
lock (_gate) _counts[ecu] = _counts.GetValueOrDefault(ecu) + 1;
}
public Dictionary<string, int> Snapshot()
{
lock (_gate) return new Dictionary<string, int>(_counts);
}
}