Courses › Low-Level Design › Foundations
Concurrency & Thread Safety
Race conditions, locks, condition variables, lock granularity and avoiding deadlock.
Almost every LLD round ends with the same follow-up: "What happens if two threads call this at the same time?" Some questions are concurrency questions from the start, like a bounded blocking queue, a rate limiter, a logger or a pub/sub broker. You don't need to be a concurrency expert, but you need a clear vocabulary and a few patterns you can write correctly under pressure.
Spotting the shared state
Start by listing which data more than one thread can touch, and which operations read, then write it. Those operations are where the race conditions are.
class Counter:
def __init__(self):
self.value = 0
def increment(self):
self.value += 1 # read, add, write: two threads can both read 5 and both write 6The classic shapes of a race condition:
- Read-modify-write: counters, balances, inventory.
- Check-then-act:
if seat.is_free(): seat.book(). Another thread can book the seat between the check and the act. - Compound operations on a collection: checking for a key in a map, then inserting it.
Tool 1: a mutex around the critical section
import threading
class Counter:
def __init__(self):
self.value = 0
self._lock = threading.Lock()
def increment(self):
with self._lock:
self.value += 1Rules worth saying out loud:
- Keep critical sections small. Don't do I/O or call external code while holding a lock.
- One lock per piece of state it protects. Name it after the state, for example
_balance_lock. - Never return a mutable internal collection from a locked method. Return a copy.
Tool 2: condition variables for "wait until"
When a thread must wait for a state change, such as "the queue is not empty" or "a slot is free", use a condition variable rather than polling in a loop.
import threading
from collections import deque
class BoundedBlockingQueue:
def __init__(self, capacity):
self._items = deque()
self._capacity = capacity
lock = threading.Lock()
self._not_full = threading.Condition(lock)
self._not_empty = threading.Condition(lock)
def put(self, item):
with self._not_full:
while len(self._items) == self._capacity: # while, not if
self._not_full.wait()
self._items.append(item)
self._not_empty.notify()
def take(self):
with self._not_empty:
while not self._items:
self._not_empty.wait()
item = self._items.popleft()
self._not_full.notify()
return item[!WARNING] Always wait in a while loop A thread can wake up without its condition being true. This happens with spurious wakeups, or when another thread grabbed the item first. Re-check the condition after every wake-up. Using if instead of while is the most common bug in this question.
Tool 3: let a thread-safe structure do it
Often the best answer is to not write the locking yourself:
- Python:
queue.Queue, orthreading.Semaphoreto limit concurrency - Java:
ConcurrentHashMap(withcomputeandmergefor atomic updates),AtomicLong,BlockingQueue,ReentrantReadWriteLock
Mention these, then implement the hand-rolled version if the interviewer asks to see the mechanics.
Choosing the granularity
[!INTERVIEW] Start coarse, then refine "I'll start with one lock on the whole object so it's correct. If contention becomes a problem, I'd switch to a lock per account." Correct first, then fast. That's the order interviewers want to hear.
Avoiding deadlock
A deadlock needs threads that hold one lock while waiting for another, in a cycle. The standard fix is a global lock order:
def transfer(src, dst, amount):
first, second = sorted((src, dst), key=lambda a: a.id) # always lock in id order
with first.lock, second.lock:
if src.balance < amount:
raise ValueError("insufficient funds")
src.balance -= amount
dst.balance += amountOther options: a timeout on lock acquisition (lock.acquire(timeout=…)), or a single lock for operations that span several entities.
Optimistic concurrency
Instead of locking, attach a version number to each record. A writer reads the version, computes the change, and commits only if the version hasn't changed. Otherwise it retries. This works well when conflicts are rare, and it's how most databases and in-memory booking systems avoid holding locks while a user thinks.
Producer–consumer and thread pools
Most "process tasks in the background" designs are a blocking queue plus a fixed pool of worker threads. Know how to explain:
- Backpressure: a bounded queue makes producers wait (or reject work) when consumers fall behind.
- Graceful shutdown: stop accepting new work, let the workers drain the queue, and use a sentinel value or an event to tell them to exit.
- Failures: catch exceptions in each worker so one bad task doesn't kill the thread.
Python's GIL
The GIL means only one thread runs Python bytecode at a time, but it does not make your code thread-safe. self.value += 1 can still interleave between threads. Say this if you're interviewing in Python. It shows you know the difference between parallelism and safety.
[!TIP] Practice Bounded Blocking Queue · Rate Limiter Class · In-Memory Pub/Sub · Logging Framework · In-Memory Banking System · Movie Ticket Booking