Thread Safety Explained: Confine, Freeze, or Lock
9 min readBytePatterns
What thread-safe code means and how to design it: confine state to one thread, make shared data immutable, then guard the rest with one documented lock.
"Is this code thread-safe?" is a question people usually answer by squinting at it for races. That rarely works, because the bug is in an interleaving you did not imagine. A better approach is to design so that most of the code cannot race at all: keep state private where you can, make shared state immutable where you can, and put a lock around the small remainder. That order matters more than any locking trick, and it is what an interviewer is listening for.
The problem it solves
Code is thread-safe when its invariants hold no matter how the threads' steps interleave. The failures are familiar:
- Lost updates. Two threads read a counter, both add one, both write back, and one increment vanishes (the race condition classic).
- Check-then-act. Two threads both see a seat as free, and both book it.
- Torn reads. A reader sees an object halfway through an update: the new name with the old address.
- Locks everywhere. Guarding every getter makes code slow, invites deadlock, and still misses the compound action that spans two calls.
A race needs three ingredients: shared state, that is mutable, accessed without coordination. Remove any one and the race is gone. The design order is simply: remove them in the cheapest order.
The intuition
Work down the list, stopping as soon as nothing is left:
- List what two threads can reach. Globals, attributes of shared objects, class variables, caches, anything passed into more than one thread.
- Confine. Give each thread its own data: its own slice of the input, its own subtotal, its own connection. State only one thread touches needs no synchronisation and cannot race.
threading.local()and passing work through aqueue.Queue, so each item is owned by one thread at a time, are both confinement. - Freeze. Whatever must be shared, make immutable: tuples, frozen dataclasses, strings, and objects built fully before other threads see them. Races need a write; readers alone can never disagree. To "change" shared configuration, build a new object and swap the single reference.
- Guard the rest. What is left, shared and mutable, gets one lock, and a comment saying exactly which state it guards. Every access, reads included, goes through it, and the whole compound action (check then act, read then write) sits inside one critical section.
Only if that genuinely fails, usually on measured contention, reach for finer-grained locks or lock-free techniques.
One Python note, as of September 2026: the global interpreter lock in standard CPython does not make your code thread-safe. It keeps the interpreter's own structures consistent, but a thread can still be switched out between the check and the act. CPython's optional free-threaded build, which removes the GIL, makes that even more visible, and the design rules above do not change.
Watch it run
The animation applies the lesson's checklist to its own example: two threads, six readings, one dictionary of totals. Work down the list in order: don't share it, freeze it, or guard it. First, list every piece of state that two threads can actually reach: readings and totals. Step one, confine: each thread gets its own slice, [3, 8, 1] and [9, 4, 7], and its own subtotal. State a single thread owns needs no synchronisation and cannot race: the sums are 12 and 20, and zero locks so far. Step two, freeze: readings is read by both and never mutated, and races need a write. That leaves one shared, mutable thing, totals, which gets one lock and a comment. Left takes the lock, writes its 12, and hands the lock back while right waits; the critical section is one line. Right writes 20 next, and 12 + 20 = 32, on this run and on every other one. Reach for clever lock-free tricks only after those three have genuinely failed you.
Designing Thread-Safe Code
Step 1 of 9
Work down the list in order: don't share it, freeze it, or guard it.
The same interactive animation as the lesson — step through it with the controls.
The code
The lesson's example with the input frozen as a tuple, then a brute-force explorer that runs every interleaving of two ticket buyers. Each thread is a generator, and each yield is a point where the scheduler may switch; a thread waiting on the lock is not runnable until it is free:
import threading
readings = (3, 8, 1, 9, 4, 7) # freeze: a tuple cannot be mutated by anyone
totals = {}
totals_lock = threading.Lock() # guards: totals, and nothing else
def sum_chunk(name, chunk):
subtotal = sum(chunk) # confine: local to this thread, no lock
with totals_lock: # guard: the one shared, mutable thing
totals[name] = subtotal
parts = {"left": readings[:3], "right": readings[3:]}
ts = [threading.Thread(target=sum_chunk, args=kv) for kv in parts.items()]
for t in ts: t.start()
for t in ts: t.join()
print(sorted(totals.items()), sum(totals.values())) # [('left', 12), ('right', 20)] 32
from collections import Counter
def run_all(program, names=("ann", "bob")):
"""Brute force: run every possible interleaving of the threads' steps."""
outcomes = Counter()
def explore(schedule):
state = {"seat": None, "sold": 0, "lock": None}
threads = {n: program(state, n) for n in names}
pending = dict.fromkeys(names) # what each thread yielded last
for n in schedule:
pending[n] = next(threads[n], "done")
runnable = [n for n in names if pending[n] != "done" and not
(pending[n] == "acquire" and state["lock"] not in (None, n))]
if not runnable:
outcomes["sold %d" % state["sold"]] += 1
for n in runnable:
explore(schedule + [n])
explore([])
return dict(sorted(outcomes.items()))
def check_then_act(state, me):
if state["seat"] is None: # check...
yield # ...another thread may run right here...
state["seat"] = me # ...then act on a stale answer
state["sold"] += 1
yield
def guarded(state, me):
yield "acquire" # wait until the lock is free
state["lock"] = me
if state["seat"] is None:
yield # a switch can still happen, but the other
state["seat"] = me # thread is parked at its acquire
state["sold"] += 1
state["lock"] = None # release
yield
print(run_all(check_then_act)) # {'sold 1': 6, 'sold 2': 12}
print(run_all(guarded)) # {'sold 1': 26}
Unguarded, the one seat is sold twice in 12 of the 18 possible schedules; a real thread scheduler would hit those rarely, which is exactly why such bugs survive testing. With the check and the act inside one critical section, all 26 schedules sell it once. The same holds for three buyers, and a seeded random check runs real threads over 300 inputs split into private slices, against a brute-force sum():
three = ("ann", "bob", "cy")
print(run_all(check_then_act, three)) # {'sold 1': 90, 'sold 2': 360, 'sold 3': 540}
print(run_all(guarded, three)) # {'sold 1': 2580}
import random
rng = random.Random(32)
ok = True
for _ in range(300):
data = tuple(rng.randint(-50, 50) for _ in range(rng.randint(0, 60)))
k = rng.randint(1, 8)
totals.clear()
chunks = {"t%d" % i: data[i::k] for i in range(k)} # disjoint, private slices
ts = [threading.Thread(target=sum_chunk, args=kv) for kv in chunks.items()]
for t in ts: t.start()
for t in ts: t.join()
ok &= len(totals) == k and sum(totals.values()) == sum(data) # brute force: sum()
print(ok) # True
The complexity
- Confined and frozen state: zero synchronisation cost, and it scales with threads.
- One lock: each critical section runs one thread at a time, so keep it short: compute outside, publish inside, as
sum_chunkdoes. - Exhaustive interleavings grow combinatorially, from 18 schedules for two threads to 990 for three here, which is why you design races out rather than test them out.
Where it goes wrong
- Thread-safe parts, unsafe whole. Each dictionary operation may be safe on its own;
if k not in d: d[k] = ...still is not. - Locking the write but not the read. A reader without the lock can see a half-finished update.
- Undocumented locks. Nobody knows which lock guards which field, so someone adds an access without it.
- Leaking confined state. Returning a reference to a thread's private list makes it shared again.
- Relying on the GIL. It never protected compound actions.
When it shows up in interviews
As "what does thread-safe mean?", "how would you make this class thread-safe?", and in design exercises such as a thread-safe cache, counter or bounded buffer. The strong answer names the order, confine, freeze, guard, before any code.
How to say it in an interview
"Thread-safe means the invariants hold under any interleaving. A race needs shared, mutable, uncoordinated state, so I remove those in the cheapest order. First confine: give each thread its own data, or hand work over through a queue. Then freeze: share only immutable objects, and swap a reference to publish a new version. Whatever is still shared and mutable gets one lock, documented with what it guards, and the whole check-then-act goes inside it, reads included. I'd keep critical sections short, and only consider lock-free code if contention shows up in measurements."