Thread-Safe Design

Designing code so it behaves correctly under concurrent access isn't about sprinkling locks everywhere — it's about deliberately choosing what state is shared, what's confined to one thread, and how threads communicate.

Locks and other primitives (covered in the previous topic) are tools; thread-safe design is the discipline of using them well — minimizing shared mutable state in the first place, being explicit about what needs protection, and structuring communication between threads so correctness doesn't depend on getting every lock exactly right by accident.

The Core Problem: Shared Mutable State

Concurrency bugs almost always trace back to one thing: mutable state that more than one thread can see and modify. If no state is shared, there is nothing to race over; if shared state is never mutated, there is nothing to corrupt. Thread-safe design starts by asking, for every piece of state a concurrent program touches: is this actually shared, and does it actually need to be mutable?

This reframes the problem: instead of 'how do I lock this correctly', the first and often more powerful question is 'can I avoid sharing this, or avoid mutating it, at all'.

Strategy 1: Prefer Immutable State

An object that cannot be mutated after creation can be shared freely between threads with zero synchronization — there is no 'in-progress' state another thread could observe, because nothing ever changes. Python's built-in immutable types (int, float, str, tuple, frozenset, bytes) are already safe to share this way; you can extend the same idea to your own data using frozen dataclasses or NamedTuple.

Instead of mutating a shared Point in place, threads that need an 'updated' point create and share a brand-new Point object — the old one is simply discarded, never modified while others might be reading it.

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
# p.x = 5.0   # would raise dataclasses.FrozenInstanceError — cannot mutate

# Sharing an immutable Point across threads needs no lock at all:
# every thread sees the same fixed x, y forever.

Strategy 2: Thread Confinement

Thread confinement means keeping a piece of mutable state entirely local to one thread — never shared — so no synchronization is needed because no other thread can ever see it. A thread's local variables are naturally confined; threading.local() extends this idea to give each thread its own independent copy of an attribute, even through shared code paths.

Even though thread_data is a single module-level object referenced by every thread, each thread transparently gets its own independent request_id attribute — a common technique for per-thread context like database connections or request IDs in web frameworks.

import threading

thread_data = threading.local()

def process_request(request_id: str) -> None:
    thread_data.request_id = request_id   # each thread gets its own copy
    handle()

def handle() -> None:
    # reads the CURRENT thread's request_id, not any other thread's
    print(f"handling {thread_data.request_id} on {threading.current_thread().name}")

threads = [threading.Thread(target=process_request, args=(f"req-{i}",)) for i in range(3)]
for t in threads:
    t.start()
for t in threads:
    t.join()

Strategy 3: Explicit Locking for Genuinely Shared State

When state truly must be shared and mutated (a counter, a cache, a connection pool), the safest design pattern is encapsulation: bundle the lock together with the state it protects inside one object, and never expose the raw state or the lock to outside code. Every access goes through methods that acquire the lock internally, so correctness doesn't depend on every caller remembering to lock correctly.

No caller of ThreadSafeCache ever needs to know a lock exists — it's an implementation detail fully encapsulated inside the class, which is far more robust than expecting every call site to remember to lock a bare shared dict correctly.

import threading

class ThreadSafeCache:
    def __init__(self) -> None:
        self._data: dict[str, object] = {}
        self._lock = threading.Lock()

    def get(self, key: str, default=None):
        with self._lock:
            return self._data.get(key, default)

    def set(self, key: str, value) -> None:
        with self._lock:
            self._data[key] = value

    def get_or_compute(self, key: str, factory) -> object:
        with self._lock:
            if key not in self._data:
                self._data[key] = factory()
            return self._data[key]

Atomicity

An operation is atomic if it appears to happen as a single, indivisible step from every other thread's point of view — no other thread can observe it 'half done'. Very few compound operations in Python are atomic by language guarantee; most require explicit locking to become effectively atomic.

CPython implementation detail: because of the GIL, certain single bytecode operations happen to be effectively atomic in CPython (e.g. list.append(), a single dict item assignment d[k] = v). This is not a language guarantee, is easy to misapply (compound expressions like d[k] = d[k] + 1 are NOT atomic even though each half looks simple), and does not hold on a free-threaded (GIL-less) build — never design around it deliberately.

import threading

shared = {"count": 0}
lock = threading.Lock()

def unsafe_increment() -> None:
    shared["count"] = shared["count"] + 1   # read + compute + write: NOT atomic

def safe_increment() -> None:
    with lock:
        shared["count"] = shared["count"] + 1   # now effectively atomic

Visibility

Visibility is the guarantee that when one thread writes a value, another thread reading it afterward actually sees the new value rather than a stale cached one. In CPython, ordinary attribute and variable writes are visible to other threads promptly because the GIL serializes bytecode execution and there's no per-thread CPU-level caching of Python-level object state to worry about the way there can be in lower-level languages.

This means Python programmers rarely need to think about 'memory visibility' bugs the way C/Java programmers sometimes must with unsynchronized reads across CPU cores — but this is again a CPython/GIL-driven characteristic worth knowing is implementation-specific, not something to build fragile assumptions on top of. Correct synchronization (locks, Events, Conditions) is still the right tool for coordinating when a value is safe to read, not just whether it's technically 'visible'.

Safe Communication Between Threads: queue.Queue

Rather than sharing mutable state directly and coordinating access with locks by hand, a common and much simpler pattern is to have threads communicate exclusively through a thread-safe queue. The standard-library queue.Queue class is internally synchronized — put() and get() are safe to call from multiple threads with zero manual locking required by your code.

This is a preview of the pattern behind the Thread Pools topic's worker model: threads never touch each other's memory directly — all coordination flows through the queue, which handles the locking internally.

import threading
import queue

work_queue: queue.Queue[int] = queue.Queue()
results: queue.Queue[int] = queue.Queue()

def worker() -> None:
    while True:
        item = work_queue.get()
        if item is None:      # sentinel value signaling "no more work"
            break
        results.put(item * item)
        work_queue.task_done()

threads = [threading.Thread(target=worker) for _ in range(3)]
for t in threads:
    t.start()

for i in range(10):
    work_queue.put(i)
for _ in threads:
    work_queue.put(None)   # tell each worker to stop

for t in threads:
    t.join()

collected = sorted(results.get() for _ in range(10))
print(collected)   # [0, 1, 4, 9, ..., 81]

Designing a Thread-Safe Class: A Checklist

When designing a class that will be shared across threads, work through these questions deliberately rather than adding locks reactively after a bug appears:

1. Does this state actually need to be shared across threads at all?
   -> If not, keep it thread-local (threading.local() or a plain local variable).
2. Can this state be made immutable instead of mutable?
   -> If so, share it freely; build new objects instead of mutating.
3. If it must be shared and mutable, is exactly one lock protecting it,
   encapsulated alongside the state (not exposed to callers)?
4. Are ALL read AND write accesses to the state going through that lock,
   with no code path that reads/writes it unprotected?
5. Are compound operations (read-modify-write, multi-field updates)
   done as ONE critical section, not as separate locked steps?
6. Could this be redesigned as message-passing (a Queue) instead of
   shared mutable state, sidestepping the problem entirely?

Practical Usage: Refactoring Shared State into a Safe Design

The refactored version makes it structurally impossible for calling code to read or write the counters without going through the lock, because the fields are private and only reachable via record()/snapshot().

import threading

# BEFORE: unsafe shared state, scattered unprotected access
stats = {"requests": 0, "errors": 0}

def handle_request_unsafe(success: bool) -> None:
    stats["requests"] += 1        # race: two threads can both read the old value
    if not success:
        stats["errors"] += 1       # race: same problem

# AFTER: encapsulated, thread-safe by construction
class RequestStats:
    def __init__(self) -> None:
        self._requests = 0
        self._errors = 0
        self._lock = threading.Lock()

    def record(self, success: bool) -> None:
        with self._lock:
            self._requests += 1
            if not success:
                self._errors += 1

    def snapshot(self) -> tuple[int, int]:
        with self._lock:
            return self._requests, self._errors

Edge Cases

Mutable default arguments and module-level mutable globals are especially dangerous in threaded code, since they're implicitly shared by every caller/thread without that sharing being obvious at the call site. Objects that look read-only but have hidden internal caches (e.g. a class that lazily computes and stores a value on first access) can still race if two threads trigger the lazy computation simultaneously — 'read-only from the outside' does not always mean 'no internal mutation'.

Locking the entire check-then-compute-then-store sequence (not just the store) ensures the factory function runs exactly once even if many threads call get() simultaneously.

import threading

class LazyValue:
    def __init__(self, factory) -> None:
        self._factory = factory
        self._value = None
        self._computed = False
        self._lock = threading.Lock()

    def get(self):
        # Naive version (unsafe): two threads could both see _computed as
        # False and both call factory(), possibly with wasted work or
        # inconsistent side effects.
        with self._lock:
            if not self._computed:
                self._value = self._factory()
                self._computed = True
            return self._value

Key Takeaways

Thread safety bugs come from shared mutable state; the first design move is to reduce sharing or reduce mutability, not to add locks reactively.

Immutable objects can be shared across threads with zero synchronization, because nothing about them can ever change underneath a reader.

Thread confinement (via threading.local() or simply not sharing an object) eliminates the need for synchronization entirely for that state.

When state must be shared and mutable, encapsulate the lock with the state inside one class so correctness doesn't depend on every caller locking correctly.

Atomicity and visibility of compound operations are not guaranteed by the language; relying on CPython/GIL-specific behavior for either is fragile.

queue.Queue offers safe communication between threads without manual locking, and is often simpler and more robust than sharing state directly.

class Cache: def __init__(self): self.data = {} # public, unprotected # elsewhere, in various places across the codebase: cache.data[key] = value # every call site must remember to lock class Cache: def __init__(self): self._data = {} self._lock = threading.Lock() def set(self, key, value): with self._lock: self._data[key] = value

Exposing raw shared state means correctness depends on every single call site remembering to lock correctly; encapsulating access behind methods makes it structurally impossible to bypass the lock.

stats["count"] = stats["count"] + 1 # looks simple, is NOT atomic with lock: stats["count"] = stats["count"] + 1

Any compound read-modify-write, however simple it looks syntactically, requires explicit locking to be safe under concurrent access — it is never atomic just because it fits on one line.

def get(self): if not self._computed: # unprotected check with self._lock: self._value = self._factory() self._computed = True return self._value def get(self): with self._lock: if not self._computed: self._value = self._factory() self._computed = True return self._value

Checking the flag outside the lock creates a race: two threads can both see _computed as False before either acquires the lock, causing the factory to run twice (or worse, inconsistent state).

with lock: d[k] += 1 Even though the GIL is real in CPython, compound operations like += still span multiple bytecode instructions that can interleave between threads; relying on the GIL for correctness is fragile, non-portable (fails on free-threaded builds), and not a language guarantee.

What is the single most effective first step in designing thread-safe code?

Minimizing shared mutable state in the first place — asking whether a piece of state truly needs to be shared, and whether it truly needs to be mutable — rather than starting from 'add locks everywhere'.

Why can immutable objects be shared between threads with zero synchronization?

Because nothing about an immutable object can ever change after creation, there is no possibility of one thread observing it in a partially-updated, inconsistent state — every reader always sees the same fixed value.

What does threading.local() provide, and when is it useful?

It gives each thread its own independent copy of an attribute accessed through a single shared object, useful for per-thread context (like a request ID or a database connection) that should never be visible to or overwritten by other threads.

Why is encapsulating a lock together with the state it protects, inside a class, better than passing a bare lock around externally?

It makes correctness structural rather than a matter of discipline — callers cannot access the state without going through methods that always acquire the lock, so it's impossible to accidentally bypass the protection at some call site.

Is d[k] = d[k] + 1 atomic in CPython?

No — it involves a read, a computation, and a write as separate steps; even though a single bytecode instruction like a plain dict assignment can be effectively atomic in CPython due to the GIL, a compound read-modify-write sequence like this one is not, and requires explicit locking.

How does communicating via queue.Queue sidestep thread-safety problems compared to sharing state directly?

Queue's put()/get() are internally synchronized, so threads never touch the same mutable object concurrently at all — they exchange data through a safe channel instead of coordinating access to shared memory by hand.

Why is it risky to rely on CPython's GIL for the safety of your concurrent code's design?

The GIL is a CPython implementation detail, not a Python language guarantee; code that relies on it can be fragile even within CPython (compound operations still aren't atomic) and will break on a free-threaded (GIL-less) build of CPython or on other Python implementations without a GIL.

Refactor this unsafe shared counter into a thread-safe class using encapsulation, exposing only increment() and value.

import threading class Counter: def __init__(self) -> None: self._count = 0 self._lock = threading.Lock() def increment(self) -> None: with self._lock: self._count += 1 @property def value(self) -> int: with self._lock: return self._count

The private _count and _lock are only reachable through increment() and the value property, both of which acquire the lock for their entire read-modify-write or read, making it structurally impossible for any caller to touch the counter unsafely.

Write a function-scoped design (using threading.local()) so that each thread accumulates its own separate log of messages, with a helper log(message) callable from any thread and get_log() to retrieve the calling thread's own accumulated list.

import threading _local = threading.local() def log(message: str) -> None: if not hasattr(_local, "messages"): _local.messages = [] _local.messages.append(message) def get_log() -> list[str]: return getattr(_local, "messages", []) threading.local() gives each thread an independent view of the same attribute name, so messages accumulated by one thread via log() never appear in another thread's get_log() result — no locking is needed because the state is never actually shared.

Why is this class-level lazy-loading pattern unsafe if get() can be called from multiple threads concurrently, and how would you fix it?

Two threads can both see cls._config as None before either finishes assigning it, so expensive_load() can run more than once (wasting work, or worse, causing inconsistent state if it has side effects). Fix it by wrapping the check-and-assign in a class-level threading.Lock acquired for the whole check-then-set sequence.

count = 0
def bump():
    global count
    count += 1
class LazyConfig:
    _config = None

    @classmethod
    def get(cls):
        if cls._config is None:
            cls._config = expensive_load()
        return cls._config