Multithreading — Revision Sheet (Interview Prep)¶
1. Race Conditions¶
Condition for a race: shared + mutable state, touched by multiple threads, with at least one write, and no coordination forcing order.
counter++ is 3 steps, not 1:
1. Read counter into a register
2. Increment the register
3. Write register back to memory
Two threads can interleave these steps — both read the same stale value before either writes, so one increment is lost. Result: final value is less than expected, and different on every run. Not a crash — silently wrong data. Non-reproducible = the signature of a race.
Key point: two threads touching different variables never race — there's no shared memory location for interleaving to happen on.
2. lock / Monitor¶
lock (lockObj)
{
counter++;
}
Compiles to:
Monitor.Enter(lockObj);
try { counter++; }
finally { Monitor.Exit(lockObj); }
- Every .NET object has a hidden CLR field tracking "which thread owns this as a lock."
lockObjholds no data — it's never the thing being operated on. It's a gatekeeper token;counteris the actual protected state. - Threads block on the lock object, never on the shared data itself. The relationship between lock and data is pure convention — not enforced by the compiler. If any code touches
counterwithout going through the same lock, protection is void. - Use a dedicated
new object()as the lock — never a string literal (string interning can silently share the same lock object across unrelated code). try/finally(nottry/catch) — lock is always released even if the code inside throws, becausefinallyruns unconditionally.- No default timeout.
Monitor.Enterblocks indefinitely. Danger case: manually callingMonitor.Enter/Exitwithouttry/finallycan leak a lock forever if an exception hits between them. - Reentrant: the same thread can
lockthe same object again (e.g. nested method call) without self-deadlocking — CLR tracks owning thread + a count, only releases at count 0. Only a different thread trying to enter would actually block.
3. Deadlocks¶
Classic two-lock deadlock:
Thread 1: lock(A) → ... → lock(B) [waits, B held by Thread 2]
Thread 2: lock(B) → ... → lock(A) [waits, A held by Thread 1]
Both block forever. No exception, no crash — program just hangs silently.
- Same structure as DB deadlocks (order/stock rows locked in opposite order) — a cycle of "waiting for" relationships. DB detects and kills one transaction automatically; C#'s
lockhas no built-in detection. - Timing-dependent, not code-dependent — removing artificial delays (
Thread.Sleep) doesn't remove the possibility, just shrinks the window. Same unreliability property as race conditions: can pass 999 runs and hang on the 1000th, especially under production load/contention. - Fix: always acquire multiple locks in the same global order, everywhere in the codebase. Structurally prevents the opposite-order cycle.
4. Thread vs Task vs async/await¶
| What it is | Cost | |
|---|---|---|
Thread |
Actual OS-level thread, you own its lifecycle | Expensive — ~1MB stack, OS bookkeeping per create/destroy |
Task |
Unit of work scheduled onto the CLR-managed ThreadPool | Cheap — reused pooled threads, no per-task creation cost |
async/await |
Not inherently about threads at all — about not blocking a thread during a wait | For true I/O, zero thread cost during the wait |
The core "gotcha" concept: during a genuine I/O await (e.g. await client.GetStringAsync(...)), the OS's own async I/O mechanism (IOCP on Windows / epoll-equivalent on Linux) handles the actual wait — no thread is occupied at all. The calling thread is released back to the pool immediately; when the I/O completes, some pooled thread (not necessarily the original) resumes execution after the await. This is why you can have thousands of concurrent in-flight I/O calls on a pool of ~20 threads.
This is different from Task.Run(() => CpuBoundWork()) — that genuinely occupies a thread for the whole duration, because it's real CPU work, not I/O waiting.
Interview trap: "Does async/await create a new thread?" → No. It changes whether waiting blocks a thread, not which thread runs the code.
Three ways to handle a Task — what actually happens¶
| Approach | Thread during wait | Exceptions | Ordering |
|---|---|---|---|
await |
Released back to pool, resumes later | Propagate normally via try/catch | Correct — caller waits properly |
.Result / .Wait() |
Blocked, held hostage for the full wait | Observed (wrapped in AggregateException) but at the cost of blocking |
Correct completion order, but... |
| Bare call, no await/Result | Task runs in background, unsupervised | Silently swallowed — nobody observes them | None — may finish after caller returns/exits |
.Result / .Wait() — two concrete failure modes:
1. Thread pool starvation — every blocked request holds a thread-pool thread hostage for the full wait duration (even though the actual work isn't using that thread). Under load, the pool runs out and new requests queue up — app looks "hung" while CPU is idle.
2. Deadlock (classic ASP.NET Framework / WPF / WinForms, not ASP.NET Core by default) — blocking thread captures a SynchronizationContext; the continuation after the awaited call needs that same context/thread to resume on; but that thread is blocked waiting. Neither side can proceed. Same cyclic-wait shape as the two-lock deadlock.
Rule: "await all the way up." Once any method in a call chain does real async I/O, every caller above it should be async and await it, up to the outermost entry point. Blocking anywhere in that chain (.Result/.Wait()) reintroduces blocking and its downsides.
Bare unawaited call — still executes synchronously up to its first await, then runs disconnected in the background. Compiler warns: CS4014. Worst of the three options because it looks fine in testing and fails silently in production (dropped work, invisible exceptions) under different timing.
5. async void¶
- Only legitimate use: event handlers, whose signature is fixed by the framework (can't return
Task). - Danger: there's no
Taskobject to carry an exception back to the caller. Any exception thrown after the firstawaitgets rethrown directly on theSynchronizationContext(or thread pool) instead of being catchable — can crash the process. A surroundingtry/catchat the call site does nothing, because control has already returned to the caller by the time the exception fires. - Rule: never write
async voidexcept for event handlers. Everywhere else:async Task/async Task<T>, even if the return value is unused — it gives callers something toawaitand catch exceptions on. - If forced into
async void(event handler), wrap the body intry/catchyourself — nobody else can catch it.
6. lock vs Interlocked vs Monitor.TryEnter¶
| Tool | Use when | Mechanism |
|---|---|---|
Interlocked |
Single primitive value, single atomic op (increment, add, exchange, compare-and-swap) | Single atomic CPU instruction (e.g. LOCK XADD) — no blocking, no thread suspension, no context switch |
lock (Monitor) |
Multiple related fields, or a multi-step operation that must be consistent as one unit | Mutual exclusion — blocks other threads until released |
Monitor.TryEnter(obj, timeout) |
Need a timeout or non-blocking "try, else skip" instead of waiting forever | Raw Monitor, no automatic finally — must wrap manually |
Interlocked examples:
- Interlocked.Increment(ref counter) — metrics/hit counters, no blocking needed.
- Interlocked.CompareExchange(ref flag, 1, 0) — atomic "claim this if free" pattern (conceptually identical to the PEPPOL UPDATE WHERE status = 'available' atomic conditional update, just in-process).
Why Interlocked can't replace lock for multi-field updates: no single CPU instruction can atomically check-and-update two unrelated fields together (e.g. balance -= amount; transactionCount++;). That needs real mutual exclusion over the whole block.
Interview one-liner: "Interlocked for single-variable atomic ops with no blocking. lock when protecting multiple related fields or a multi-step operation as one consistent unit. Monitor.TryEnter when I need a timeout instead of blocking forever."
7. ConcurrentDictionary¶
- Plain
Dictionaryis not thread-safe at all — concurrent writes (even to different keys) can corrupt internal bucket/hash structure, not just produce wrong values. Can throw, corrupt state, or hang during resize. - Wrapping every access in one
lockis correct but coarse — serializes access across all keys, even unrelated ones, killing throughput under high concurrency. ConcurrentDictionaryuses fine-grained internal locking — segments/buckets each with their own lock, so unrelated keys don't contend and can proceed in true parallel.- Atomic compound operations avoid hand-rolled check-then-act races (TOCTOU bugs):
GetOrAdd(key, valueFactory)— get if exists, else compute-and-insert atomically.AddOrUpdate(key, addValue, updateFactory)— insert if missing, or atomically update based on old value if present.TryAdd/TryUpdate/TryRemove— atomic, each reports success/failure.
Relevant example (ERP satellite domain): multiple client syncs (Exact Online, WooCommerce, Orderchamp) updating a shared ConcurrentDictionary<string, DateTime> keyed by client ID for last-sync timestamps — correct under concurrency with zero manual locks, and one client's sync never blocks another's.
7b. ConcurrentDictionary methods, in detail¶
Unifying idea: every method beyond a single read or a single blind write exists to collapse a check-then-act sequence into one atomic internal step. If you tried to compose the same behavior yourself with two separate calls, another thread could act in the gap between them and invalidate your assumption.
TryAdd(key, value)¶
Atomically checks if key exists; if not, inserts and returns true. If it exists, does nothing and returns false — no exception (unlike Dictionary.Add). The check-and-insert is one indivisible step — two threads racing on the same missing key can never both succeed.
TryGetValue(key, out value)¶
Safe lookup, same semantics as Dictionary. Never corrupts or throws under concurrent access from other threads; value may simply be stale the instant after you read it (inherent to any concurrent read).
TryRemove(key, out removedValue)¶
Atomically removes if present and hands back the removed value in the same operation. Avoids the race of doing TryGetValue then a separate Remove, where another thread could change/remove the value in between.
TryUpdate(key, newValue, comparisonValue)¶
Compare-and-swap for a dictionary entry (same idea as Interlocked.CompareExchange). Only updates if the current stored value still equals comparisonValue at that instant; otherwise no-op, returns false. Guards against "read old value, compute new value, blind-write new value" — where another thread's change in between would otherwise get silently discarded.
Indexer dict[key] get/set¶
Setter: unconditional overwrite, thread-safe (won't corrupt structure) but no compare — will stomp whatever another thread just wrote. Use only when you don't care about the previous value (e.g. "last sync time," newest write always wins).
Getter: throws KeyNotFoundException on missing key, same as Dictionary — TryGetValue remains the safe idiom.
Count / Keys / Values / foreach enumeration¶
Won't throw during concurrent mutation (unlike Dictionary, which throws InvalidOperationException if modified while enumerating) — but the view is a weakly consistent, point-in-time-ish snapshot. May miss an entry added mid-enumeration or include one later removed. Never gate race-sensitive logic on Count (e.g. "if Count < 10, add another" is itself a check-then-act race).
7c. GetOrAdd vs AddOrUpdate — the actual difference¶
Distinguishing question: if the key already exists, do I want to leave it alone, or change it?
| If key missing | If key exists | |
|---|---|---|
GetOrAdd |
Computes/inserts once | Returns existing value untouched — never modifies it, ever |
AddOrUpdate |
Inserts addValue |
Runs updateValueFactory(key, oldValue) and replaces with the result |
// GetOrAdd — lazy cache, initializes once, never touches it again
int a = dict.GetOrAdd("x", 1); // missing → inserts 1
int b = dict.GetOrAdd("x", 1); // exists → the "1" argument is IGNORED, returns existing value
// AddOrUpdate — every call actively transforms the value
dict.AddOrUpdate("x", 1, (k, old) => old + 1); // missing → inserts 1
dict.AddOrUpdate("x", 1, (k, old) => old + 1); // exists (1) → stores 2
dict.AddOrUpdate("x", 1, (k, old) => old + 1); // exists (2) → stores 3
Use GetOrAdd: lazy-initialized cache (e.g. cached compiled NCalc expression) — build once, reuse forever.
Use AddOrUpdate: running counters, running totals, "always refresh on every hit" (e.g. last-seen timestamp).
Shared gotcha (both methods): under contention, the factory delegate (valueFactory / updateValueFactory / addValueFactory) can be invoked more than once if multiple threads race on the same key — only the final stored result is guaranteed to be the single winning one, not the number of times the factory itself ran. Don't put side effects (API calls, logging, external counters) inside these factories if you need exactly-once execution.
AddOrUpdate — why the add side has two overloads, but update never does¶
Add side can be a fixed value OR a factory:
// Fixed value — cheap/constant, no reason to wrap it in a delegate
dict.AddOrUpdate(key, 1, (k, old) => old + 1);
// Factory — only invoked if key is actually missing; avoids paying for expensive
// computation (DB call, API call) on every call when the key usually already exists
dict.AddOrUpdate(key, k => ExpensiveDbLookup(k), (k, old) => old + 1);
Rule: cheap/constant/doesn't depend on avoiding unnecessary work → fixed value (no delegate allocation overhead). Expensive or must only run when actually needed → factory.
Update side must always be a factory — there is no fixed-value update overload. Reason: "update" is inherently defined as a function of the old value — the factory signature is (key, oldValue) => newValue, and it needs the old value as an input to make sense as an "update" at all. If you wanted "always overwrite with a constant, ignore whatever was there" — that's not an update anymore, it's a plain assignment, and it's already covered by the indexer: dict[key] = fixedValue;. So a "fixed update value" would be a redundant API surface — the indexer already does exactly that, cheaper, with no delegate involved.
| Branch | Can it be a fixed value? | Why |
|---|---|---|
| Add | Yes | Nothing to derive from on first insert — a constant is a complete, valid initial state |
| Update | No — must be a factory | Update is defined as "derive new value from old value"; without that dependency it's not an update, it's an overwrite, already covered by dict[key] = value |
Interview one-liners for this section: - "GetOrAdd initializes once and never touches an existing value again — it's a lazy cache. AddOrUpdate transforms the existing value via the update factory on every call after the first — it's for counters and running state." - "AddOrUpdate's add-side can be a fixed value or a factory, same reasoning as GetOrAdd — factory avoids paying for expensive computation when the key already exists. The update side has no fixed-value overload because update is defined as a function of the old value; if you don't need the old value, you don't need AddOrUpdate at all, you just overwrite via the indexer."
Interview-Ready One-Liners (say these out loud, practice them)¶
- Thread vs Task: "
Task.Runschedules work onto the CLR-managed ThreadPool instead of creating a new OS thread each time — avoids the cost of stack allocation and OS bookkeeping per unit of work." - async/await and threads: "
awaitdoesn't create a thread — for genuine I/O, no thread is occupied during the wait at all; the runtime resumes on any available pooled thread once the I/O completes." - Why not block on async: "Blocking with
.Resultholds a thread hostage for the full wait and can cause thread pool starvation under load, or deadlock if a SynchronizationContext is involved." - async void: "
async voidhas noTaskto carry exceptions back to the caller — exceptions past the first await can crash the process. I only use it for event handlers, and I wrap the body in try/catch myself." - Interlocked vs lock: "
Interlockedfor single-variable atomic ops with no blocking.lockwhen protecting multiple related fields or a multi-step operation as one consistent unit." - Deadlock cause and fix: "Deadlock is a cycle of threads each holding a lock the other needs, acquired in opposite order. Fix: always acquire locks in a consistent global order."
- ConcurrentDictionary: "Plain Dictionary isn't thread-safe even for concurrent reads/writes to different keys — can corrupt internal state, not just produce wrong values. ConcurrentDictionary uses fine-grained locking per segment so unrelated keys don't contend, plus atomic GetOrAdd/AddOrUpdate avoid hand-rolled check-then-act races."
Tier 3 — Not Drilled Today (mention only if it comes up, don't over-prep)¶
- SemaphoreSlim — limiting concurrency (e.g. max N concurrent calls to an external API). You have real context here (Exact Online/WooCommerce/Orderchamp integrations) — can speak to it live if asked.
- Producer-consumer pattern — you live this via RabbitMQ consumers. Answer from real experience, no need to prep theory.
volatile— memory visibility across cores, not atomicity. Rarely the right tool; know it exists.- Thread pool starvation — covered above as a consequence of
.Result/.Wait(); you already have the story if asked directly.