← Open in the full interactive course (progress tracking, search & more)

The best synchronization code is the synchronization code you never had to write.

The last two lessons were reactive: here's a bug (a race condition), here's another bug (a deadlock), here's how to patch each one after the fact with a lock, a timeout, a consistent ordering convention. Useful — but patching is not the same as designing well in the first place. This lesson flips the whole Part's synchronization toolkit around and asks a different question: if you were designing a class from scratch, before any bug existed, how would you shape it so that most of that patching was never necessary at all?

In this lesson, you'll learn the actual hierarchy of thread-safe design strategies — starting from the strongest (immutability, which needs zero synchronization) down through minimizing shared state, to encapsulating any synchronization a class genuinely needs inside itself rather than exposing raw state and hoping callers remember to lock correctly. You'll also learn a real, documented .NET convention about which members are — and aren't — safe to call from multiple threads by default, and close by designing a genuinely thread-safe cache class from the ground up.

What Is It?

The Simple Explanation

Thread-safe design means shaping a type's data and API up front so that using it correctly from multiple threads is either automatic (nothing to get wrong) or clearly documented and self-contained (the type protects itself, rather than trusting every caller everywhere to remember to lock around it correctly, forever, in every file that ever touches it).

The Technical Definition

A type is thread-safe if it behaves correctly — no race conditions, no corrupted state, no crashes — when its members are invoked concurrently from multiple threads, without the caller needing to add any external synchronization of their own. Thread-safe design is the discipline of achieving that property deliberately, through a small number of well-understood strategies, rather than by sprinkling lock statements reactively wherever a bug happens to surface.

The core message of this whole lesson

The strongest, simplest way to make code thread-safe is to make it need no synchronization at all. An object with no mutable state after construction can be shared across any number of threads, read concurrently forever, with zero locks and zero risk — because a race condition, as the earlier lesson defined it precisely, requires mutable state, and there simply isn't any. Everything else in this lesson is what to do when you genuinely can't avoid some mutable, shared state — which is real and common, just not the default you should reach for first.

Why Does It Exist?

The Problem — Reactive Locking Doesn't Scale to a Team

Imagine a class that exposes a public mutable field — say, a Dictionary<string, int> — and a comment saying "remember to lock around this before using it from multiple threads." That works exactly as long as every single developer, on every single call site, in every file, forever, reads and obeys that comment. One missed spot — a new feature, a hurried bugfix, someone who never saw the comment — reintroduces a live race condition. The correctness of the whole system now depends on discipline scattered across every caller, rather than being enforced in one place.

The Need

What's needed is for thread-safety to be a property of the type itself — either because there's genuinely nothing to protect (immutability), or because the type protects its own internals and presents a safe contract to every caller, rather than handing out raw mutable state and a wish of good luck.

The Solution — A Hierarchy of Strategies, Strongest First

This lesson presents that hierarchy, from the strategy that eliminates the risk entirely down to the one that merely manages it — with a strong bias toward reaching for the earlier, stronger options whenever the design allows it.

Big Picture

StrategyHow it worksStrength
1. ImmutabilityNo mutable state after construction — nothing for a race condition to happen toStrongest — risk eliminated, zero synchronization needed
2. Minimize shared statePass data through parameters/return values instead of shared fields multiple threads can reachStrong — less to protect in the first place
3. Encapsulated synchronizationThe class owns its own lock/concurrent collection internally; callers use a safe public APIGood — correctness lives in one place, not scattered across every caller
4. Caller-managed synchronizationRaw mutable state is exposed; every caller is expected to lock around it correctly, externallyWeakest — fragile, depends on every caller getting it right, forever

Notice the direction of travel: each strategy down the list pushes more of the correctness burden outward, onto more people, for longer. Good thread-safe design means climbing as far up this list as the problem genuinely allows — and reserving strategy 4 for situations where you have no other choice and can document the contract extremely clearly.

How It Works

STRATEGY 1 — IMMUTABILITY
No mutable state → no synchronization needed, ever
STRATEGY 2 — MINIMIZE SHARED MUTABLE STATE
Prefer parameters and return values over shared fields
STRATEGY 3 — ENCAPSULATE NECESSARY SYNCHRONIZATION
The class owns its own lock — callers never see raw state

Simple Example

The difference between strategy 3 and strategy 4, made concrete — a running total that multiple threads report progress to:

Strategy 4 — caller-managed (fragile)

public class ProgressTracker
{
    public int Completed; // raw, public, mutable

    // Every single caller must remember:
    // lock (someSharedLockObject) { tracker.Completed++; }
}

Strategy 3 — encapsulated (robust)

public class ProgressTracker
{
    private int _completed;

    public void ReportOneCompleted() =>
        Interlocked.Increment(ref _completed);

    public int Completed => Volatile.Read(ref _completed);
}
// Every caller just calls ReportOneCompleted() —
// there is no raw field to forget to protect.

In the fixed version, every caller — today's code, and code written by someone new to the team next year — automatically gets correct behavior, because there was never a raw field for them to touch unsafely. The synchronization decision was made exactly once, by the class's author, in exactly one place.

Real-World Example — Designing a Thread-Safe Cache From Scratch

Here's the whole hierarchy applied together, to a genuinely common real-world need: a small in-memory cache that many concurrent request-handling threads will read from and write to at the same time.

/// <summary> /// A simple in-memory cache. All members of this type are safe /// for concurrent use by multiple threads — no external locking /// is required by callers. /// </summary> public sealed class SimpleCache<TKey, TValue> where TKey : notnull { // Strategy 3: synchronization is encapsulated inside the class. // A ConcurrentDictionary does its own internal locking, so this // class never needs a private lock object of its own for gets/sets. private readonly ConcurrentDictionary<TKey, TValue> _entries = new(); public bool TryGet(TKey key, out TValue value) => _entries.TryGetValue(key, out value!); // GetOrAdd is a single atomic operation on ConcurrentDictionary — // not a separate "check" then "add" from the caller's side, which // would reintroduce exactly the check-then-act race from the // race-conditions lesson. public TValue GetOrAdd(TKey key, Func<TKey, TValue> factory) => _entries.GetOrAdd(key, factory); public void Remove(TKey key) => _entries.TryRemove(key, out _); public int Count => _entries.Count; }

Walking through the design decisions:

Analogy

A Bank Vault vs. a Cash Box on the Counter

A raw mutable public field is a cash box sitting on the counter — anyone who walks by can reach in, and whether the money stays correct depends entirely on every single person who touches it remembering to be careful. A class with encapsulated synchronization is a bank vault with one teller window: customers never touch the cash directly; they hand a request to the teller, who follows the bank's own internal procedures every single time, correctly, regardless of who's asking. The correctness lives inside the vault's design, not in the hope that every customer behaves.

And an immutable value is like a sealed, tamper-evident envelope of cash — nobody, not even the "teller," can change what's inside once it's sealed, so there's nothing to steal or corrupt no matter how many people pass it around.

Under the Hood — The .NET Thread-Safety Convention

This is a real, documented convention worth knowing precisely, not a rule of thumb someone invented: across the .NET Base Class Library, an instance member is not guaranteed to be thread-safe unless its documentation explicitly says so, while static members are generally expected to be safe for concurrent use. This is exactly why a plain List<T> or Dictionary<TKey, TValue> is documented as not thread-safe for its instance members (two threads calling Add on the same List<T> concurrently is a real race condition), while a purpose-built type like ConcurrentDictionary<TKey, TValue> exists specifically to make an explicit, documented promise about its instance members that the ordinary collections don't make.

Member kindDefault expectationExample
Instance membersNOT thread-safe unless documented otherwiseList<T>.Add, Dictionary<TKey,TValue>.this[key]
Static membersGenerally expected to be thread-safeMath.Max, string.Concat, Path.Combine
Explicitly documented instance membersThread-safe, by explicit design contractConcurrentDictionary<TKey,TValue>'s instance members

The practical rule this gives you: never assume a type's instance members are safe to call from multiple threads just because it seems simple or you've never personally hit a problem. Check its documentation. If it doesn't say, assume it isn't — and either don't share it across threads, or wrap access to it using the strategies from this lesson.

Common Confusion

1. "It uses a ConcurrentDictionary, so anything I do with it is automatically safe" — not always

Each individual operation on a ConcurrentDictionary is thread-safe in isolation. But if you compose two of those individually-safe operations yourself — say, checking ContainsKey and then, separately, calling the indexer to set a value — you've recreated a check-then-act race, exactly like the inventory example from the race-conditions lesson, just with a "thread-safe" collection underneath it. That's why the cache example above uses GetOrAdd — a single atomic operation the collection provides specifically to avoid this trap — rather than composing "check" and "add" by hand.

2. "Thread-safe" doesn't mean "any two operations can be called together in any order and get the result you intended"

A type can guarantee that each of its individual members won't corrupt its internal state, without guaranteeing anything about the meaningful ordering of a business-level sequence of calls (e.g., "check the balance, then withdraw" across two separate thread-safe method calls can still race at the business-logic level, even if each individual method is perfectly safe on its own). Thread safety is about protecting a type's internal invariants — not a substitute for designing atomic operations for the specific higher-level guarantees your logic actually needs.

Common Mistakes

Mistake 1 — Exposing mutable state raw and documenting "remember to lock" as the only protection

A public mutable field or auto-property, with correctness resting entirely on every caller reading and following a comment.

Encapsulate the synchronization inside the class itself (Strategy 3), so there's no raw state for a caller to mishandle in the first place.

Mistake 2 — Assuming an instance member is safe because it "seems simple"

Sharing a plain List<T> or Dictionary<TKey,TValue> across threads without checking its documentation, because adding an item feels like a trivial operation.

Apply the documented convention: instance members are not thread-safe unless the type explicitly says so. Check first, or default to assuming it isn't.

Mistake 3 — Not documenting the thread-safety contract you actually built

Building a genuinely thread-safe class but leaving its documentation silent on the subject, forcing every future caller to read the implementation to find out.

State the contract explicitly — "all members of this type are thread-safe" or, just as usefully, "this type is not thread-safe; synchronize externally if sharing across threads." A deliberate, documented decision either way beats silence.

When Should I Use Each Strategy?

Immutability
Whenever the type genuinely represents a value, not an evolving thing — the default first choice.
Minimize sharing
Whenever data can flow through parameters/return values instead of a shared field — often it can.
Encapsulated sync
When a type genuinely needs mutable, shared internal state — own the synchronization yourself.
Caller-managed sync
Reserve for rare, well-justified, extremely clearly documented cases — not a first resort.
Rule of thumb: before writing a single lock statement inside a class, ask "does this data need to be mutable at all?" and then "does it need to be shared across threads at all?" Most of the time, honest answers to those two questions eliminate the need for most of the synchronization code you were about to write.

Mental Model

Thread-safe design = shape the type so callers can't get it wrong, instead of trusting them not to.

Remember:
· Immutable state needs zero synchronization — nothing to race on.
· Less shared state means less to protect in the first place.
· Necessary synchronization belongs inside the class, not scattered across every caller.
· Instance members: assume unsafe unless documented otherwise. Statics: generally expected safe.
· A thread-safe collection's individual operations being safe doesn't make your composed sequence of calls automatically safe too.

Key Takeaway


Check Your Understanding

You've seen the full hierarchy of thread-safe design strategies and built a cache class that actually applies them. Let's check the reasoning, not just the vocabulary.

1. Why is immutability considered the strongest thread-safety strategy, rather than just one option alongside locking?

Show answer

Correct: B

Why B is correct: A race condition requires shared, mutable, unsynchronized state. Immutability removes the "mutable" ingredient entirely — there's no write for a concurrent read to race against — so the object is safe to share across threads with zero synchronization, not just safer or less likely to have a problem.

Why A is incorrect: Storage location (stack vs. heap) is unrelated to thread safety — reference-type immutable objects live on the heap and are still perfectly safe to share, because of their immutability, not their storage location.

Why C is incorrect: There's no automatic parallelization happening — the safety comes from there being nothing to mutate, not from any runtime optimization.

Why D is incorrect: Immutable objects can absolutely be freely passed between and shared across threads — that's exactly the point; it's precisely what makes them so useful for concurrent code.

Reinforcement: Immutability doesn't manage the risk of a race condition the way a lock does — it removes the possibility outright.

2. A class exposes a public mutable Dictionary field directly, with an XML comment saying "callers must lock around this before use." What is the primary design weakness of this approach, compared to encapsulating the synchronization inside the class?

Show answer

Correct: B

Why B is correct: This is exactly the "caller-managed synchronization" weakest strategy from this lesson's hierarchy — the correctness burden is scattered across every call site, forever, rather than enforced in one place. Any single developer who misses the comment, in any file, reopens the race condition.

Why A is incorrect: Field vs. property access speed is not the issue being discussed here — the concern is correctness and the distribution of responsibility for synchronization, not raw performance.

Why C is incorrect: A Dictionary field absolutely can be made public in C# — the point is that doing so for shared mutable state is a design weakness, not that it's disallowed by the language.

Why D is incorrect: A comment has no effect on compilation whatsoever — the weakness described is a design and correctness issue, not a build error.

Reinforcement: Encapsulating synchronization inside the class puts correctness in exactly one place — the class's own implementation — instead of trusting every caller to get it right independently.

3. According to the documented .NET thread-safety convention covered in this lesson, what should you assume about the instance members of a type like List<T> unless its documentation says otherwise?

Show answer

Correct: B

Why B is correct: This is the real, documented .NET convention: instance members are not guaranteed thread-safe by default — a type has to explicitly document that guarantee (as ConcurrentDictionary<TKey,TValue> does) for you to rely on it. Static members are generally expected to be safe for concurrent use.

Why A is incorrect: This gets the default backwards — the safe default assumption is the opposite: assume NOT thread-safe unless documented.

Why C is incorrect: Being generic has nothing to do with thread safety — the convention applies uniformly regardless of whether a type uses generics.

Why D is incorrect: The documentation itself is the intended source of truth for this — that's exactly why the convention exists, so callers don't have to read implementation source code to know what's safe.

Reinforcement: "Not documented as safe" should always be read as "assume unsafe" — this is a real, load-bearing convention across the .NET BCL, not a guess.

4. A developer uses a ConcurrentDictionary but writes: `if (!cache.ContainsKey(key)) { cache[key] = ComputeValue(key); }` from multiple threads. Why can this still exhibit a race condition, even though ConcurrentDictionary's individual members are thread-safe?

Show answer

Correct: B

Why B is correct: This is exactly the check-then-act race described in Common Confusion — ContainsKey and the indexer assignment are each individually thread-safe, but composing them as two separate steps recreates the same race-condition shape from the race-conditions lesson. The fix is to use a single atomic operation like GetOrAdd instead of composing the check and the write yourself.

Why A is incorrect: ConcurrentDictionary genuinely does synchronize its individual operations internally — the problem here is composing two safe operations, not a failure of its internal synchronization.

Why C is incorrect: Nothing about calling these members from a background thread causes an exception — the danger is silent, not an error you'd immediately notice.

Why D is incorrect: They can be used together — the code compiles and often appears to work — the problem is specifically the race between the check and the write when done from multiple threads, not a hard restriction on using them together at all.

Reinforcement: A thread-safe collection makes each individual call safe — it does not make an arbitrary sequence of calls you compose yourself safe. Prefer a single atomic method when one exists.

5. You're designing a new class that will hold configuration values loaded once at startup and never changed afterward, read concurrently by many request-handling threads. Which strategy from this lesson's hierarchy best fits this scenario?

Show answer

Correct: B

Why B is correct: "Loaded once, never changed afterward" is precisely the profile of a value that should be immutable — this lesson's strongest strategy applies directly. An immutable configuration object can be read by any number of concurrent threads with zero locking, because there's no mutation to protect against after construction.

Why A is incorrect: Locking around a value that never changes adds pure overhead and complexity for no safety benefit — there's nothing to protect once construction is complete, so this is unnecessary.

Why C is incorrect: This is the weakest strategy in the hierarchy, and it's entirely unnecessary here — there's no reason to burden every caller with locking around data that structurally cannot change.

Why D is incorrect: Concurrent reads of immutable data are completely safe by definition — many threads reading, with nobody ever writing, is exactly the case that requires no coordination at all.

Reinforcement: "Read by many, written once (or never, after construction)" is the textbook signal to reach for immutability first — it's the cheapest and safest fix available.

You've now moved from reacting to concurrency bugs to designing them out from the start. Up next: applying all of this to a genuinely harder problem — consuming multiple async streams concurrently.


dotnetmadeeasy.com — Learn C# and .NET, the right way.