Module 10: Concurrency-Safe Design
Why this matters
Every pattern so far has been reasoned about as if only one thread ever touches your objects. Real systems rarely get that luxury — a web server handles many requests concurrently, a UI has a background worker thread, a cache is read and written from everywhere at once. A design that's perfectly correct single-threaded can silently corrupt data, deadlock, or construct two "singletons" the moment a second thread gets involved. This module gives you the concrete vocabulary (race condition, critical section, lock) and shows exactly where module 06's naive Singleton breaks — plus the two techniques (immutability, and putting locks inside the class that owns the state) that make most of this manageable rather than terrifying.
Concepts
Race conditions and critical sections
A race condition happens when two threads access shared mutable
state concurrently, and the outcome depends on timing you don't control.
The classic example: count += 1 looks like one operation but is
actually three (read count, add 1, write it back) — if two threads
interleave those three steps, an increment can be lost entirely.
import threading
counter = 0
def increment():
global counter
for _ in range(100_000):
counter += 1 # read-modify-write — NOT atomic, even under the GIL
threads = [threading.Thread(target=increment) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # expected 200000 — almost always LESS, due to lost updates
Python's Global Interpreter Lock (GIL) means only one thread executes
Python bytecode at a time, but that does not make count += 1
atomic — it's still multiple bytecode operations, and the interpreter can
switch threads between them. The GIL prevents certain classes of
low-level corruption, not this one. This is a genuinely common
misconception worth being explicit about.
The fix: wrap the critical section (the code that touches shared state) in a lock, so only one thread executes it at a time.
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
with lock: # only ONE thread can be inside this block at a time
counter += 1
threads = [threading.Thread(target=increment) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # 200000 — correct, every time
A properly thread-safe Singleton
Module 06 showed a lazy Singleton with a double-checked lock, and promised the full reasoning would land here. First, see exactly what breaks without it:
class AppConfig:
_instance = None
@classmethod
def get_instance(cls):
if cls._instance is None: # BROKEN under concurrency:
cls._instance = AppConfig() # two threads can BOTH pass the check above
return cls._instance # before either finishes creating an instance —
# resulting in two different "singleton" objects
The fix — guard creation with a lock, and check again inside the lock (the "double check"), because another thread might have finished creating the instance while this one was waiting for the lock:
import threading
class AppConfig:
_instance = None
_lock = threading.Lock()
@classmethod
def get_instance(cls):
if cls._instance is None: # fast path: skip locking if already created
with cls._lock:
if cls._instance is None: # SECOND check — another thread may have juat built it
cls._instance = AppConfig()
return cls._instance
In practice, Python's cleanest thread-safe singleton is usually simpler
than this: a plain module is only ever imported (and its top-level
code executed) once, guarded by Python's own internal import lock —
config.py containing settings = {} at module level, imported as
from config import settings everywhere, is a thread-safe singleton
with zero manual locking.
Why check twice? The first check (before the lock) is a fast path — after the singleton is created once, every future call skips locking entirely, since locking has real overhead even when uncontested. The second check (inside the lock) exists because between the first check returning "not yet created" and this thread actually acquiring the lock, some other thread might have gotten there first and already finished construction — without the second check, you'd construct a second instance anyway.
Producer-consumer: decoupling work generation from work processing
Problem it solves: one or more threads produce work items, one or more threads consume and process them, at potentially different speeds — you need a hand-off point that's itself safe under concurrent push/pop, without hand-rolling locks and wait-conditions yourself.
import threading
import queue
import time
work_queue = queue.Queue() # already thread-safe — no manual locking needed
def producer():
for i in range(5):
print(f"Producing {i}")
work_queue.put(i)
time.sleep(0.1)
work_queue.put(None) # a sentinel value signaling "no more work"
def consumer():
while True:
item = work_queue.get() # BLOCKS until something is available — no busy-waiting
if item is None:
break
print(f"Consuming {item} -> {item ** 2}")
t1 = threading.Thread(target=producer)
t2 = threading.Thread(target=consumer)
t1.start(); t2.start()
t1.join(); t2.join()
The single most important lesson here: neither example hand-rolls
any locking at all. queue.Queue/BlockingCollection<T> are themselves
already thread-safe, battle-tested implementations of exactly this
hand-off — reinventing this with your own lock and condition-variable
logic is both unnecessary and a common source of subtle bugs (see Common
Mistakes).
Immutability: the best lock is no lock
Module 03 introduced value objects (@dataclass(frozen=True)/record)
for value equality. Here's their other major payoff: an object that
can never be mutated after construction cannot have a race condition,
because there is no "mutate" operation for two threads to race on. This
is often cheaper and safer than any amount of careful locking.
# UNSAFE: a mutable Point shared and modified by two threads
class MutablePoint:
def __init__(self, x, y):
self.x = x
self.y = y
# Two threads both calling point.x += 1 concurrently -> the exact same lost-update race as 'counter' above
# SAFE: an immutable value object — there's no mutation to race on
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
def move_right(point: Point) -> Point:
return Point(point.x + 1, point.y) # returns a NEW Point — never touches the old one
p1 = Point(0, 0)
p2 = move_right(p1) # p1 is UNCHANGED — safe to hand p1 to as many threads as you like,
# none of them can ever see it in a half-updated state
The practical guidance: prefer immutable data passed between threads over mutable shared state guarded by locks, wherever the design allows it. Locks are a necessary tool for state that genuinely must be shared and mutated (like the counter and the singleton above) — but the cheapest way to win a synchronization bug is to have no shared mutable state to synchronize in the first place.
Where locks belong in a design
Three concrete rules, applied as design decisions, not just syntax:
- Keep the critical section as small as possible. Lock only the actual read-modify-write of shared state — never expensive work (I/O, sleeping, a slow computation) that doesn't itself touch the shared data, since anything inside the lock blocks every other thread waiting on it.
- Encapsulate the lock inside the class that owns the shared state,
the same way module 01 taught you to encapsulate a field behind a
method rather than trusting every caller to validate it themselves.
A class that exposes its lock publicly and expects callers to
remember
lock(myObject.TheLock) { ... }around every access is one forgotten call away from a race condition — put the locking inside the method (as theIncrement/incrementexample does), so it's structurally impossible to forget. - Never acquire multiple locks in inconsistent order across
different code paths. If thread A locks
XthenY, and thread B locksYthenX, both can end up permanently waiting for a lock the other holds — a deadlock. If a design genuinely needs two locks, always acquire them in the same, fixed global order everywhere in the codebase.
Hands-on exercises
1. Reproduce, then fix, a race condition
Run the unsynchronized counter example above several times and observe the final count is usually wrong (and inconsistent between runs). Add the lock and confirm it's now always exactly 200,000, every run.
2. Thread-safe Singleton
Implement the double-checked-locking AppConfig singleton (or, in C#,
the Lazy<T> version) and write a short test that starts several
threads all calling get_instance()/GetInstance() simultaneously,
confirming every thread receives the same object (compare by identity/
ReferenceEquals, not by value).
3. Producer-consumer pipeline
Build the producer/consumer example above with two consumers instead of one, both pulling from the same queue. Confirm every produced item gets consumed exactly once, never twice, and never dropped.
4. Race condition removed via immutability
Take the unsafe MutablePoint example and have two threads both call
point.x += 1 a large number of times concurrently — observe lost
updates, just like the counter. Then replace it with the immutable
Point + move_right/MoveRight pattern, confirm the race is
structurally impossible now (there's no shared mutation left to race
on).
5. Encapsulate the lock
Given a Counter class whose value field is public and mutated
directly by callers wrapping their own lock/with lock: around every
access (easy to forget), refactor so the lock lives inside an
increment()/Increment() method on Counter itself — callers just
call the method, with no way to forget the lock because they never see
it.
Independent challenge
No code given.
Task: Design a small thread-safe in-memory cache (a preview of
the real LRU cache coming in module 18) supporting concurrent get(key)
and put(key, value) calls from multiple threads, with the locking
fully encapsulated inside the cache class — callers never see or
manage a lock directly. Prove it's actually safe: spin up several
threads that concurrently put a large number of distinct keys, then
confirm from a single thread afterward that every key that was put is
correctly retrievable via get, with no corrupted or missing entries.
Hint
A single internal lock guarding both get and put (per this module's
"keep the critical section small" guidance, wrapping only the actual
dictionary/Dictionary<K,V> read or write, not any surrounding logic) is
sufficient for correctness here — don't reach for anything fancier
(reader-writer locks, lock-free structures) until you've measured an
actual contention problem; that's the same YAGNI judgment call (module
05) applied to concurrency primitives specifically.
Common mistakes & troubleshooting
- Assuming Python's GIL means threads never need locks. The GIL
prevents certain low-level interpreter corruption, but a compound
operation like
x += 1is still multiple bytecode steps a thread switch can interleave — this module's very first example proves it. Locking is still required for correctness on any shared mutable state, GIL or not. - Exposing a lock publicly and trusting every caller to remember to
acquire it. One missed
lock/with lock:anywhere in the codebase reintroduces the race. Put the lock inside the class, acquired automatically by its own methods. - Holding a lock across slow or blocking work that doesn't touch the shared state. This needlessly serializes threads that could otherwise proceed in parallel — keep locked sections to the minimum actual read/write of shared data.
- Acquiring multiple locks in inconsistent order across different code paths. The classic deadlock: thread A holds lock 1, waits for lock 2; thread B holds lock 2, waits for lock 1 — neither ever proceeds. Always acquire multiple locks in one fixed, global order.
- Hand-rolling a producer-consumer queue with your own lock and
condition-variable logic instead of using
queue.Queue/BlockingCollection<T>. These built-ins are already correctly implemented and heavily tested — reinventing them is extra risk for zero benefit, the concurrency-specific version of module 05's "don't build what already exists" judgment call. - Forgetting the second null-check inside a double-checked lock. Without it, two threads can both pass the first check, then both wait on the lock, and both construct an instance one after another once each gets the lock in turn — the double-check is what actually prevents the second one from doing so.
Checkpoint quiz
- Why is
counter += 1not atomic, even in Python under the GIL? - In the double-checked-locking Singleton, why is there a null-check both before and inside the lock — what would break if you removed the second one?
- What does
queue.Queue/BlockingCollection<T>give you for free that you'd otherwise have to hand-roll with a lock and a condition variable? - Why can't an immutable value object ever have a race condition?
- What's a deadlock, and what's the one concrete rule that prevents the classic two-lock version of it?
- What's wrong with a class that exposes its internal lock publicly for callers to acquire themselves?
Answers
- Because it's actually three separate steps — read the current value, compute the new value, write it back — and a thread switch can occur between any of them, letting two threads both read the same stale value and each write back an increment that overwrites the other's.
- The first check is a fast path that avoids locking overhead once the singleton already exists. The second check (inside the lock) catches the case where another thread finished construction between this thread's first check and it actually acquiring the lock — without it, this thread would go ahead and construct a second instance anyway.
- Thread-safe push/pop with correct blocking behavior (a consumer waiting on an empty queue is woken exactly when an item arrives, with no busy-waiting or missed/duplicated items) — logic that's easy to get subtly wrong if hand-rolled with a raw lock and manual wait/notify calls.
- Because there's no operation that changes it after construction — a race condition requires a read-modify-write (or any mutation) on shared state that another thread could interleave with; if nothing can ever mutate the object, there's nothing to race on.
- A deadlock is when two (or more) threads each hold a lock the other is waiting for, so neither can ever proceed. The fix: always acquire multiple locks in one single, fixed, consistent order everywhere in the codebase, so the "A waits for B while B waits for A" situation can't arise.
- Every caller must remember to wrap their own access in
lock/with lock:themselves — a single missed call anywhere in the codebase silently reintroduces the exact race condition the lock was meant to prevent. Encapsulating the lock inside the class's own methods makes this structurally impossible to forget.
Interview questions
- "How would you make a Singleton thread-safe?"
Guard the lazy-initialization check with a lock, and check again
inside the lock (double-checked locking) to avoid constructing two
instances if two threads both saw "not yet created" before either
acquired the lock — or, in C#, simply use the built-in
Lazy<T>, which does this correctly for you. - "Does Python's GIL mean you never need locks for thread safety?"
No — the GIL serializes bytecode execution but doesn't make
multi-step operations (like
x += 1, or any read-then-write sequence) atomic; a thread switch can still occur mid-sequence, producing the same lost-update race as in a language without a GIL. - "Explain the producer-consumer pattern and why you'd use a
thread-safe queue instead of a plain list with manual locking."
Producers add work items, consumers remove and process them,
decoupled by a shared hand-off point supporting concurrent, blocking
push/pop. A thread-safe queue (
queue.Queue/BlockingCollection<T>) already correctly implements the locking and the "wait until something's available" blocking behavior — hand-rolling this with a plain list and manual locks is extra risk (missed edge cases around waiting/notifying) for no benefit. - "How does immutability help with concurrency?" An object that can never be mutated after construction has no operation for two threads to race on — it can be freely read and shared across as many threads as you like with zero locking, because there's no "in-progress mutation" state it could ever be caught in.
- "What's a deadlock, and how do you avoid it when a design needs multiple locks?" Two threads each hold one lock and wait for a lock the other one holds, so neither ever proceeds. Avoid it by always acquiring multiple locks in one single, consistent, predetermined order throughout the entire codebase, so the circular-wait condition that causes deadlock can never arise.
Further reading & sources
- Python:
threading— Lock objects - official reference forthreading.Lock. - Python:
queue— a synchronized queue class - official reference for the thread-safequeue.Queueused above. - Microsoft Learn:
lockstatement (C#) - official reference for C#'slock. - Microsoft Learn:
Lazy<T>- the production-grade thread-safe lazy-initialization type referenced in the Singleton fix. - Microsoft Learn:
BlockingCollection<T>- official reference for the thread-safe collection used in the producer-consumer example. - Real Python: The GIL - a clear, practical explanation of what the GIL does and doesn't protect you from.
Next
11-requirements-to-class-diagrams — the actual method used in an LLD interview: turning a vague prompt into actors, use cases, entities, relationships, and a first-draft class and sequence diagram, plus a cumulative review across modules 09–11.