Threading in C
A deep-dive walkthrough of multithreading in C# — covering the Thread class directly, the managed thread pool, race conditions and why they happen mechanically, synchronization primitives (lock/Monitor, Mutex, Semaphore, ReaderWriterLockSlim), deadlocks and how to avoid them, thread-safe and lock-free patterns, the Parallel class and PLINQ for data parallelism, and how threading relates to — and differs from — the Task/async/await model covered elsewhere in this series.
Table of Contents
- Introduction
- The Thread Class Directly
- The Managed Thread Pool
- Race Conditions: Why Shared Mutable State Is Dangerous
- lock and Monitor: The Standard Mutual Exclusion Tool
- Deadlocks: The Cost of Getting Locking Wrong
- Other Synchronization Primitives
- Interlocked: Lock-Free Atomic Operations
- Thread-Safe Collections
- volatile and Memory Visibility
- The Parallel Class and Data Parallelism
- PLINQ: Parallel LINQ
- Threading vs. Task/async-await: How They Actually Relate
- Thread Safety Design Patterns
- Common Pitfalls
- Quick Reference Table
- Conclusion
Introduction
Threading is about genuinely running more than one sequence of instructions at the same time, sharing the same process's memory — which is a fundamentally different problem from the asynchrony this series' async/await and Task guides cover. Those guides are about not wasting a thread while waiting; this guide is about what happens once you genuinely have multiple threads executing simultaneously and potentially touching the same data at the same moment, which introduces an entire category of correctness problems — race conditions, deadlocks, torn reads — that simply don't exist in single-threaded code. This guide goes deep on the Thread class itself, the thread pool underneath both direct threading and Task, the synchronization primitives C# provides to make shared mutable state safe, and where threading, Task, and async/await genuinely intersect versus where they solve entirely different problems.
Single thread: instructions execute ONE AT A TIME, in a strict, predictable order.
Multiple threads: instructions from DIFFERENT threads can INTERLEAVE in ways that
are not predictable, not deterministic, and — for shared mutable state
touched without synchronization — often genuinely incorrect.
1. The Thread Class Directly
Creating and starting a thread
Thread thread = new Thread(() => Console.WriteLine("Running on a new thread"));
thread.Start();
thread.Join(); // blocks the CALLING thread until `thread` finishes
Thread represents a real, dedicated operating system thread — new Thread(...) constructs it (with a delegate describing what it should run), .Start() actually begins execution, and .Join() blocks the calling thread until the target thread finishes, which is the standard way to wait for a manually-created thread to complete before proceeding.
Passing data to a thread
Thread thread = new Thread(DoWork);
thread.Start("some input"); // ParameterizedThreadStart — passed as object, requires a cast inside
void DoWork(object? data)
{
string input = (string)data!;
Console.WriteLine($"Processing: {input}");
}
// The modern, cleaner alternative — a closure captures the variable directly, no cast needed
string capturedInput = "some input";
Thread thread2 = new Thread(() => DoWork(capturedInput));
thread2.Start();
The older ParameterizedThreadStart API (passing an object to .Start(...)) requires an unsafe cast inside the thread's method — modern code almost universally prefers a lambda closure (per this series' Delegates guide's Section 8 discussion of closures) to capture whatever data the thread needs directly and type-safely, without the object cast.
Thread properties worth knowing
thread.IsBackground = true; // background threads do NOT keep the process alive on their own
thread.Priority = ThreadPriority.AboveNormal; // a HINT to the OS scheduler, not a hard guarantee
thread.Name = "WorkerThread-1"; // genuinely useful for debugging — shows up in debugger thread lists
IsBackground matters specifically for process lifetime: a foreground thread (the default) keeps the application running even if Main has returned, while a background thread is automatically terminated when every foreground thread finishes — worth setting explicitly for threads that shouldn't prevent the application from exiting. Name costs nothing and is genuinely valuable the first time you're debugging a deadlock or a race condition with several threads active simultaneously.
Why direct Thread construction is comparatively rare in modern code
Per this series' Task guide's Section 11: creating a real OS thread is a
genuinely expensive operation — real memory (a dedicated stack, typically
1MB by default) and real OS-level bookkeeping, per thread. Most everyday
concurrent work in modern C# reaches for Task.Run (Section 3, this
guide's Section 2) specifically to avoid paying this cost per unit of work.
Direct Thread construction remains the right tool for a genuinely small set of cases: a thread that needs to live for the application's entire lifetime, one needing a specific priority or apartment state (COM interop), or one that will run for a very long time doing continuous work rather than a discrete, short-lived unit of it — for ordinary background or concurrent work, Task.Run (backed by the pooled model, Section 2) is almost always the better default.
2. The Managed Thread Pool
A shared, reused set of worker threads, avoiding the cost of per-task thread creation
ThreadPool.QueueUserWorkItem(_ => Console.WriteLine("Running on a pooled thread"));
This is the lowest-level way to schedule work onto the .NET thread pool directly — Task.Run (this series' Task guide's Section 2) is built on top of essentially this same mechanism, wrapping it with a Task object for tracking status, results, and composition. The pool maintains a set of already-created, reusable worker threads, handing out queued work items to whichever thread becomes free next, avoiding the per-item cost of creating and tearing down a dedicated Thread for every single piece of work.
Pool sizing: minimum, maximum, and dynamic growth
ThreadPool.GetMinThreads(out int minWorker, out int minIO);
ThreadPool.GetMaxThreads(out int maxWorker, out int maxIO);
ThreadPool.SetMinThreads(50, minIO); // raise the MINIMUM — can help avoid a slow ramp-up under sudden burst load
The pool starts near its configured minimum and grows toward its maximum as sustained demand requires, but that growth isn't instantaneous — a sudden burst of concurrent work can briefly queue before the pool has scaled up enough threads to handle it. SetMinThreads is a real, if fairly advanced, lever some server applications use specifically to reduce this ramp-up latency under bursty load, though tuning this is more often a symptom worth investigating than a default setting worth adjusting casually.
Why thread-pool starvation is a genuine, real-world production issue
Per this series' async/await guide's Section 11: blocking a thread-pool
thread (via .Result, .Wait(), or a long synchronous CPU-bound loop
submitted via Task.Run) ties it up for the FULL duration of that block —
under enough concurrent load, EVERY pool thread can end up blocked
simultaneously, and NEW work queued to the pool has nowhere to run until
one frees up or the pool grows (which, again, isn't instantaneous).
This is precisely the mechanical root of "thread pool starvation," a well-known cause of applications that become mysteriously unresponsive under load despite low CPU usage — every pool thread is blocked waiting on something (often I/O they shouldn't have been blocking on synchronously in the first place, per this series' async/await guide's Section 7's deadlock discussion), and nothing new can make progress until that resolves.
3. Race Conditions: Why Shared Mutable State Is Dangerous
The classic example: an unsynchronized increment, run from multiple threads
int counter = 0;
void Increment()
{
for (int i = 0; i < 1_000_000; i++)
counter++; // NOT ATOMIC — this is actually THREE steps: read, add one, write back
}
var t1 = new Thread(Increment);
var t2 = new Thread(Increment);
t1.Start(); t2.Start();
t1.Join(); t2.Join();
Console.WriteLine(counter); // NOT reliably 2,000,000 — often LESS, and the exact number varies run to run
This is the canonical demonstration of a race condition, and it's worth understanding why it happens, not just that it does: counter++ is not a single, indivisible CPU operation — it's read the current value, add one, write the new value back, as three genuinely separate steps. If Thread A reads the value (say, 500), and before it writes back 501, Thread B also reads the same value (500) and writes back 501, one of those two increments is silently lost — both threads thought they were incrementing from 500, and the counter only advanced by one instead of two.
Race conditions are non-deterministic, which is precisely what makes them so hard to find
The exact interleaving of instructions from two threads depends on the OS
scheduler, CPU load, timing, and factors entirely outside your code's
control — a race condition might manifest as a wrong result 1 time in
100,000 runs, passing every quick manual test and every low-load CI run,
while still being a genuine, serious bug waiting to surface under real
production concurrency.
This non-determinism is the single most important thing to understand about race conditions as a bug category — "it worked when I tested it" carries essentially no weight for concurrent code, since a race condition's manifestation frequency depends on timing conditions your test environment may simply never happen to reproduce, which is exactly why disciplined, correct synchronization from the start matters more here than in almost any other area of application correctness.
What actually needs protecting: shared, mutable state, specifically
Read-only data shared across threads: SAFE, no synchronization needed —
nothing is being mutated, so there's no "who wins the race" question at all.
Mutable data, but each thread has its OWN independent copy: SAFE — there's
no SHARING, so no race is possible.
Mutable data SHARED and WRITTEN by more than one thread: this is the
ONLY case that genuinely needs synchronization (Sections 4-7).
Worth being precise about scope here, since "just synchronize everything" is both unnecessary overhead and, per Section 5, a genuine deadlock risk if applied indiscriminately — the actual danger zone is specifically mutable state that's both shared across threads and written to by more than one of them; read-only sharing and per-thread-independent state are both inherently safe without any locking at all.
4. lock and Monitor: The Standard Mutual Exclusion Tool
lock: C#'s built-in syntax for mutual exclusion
private readonly object _lockObject = new();
private int _counter = 0;
void Increment()
{
lock (_lockObject)
{
_counter++; // only ONE thread can be inside this block at a time
}
}
lock ensures that only one thread can be executing the block at any given moment — any other thread attempting to lock (_lockObject) while another thread already holds it will block, waiting, until the first thread exits the block. This closes exactly the race condition Section 3 demonstrated: with the increment wrapped in a lock, the read-add-write sequence becomes effectively atomic from every other thread's point of view.
What lock actually is: syntactic sugar over Monitor
// What `lock (_lockObject) { ... }` actually compiles to (simplified):
Monitor.Enter(_lockObject);
try
{
_counter++;
}
finally
{
Monitor.Exit(_lockObject); // GUARANTEED to run, even if an exception is thrown inside the block
}
lock is genuinely just convenient syntax over Monitor.Enter/Monitor.Exit, wrapped automatically in a try/finally to guarantee the lock is always released, even if the protected code throws — this guaranteed release is important enough that hand-writing Monitor.Enter/Exit directly, without the try/finally, is a genuine bug risk lock exists specifically to eliminate.
What to use as the lock object, and what to avoid
private readonly object _lockObject = new(); // ✅ a dedicated, private object — the conventional choice
// ❌ Locking on `this` is a common anti-pattern — external code with a reference
// to your object could ALSO lock on it, creating unexpected contention or deadlocks
lock (this) { /* ... */ }
// ❌ Locking on a string literal is genuinely dangerous — string interning means
// two UNRELATED pieces of code using the same literal string could accidentally
// share the SAME lock object without realizing it
lock ("some-key") { /* ... */ }
The idiomatic choice is a dedicated, private readonly object field created solely to serve as a lock — never exposed publicly, so nothing outside the class can accidentally (or intentionally) contend for the same lock and create hard-to-diagnose blocking or deadlocks. Locking on this or on interned values like string literals are both real, documented anti-patterns for exactly this reason: the lock object's identity is effectively out of your control in ways that can silently create unintended sharing.
lock protects a critical section — but only where you actually apply it
lock (_lockObject) { _counter++; } // protected
int value = _counter; // ❌ NOT protected — reading _counter directly, with NO lock, elsewhere in the code
// can still race with a concurrent write happening inside a lock block
This is a genuinely common, easy-to-miss mistake: lock only protects the code inside the lock block — any other code touching the same shared field without going through the same lock is completely unprotected, and can still race. Every piece of code that reads or writes a given shared field needs to go through the same lock consistently, or the protection is illusory.
5. Deadlocks: The Cost of Getting Locking Wrong
The classic deadlock: two locks, acquired in opposite order by two different threads
private readonly object _lockA = new();
private readonly object _lockB = new();
void Method1()
{
lock (_lockA)
{
Thread.Sleep(100); // simulating some work, giving Method2 time to acquire lockB
lock (_lockB) { /* ... */ }
}
}
void Method2()
{
lock (_lockB)
{
Thread.Sleep(100);
lock (_lockA) { /* ... */ } // DEADLOCK: waiting for lockA, which Method1's thread is holding
} // while ITS thread waits for lockB, which THIS thread is holding
}
If Method1 runs on Thread A and Method2 runs on Thread B concurrently, a genuine deadlock is possible: Thread A acquires _lockA and then waits for _lockB; Thread B has already acquired _lockB and is now waiting for _lockA — neither thread can ever proceed, because each is waiting on a resource the other is holding and will never release. This is distinct from, but conceptually related to, the async-specific deadlock this series' async/await guide's Section 7 covers — both are circular-wait situations, just arising from different mechanisms.
The standard fix: always acquire multiple locks in a consistent, agreed-upon order
// If EVERY piece of code that needs BOTH locks always acquires _lockA BEFORE _lockB,
// the circular-wait condition above becomes structurally impossible
void Method1()
{
lock (_lockA) { lock (_lockB) { /* ... */ } }
}
void Method2()
{
lock (_lockA) { lock (_lockB) { /* ... */ } } // SAME order — no deadlock possible
}
This is the standard, well-established discipline for avoiding this entire class of deadlock: establish a consistent, global ordering for any locks that might ever need to be held simultaneously, and ensure every code path acquiring more than one of them always does so in that same order — a deadlock specifically requires a circular wait, and a consistent acquisition order makes that circularity structurally impossible, regardless of timing.
Monitor.TryEnter: a timeout-based alternative that avoids deadlocking indefinitely
if (Monitor.TryEnter(_lockObject, TimeSpan.FromSeconds(5)))
{
try { /* protected work */ }
finally { Monitor.Exit(_lockObject); }
}
else
{
Console.WriteLine("Could not acquire lock within timeout — handling gracefully instead of hanging forever");
}
Where a strict, agreed-upon lock ordering genuinely isn't practical (locking against code you don't control, for instance), Monitor.TryEnter with a timeout is a real, if less elegant, alternative — rather than waiting indefinitely and potentially deadlocking forever, it gives up after a bounded time and lets your code decide how to handle that failure explicitly, converting an indefinite hang into a recoverable, detectable condition.
6. Other Synchronization Primitives
Mutex: like lock, but works ACROSS processes, not just threads within one process
using var mutex = new Mutex(false, "Global\\MyApplicationSingleInstanceMutex");
if (mutex.WaitOne(TimeSpan.Zero))
{
// this process got the mutex — no OTHER process holds it right now
RunApplication();
mutex.ReleaseMutex();
}
else
{
Console.WriteLine("Another instance is already running.");
}
A Mutex (specifically a named one, as above) is recognized at the operating system level, not just within your own process — this makes it useful for genuinely cross-process coordination, like the classic "only allow one instance of this application to run at a time" pattern, which lock/Monitor (scoped only to objects and threads within a single process) cannot achieve at all.
Semaphore/SemaphoreSlim: limiting concurrent access to a fixed number of "slots," not just one
private readonly SemaphoreSlim _semaphore = new(initialCount: 3, maxCount: 3); // allow up to 3 CONCURRENT callers
async Task AccessLimitedResourceAsync()
{
await _semaphore.WaitAsync(); // blocks (asynchronously) if all 3 slots are currently taken
try
{
// at most 3 threads/tasks are ever inside this block simultaneously
}
finally
{
_semaphore.Release();
}
}
Where lock/Monitor enforce "only one at a time," a Semaphore (or its lighter-weight, more commonly used SemaphoreSlim counterpart) enforces "at most N at a time" — genuinely useful for throttling concurrent access to a limited resource (a fixed-size connection pool, a rate-limited external API), and SemaphoreSlim specifically supports WaitAsync(), making it directly usable within async/await code, unlike lock, which cannot be held across an await at all.
ReaderWriterLockSlim: distinguishing readers (many allowed concurrently) from writers (exclusive)
private readonly ReaderWriterLockSlim _rwLock = new();
private Dictionary<string, string> _cache = new();
string Read(string key)
{
_rwLock.EnterReadLock();
try { return _cache.GetValueOrDefault(key); }
finally { _rwLock.ExitReadLock(); }
}
void Write(string key, string value)
{
_rwLock.EnterWriteLock();
try { _cache[key] = value; }
finally { _rwLock.ExitWriteLock(); }
}
An ordinary lock treats every access identically — one at a time, whether reading or writing. For workloads that are read-heavy (many concurrent readers, occasional writers), ReaderWriterLockSlim is a meaningful optimization: any number of readers can hold the read lock simultaneously (since concurrent reads of unchanging data are inherently safe, per Section 3), while a writer requires exclusive access, blocking both other writers and all readers until it completes — this can measurably outperform a plain lock specifically when reads genuinely dominate writes.
7. Interlocked: Lock-Free Atomic Operations
The problem Interlocked solves: avoiding lock overhead for simple, single-variable operations
private int _counter = 0;
void Increment() => Interlocked.Increment(ref _counter); // ATOMIC — no lock needed at all
For the specific, narrow case of simple operations on a single primitive value (increment, decrement, add, compare-and-swap, exchange), Interlocked provides genuinely atomic operations implemented directly using low-level CPU instructions, without the overhead of acquiring and releasing a lock/Monitor at all — this closes Section 3's exact race condition, for this specific operation, more cheaply than a full lock would.
Interlocked.CompareExchange: the building block for lock-free algorithms
int original, updated;
do
{
original = _value;
updated = original * 2; // whatever the desired transformation is
} while (Interlocked.CompareExchange(ref _value, updated, original) != original);
This pattern — read the current value, compute a new one, then atomically swap only if nothing else changed the value in between (retrying if it did) — is the standard building block for lock-free algorithms, and it's exactly the pattern this series' Events guide's Section 9 shows being used for thread-safe event subscription without an explicit lock. CompareExchange is genuinely more complex to reason about correctly than a plain lock, and is generally reserved for cases where lock contention has been measured to be a real, specific performance problem.
Interlocked's real limitation: it only covers single, simple operations
// ❌ Interlocked CANNOT protect this — it's TWO separate operations on TWO separate fields,
// and there's no atomic "increment both together" primitive
Interlocked.Increment(ref _count);
Interlocked.Add(ref _total, value); // a race is still possible BETWEEN these two lines
Worth being explicit about this boundary: Interlocked makes a single operation on a single variable atomic — it does not, and cannot, make a sequence of multiple operations atomic together, even if each individual step uses Interlocked itself. For anything requiring multiple related pieces of state to change together consistently, an ordinary lock (Section 4) protecting the whole sequence remains the correct, simpler tool.
8. Thread-Safe Collections
Why ordinary collections (List<T>, Dictionary<TKey,TValue>) are NOT thread-safe by default
List<int> list = new List<int>();
// ❌ Multiple threads calling list.Add(...) concurrently, with NO synchronization,
// can corrupt the list's internal state — not just "lose an item," but genuinely
// throw exceptions or produce a structurally broken collection
This is worth stating explicitly, since it surprises developers used to languages or libraries with different defaults: List<T>, Dictionary<TKey,TValue>, and the rest of System.Collections.Generic (per this series' Generics guide) are explicitly, deliberately not thread-safe — concurrent, unsynchronized mutation can corrupt their internal structure, not just produce a logically wrong but structurally intact result.
System.Collections.Concurrent: purpose-built thread-safe alternatives
ConcurrentDictionary<string, int> concurrentDict = new();
concurrentDict.TryAdd("key", 1);
concurrentDict.AddOrUpdate("key", 1, (k, existing) => existing + 1); // atomic read-modify-write
ConcurrentQueue<int> queue = new();
queue.Enqueue(42);
queue.TryDequeue(out int value);
ConcurrentBag<int> bag = new(); // unordered, optimized for scenarios where each thread mostly adds/removes its own items
These collections, in System.Collections.Concurrent, are specifically designed and implemented for safe concurrent access without requiring you to wrap every operation in your own external lock — ConcurrentDictionary's AddOrUpdate in particular is a genuinely useful atomic compound operation (read-then-conditionally-write, done safely as one step) that would otherwise require careful manual locking to get right.
Why these are still not a universal substitute for thinking about thread safety
Even with a ConcurrentDictionary, a sequence of MULTIPLE operations against
it (check if a key exists, THEN decide whether to add it) is not
automatically atomic as a WHOLE, even though each individual operation is
— this is the same "Interlocked can't cover multi-step sequences"
limitation from Section 7, applied here to concurrent collections.
Worth the same caution as Interlocked (Section 7): a concurrent collection guarantees each individual operation is thread-safe, but a sequence of several operations against it, taken together, is not automatically atomic unless you specifically use one of the collection's own compound methods (AddOrUpdate, GetOrAdd) designed for exactly that composite case, or wrap the sequence in your own external synchronization.
9. volatile and Memory Visibility
The problem: a write on one thread might not be immediately visible to another, due to compiler/CPU optimizations
private bool _shouldStop = false;
void Worker()
{
while (!_shouldStop) { /* do work */ } // might loop FOREVER, even after _shouldStop is set to true elsewhere,
} // because the compiler/CPU may have CACHED the value in a register
void Stop() => _shouldStop = true; // called from a DIFFERENT thread
This is a genuinely subtle correctness issue distinct from the race conditions Section 3 covers — it's about visibility, not just ordering: without any synchronization or memory barrier, the compiler and CPU are permitted to optimize Worker's loop by caching _shouldStop's value in a register rather than re-reading it from memory on every iteration, which means the loop might never actually observe the update Stop() makes on another thread, even though there's no "race" over a shared mutation in the Section 3 sense — just a stale, cached read.
volatile: telling the compiler/CPU this field must always be read fresh from memory
private volatile bool _shouldStop = false;
Marking a field volatile disables this specific class of optimization for that field — every read genuinely goes to memory, and every write is genuinely, immediately visible to other threads, closing exactly the visibility gap above. Worth knowing this is a narrow, specific tool: volatile addresses visibility of simple field reads/writes; it does not make compound operations atomic (Section 3's counter++ race is not fixed by making counter volatile — that's still a genuine race requiring lock or Interlocked).
In practice, lock (or Interlocked) already provides the memory-visibility guarantee too
Both lock/Monitor and Interlocked operations already include the necessary
MEMORY BARRIERS to guarantee visibility, alongside their mutual-exclusion
or atomicity guarantees — which is precisely why most real-world C# code
reaches for lock or Interlocked rather than volatile directly; volatile
is a narrower, lower-level tool reserved for specific, simple flag-style
cases where a full lock genuinely isn't warranted.
This is worth knowing to correctly scope volatile's actual usefulness: for the overwhelming majority of shared-state scenarios, lock or Interlocked already solves both the atomicity problem (Section 3) and the visibility problem this section describes, in one mechanism — volatile is specifically useful for the narrower case of a simple flag or reference being read/written without any other synchronization already in place around it.
10. The Parallel Class and Data Parallelism
Parallel.For/Parallel.ForEach: splitting a loop's iterations across multiple threads automatically
Parallel.For(0, 1_000_000, i =>
{
ExpensiveComputation(i); // each iteration runs independently, distributed across available CPU cores
});
Parallel.ForEach(items, item =>
{
ProcessItem(item);
});
This is genuinely the tool this series' Task guide's Section 11 points to for data parallelism — Parallel.For/ForEach automatically partitions the iteration range across multiple threads (typically drawn from the thread pool, Section 2), running independent iterations concurrently to exploit multiple CPU cores for genuinely CPU-bound work, which is a different goal entirely from async/await's non-blocking-I/O purpose.
Why the loop body needs to be thread-safe, exactly like any other multithreaded code
int total = 0;
Parallel.For(0, 1000, i =>
{
total += i; // ❌ the EXACT same race condition as Section 3 — Parallel.For doesn't magically prevent this
});
Parallel.For genuinely runs iterations on multiple threads simultaneously — every synchronization concern this guide has covered (race conditions, thread-safe collections, Interlocked) applies exactly as much inside a Parallel.For body as it would in manually-created threads; the convenience of the Parallel API doesn't grant any exemption from correct synchronization discipline.
Parallel.For's built-in accumulator overload, for the common aggregation case
int total = Parallel.For(0, 1000, () => 0, // per-thread LOCAL accumulator, avoids shared-state contention entirely
(i, state, localSum) => localSum + i, // combine into the LOCAL sum
localSum => Interlocked.Add(ref total, localSum) // only ONCE per thread, merge into the SHARED total
).ToString() is var _ ? 0 : 0; // (illustrative — real usage combines the overload's thread-local and final-action params directly)
Parallel.For provides an overload specifically designed for this exact aggregation pattern — each thread accumulates into its own local variable (no contention at all during the bulk of the work), and only combines into the shared total once, at the very end, per thread — this is a genuinely well-designed pattern worth knowing about specifically because it avoids Section 3's race entirely, rather than requiring a lock or Interlocked call on every single iteration.
11. PLINQ: Parallel LINQ
.AsParallel(): opting a LINQ query into parallel execution
var results = numbers
.AsParallel()
.Where(n => IsExpensiveToCheck(n))
.Select(n => ExpensiveTransform(n))
.ToList();
PLINQ extends ordinary LINQ (this series' LINQ guide) with .AsParallel(), which causes the subsequent query operators to be evaluated across multiple threads rather than sequentially — a natural fit specifically for CPU-bound, per-element work over a genuinely large collection, where the per-element cost is high enough to make the parallelization overhead worthwhile.
Why PLINQ is not automatically a win, and when to reach for it
Parallelization has real overhead — partitioning the data, coordinating
threads, merging results back together. For a CHEAP per-element
operation over a SMALL collection, this overhead can genuinely exceed
any benefit, making the parallel version SLOWER than the ordinary,
sequential LINQ equivalent.
This mirrors this series' LINQ guide's Section 12 caution about LINQ generally in hot paths — PLINQ specifically pays off when the per-element work is genuinely expensive (justifying the coordination overhead) and the collection is large enough to distribute meaningfully across cores; for cheap, fast per-element operations, ordinary sequential LINQ (or a plain loop) frequently performs better, and the only reliable way to know is to measure, not assume.
AsOrdered(): PLINQ's default behavior can reorder results, and how to preserve original order
var orderedResults = numbers.AsParallel().AsOrdered().Where(n => n > 10).Select(n => n * 2).ToList();
By default, PLINQ does not guarantee results come back in the same order as the source sequence — since work is distributed across threads that complete at different times, results naturally arrive out of order unless you explicitly opt into AsOrdered(), which preserves original sequence order at some cost to the parallelization's efficiency.
12. Threading vs. Task/async-await: How They Actually Relate
They solve genuinely different core problems, even though Task sits underneath both
Threading (this guide): genuinely running MULTIPLE things AT ONCE,
sharing memory, needing synchronization when they touch the SAME data.
async/await (this series' dedicated guide): NOT WASTING a thread while
WAITING on something (usually I/O) that isn't CPU work happening on
any thread at all during the wait.
This is the same distinction this series' async/await guide's Section 11 draws between asynchrony and parallelism, restated here from threading's own vantage point: Task.Run (this series' Task guide) uses the thread pool to achieve genuine, if lightweight, threading for CPU-bound work; awaiting a true I/O-bound Task achieves non-blocking behavior without necessarily involving multiple threads doing anything simultaneously at all.
Where the two genuinely do overlap: Task.Run plus await
public async Task<int> ComputeInBackgroundAsync()
{
int result = await Task.Run(() => ExpensiveCpuBoundComputation()); // genuine THREADING (offloaded to the pool)
// combined with genuine ASYNC WAITING
// (the calling thread is freed while it waits)
return result;
}
This is the concrete point of intersection: Task.Run genuinely uses a second thread (from the pool) to run CPU-bound work concurrently with whatever the calling thread does next; awaiting that Task is what lets the calling thread avoid blocking while that background thread does its work — the two concepts are complementary here, not competing, each solving its own half of "run this expensive computation without freezing the UI/request thread."
Every synchronization primitive in this guide applies equally to code reached via Task.Run
private readonly object _lockObject = new();
private int _sharedCounter = 0;
async Task IncrementFromMultiplePlacesAsync()
{
await Task.Run(() =>
{
lock (_lockObject) { _sharedCounter++; } // this lock is JUST as necessary here as in a raw Thread
});
}
Worth stating directly: nothing about Task/async/await exempts code from this guide's synchronization requirements — if multiple Task.Run calls (or multiple awaited operations resuming on different pool threads) touch the same shared mutable state, exactly the same race conditions, deadlocks, and visibility concerns this guide covers apply, since underneath the Task abstraction, it's still genuinely multiple threads potentially touching the same memory.
13. Thread Safety Design Patterns
Immutability: the most effective thread-safety strategy, because there's nothing to race over
public record Point(int X, int Y); // immutable (per this series' Generics/OOP guides) — inherently thread-safe
var shared = new Point(1, 2); // any number of threads can READ this concurrently, with ZERO synchronization needed
This is worth stating as the single most effective thread-safety technique of all: an object that's genuinely immutable after construction has no mutable state for concurrent writers to race over — Section 3's entire danger zone (shared, mutable, written-by-multiple-threads state) simply doesn't apply. Preferring immutable data structures, especially for data genuinely shared across threads, sidesteps an enormous amount of the complexity this guide otherwise covers.
Confinement: giving each thread its own independent copy, avoiding sharing entirely
[ThreadStatic]
private static int _threadLocalCounter; // each THREAD gets its OWN independent copy of this field
// Or, more commonly in modern code:
private static readonly ThreadLocal<int> _counter = new(() => 0);
If a piece of mutable state genuinely doesn't need to be shared across threads — each thread can have its own, independent instance — [ThreadStatic] or ThreadLocal<T> sidestep synchronization entirely by construction, the same way per-thread-only state in Section 3's opening breakdown was already established to be inherently safe.
Encapsulating synchronization inside a thread-safe wrapper class
public class ThreadSafeCounter
{
private readonly object _lock = new();
private int _value;
public void Increment() { lock (_lock) { _value++; } }
public int Value { get { lock (_lock) { return _value; } } }
}
This is the same encapsulation principle this series' OOP guide's Section 2 covers, applied specifically to thread safety — bundling the synchronization inside the class that owns the state, so every caller automatically gets correct, consistent locking without needing to remember to apply it themselves at every call site, closing exactly the "forgot to lock at one of the access points" gap Section 4 warned about.
14. Common Pitfalls
| Pitfall | Why it hurts | Better approach |
|---|---|---|
| Mutating shared state from multiple threads without any synchronization | A genuine, non-deterministic race condition — lost updates, corrupted collections, intermittent wrong results | Protect every access to shared, mutable, multi-writer state with lock (or an appropriate alternative, Sections 4-8) |
Locking on this or on a string literal |
Both create the risk of accidental, unintended lock sharing with external or unrelated code | Use a dedicated, private readonly object created solely to serve as the lock target |
| Acquiring multiple locks in inconsistent order across different code paths | A classic, genuinely common deadlock: two threads each holding a lock the other needs | Always acquire multiple locks in the same, agreed-upon order across every code path (Section 5) |
Assuming Interlocked or a concurrent collection makes an entire multi-step SEQUENCE atomic |
Each individual operation is atomic, but a sequence of several is not, unless a specific compound method is used | Use lock for genuinely multi-step, related state changes; reserve Interlocked/concurrent collections for single-operation cases |
Using ordinary List<T>/Dictionary<TKey,TValue> from multiple threads without synchronization |
These are explicitly not thread-safe — concurrent mutation can corrupt internal state, not just produce a wrong result | Use System.Collections.Concurrent types, or protect ordinary collections with a lock around every access |
Relying on a plain bool flag to signal a stop condition across threads, with no synchronization |
The compiler/CPU may cache the value, so a write on one thread might never become visible to another | Mark the flag volatile, or use lock/Interlocked, which already provide the necessary memory-visibility guarantee |
Reaching for Parallel.For/PLINQ on cheap, fast per-element work over small collections |
The coordination overhead of parallelization can exceed any benefit, making it slower than a sequential loop | Measure before parallelizing; reserve Parallel/PLINQ for genuinely expensive per-element work over sufficiently large data |
Assuming code reached via Task.Run or resumed after await is exempt from threading concerns |
Underneath the Task abstraction, it's genuinely still multiple threads potentially touching shared state |
Apply the exact same synchronization discipline to shared state touched from Task.Run/async continuations as to raw Thread code (Section 12) |
Quick Reference Table
| Concept | C# Syntax | Purpose |
|---|---|---|
| Create a dedicated thread | new Thread(() => ...); thread.Start(); |
A real, dedicated OS thread — comparatively rare in modern code |
| Queue work to the pool |
Task.Run(() => ...) / ThreadPool.QueueUserWorkItem(...)
|
Reuses pooled threads, avoiding per-task thread creation cost |
| Mutual exclusion | lock (_lockObject) { ... } |
Only one thread at a time inside the block; the standard general-purpose tool |
| Cross-process exclusion | new Mutex(false, "Global\\Name") |
Coordination across separate OS processes, not just threads |
| Limited concurrent access | new SemaphoreSlim(3, 3) |
Allows up to N concurrent callers, not just one |
| Many readers, exclusive writer | ReaderWriterLockSlim |
Optimizes read-heavy workloads over a plain lock
|
| Lock-free atomic operation | Interlocked.Increment(ref _counter); |
Atomic single-variable operations without lock overhead |
| Thread-safe collections |
ConcurrentDictionary<TKey,TValue>, ConcurrentQueue<T>
|
Purpose-built, safe-by-design alternatives to ordinary collections |
| Memory visibility for a simple flag | private volatile bool _shouldStop; |
Guarantees fresh reads/immediate write visibility for a single field |
| Data parallelism |
Parallel.For(...), .AsParallel() (PLINQ) |
Splits independent, CPU-bound work across multiple threads/cores |
Conclusion
Threading is fundamentally about the correctness challenges that appear the moment more than one sequence of instructions can genuinely touch the same memory at the same time — race conditions, deadlocks, and memory-visibility gaps are not exotic edge cases in multithreaded code; they're the default outcome of shared mutable state without deliberate, correct synchronization, and their non-deterministic nature is exactly what makes them so much harder to catch in testing than an ordinary logic bug. lock/Monitor remains the standard, general-purpose tool for the overwhelming majority of real-world synchronization needs, with Interlocked, concurrent collections, semaphores, and reader-writer locks each addressing a narrower, more specific shape of the same underlying problem — and immutability and thread confinement remain the most effective strategies of all, specifically because they eliminate the shared-mutable-state precondition the entire problem depends on.
This guide's relationship to this series' Task and async/await guides is genuinely complementary rather than redundant: those guides are about efficiently waiting without wasting a thread; this one is about correctness once you genuinely have multiple threads running simultaneously and potentially touching the same data — and Task.Run, the bridge between the two, means every synchronization discipline this guide covers remains fully in force even inside async methods, the moment CPU-bound work is deliberately offloaded to a second thread. Understanding both halves — non-blocking waiting, and correct synchronization under genuine concurrency — is what it takes to write concurrent C# code that's not just fast, but reliably, deterministically correct.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the counter-was-off-by-exactly-one-in-production-but-never-in-testing story that made race conditions' non-determinism click far better than any explanation ever could.
Top comments (0)