Threads, locks, and the things that go wrong
📖 Walk me through it — plain English
This lesson is about doing more than one thing at the same time inside a program, and the surprising bugs that creates. A thread is a single line of work running through your code — like one cook following one recipe top to bottom. A process is a whole separate kitchen with its own pantry (its own memory). Threads inside the same process are cheaper because they share one pantry, but sharing is exactly where things go wrong. A coroutine (async) is a lighter trick still: one cook who voluntarily pauses ("yields") at marked spots so they can juggle many tasks without a second cook.
The classic disaster is a race condition: two threads touch the same shared value and step on each other. The result depends on who happens to go first — a "race" — so the bug shows up randomly and is miserable to reproduce. The fix is a lock (a mutex, short for "mutual exclusion"): a rule that only one thread at a time may enter the dangerous stretch of code, called the critical section.
Everyday analogy: picture a single bathroom shared by an office. The lock on the door is the mutex — one person inside at a time. A semaphore is like a parking lot with N spaces: up to N people allowed at once, and when it's full the next person waits. A condition variable is the "occupied" doorbell — instead of rattling the handle forever, you wait quietly and get pinged when the situation changes. A deadlock is two people each politely waiting for the other to go first, so nobody ever moves.
Here is the race condition that motivates all of it. Two threads both run counter += 1, which secretly is three tiny steps — read the value, add one, write it back. If their steps interleave badly, an update vanishes. Watch the count walk from 5, where it should reach 7 but ends at 6:
A mutex fixes this: thread A locks before reading, finishes its read-add-write, then unlocks — so B is forced to wait and starts from the fresh 6, correctly reaching 7. That serialized stretch is the critical section.
Deadlock needs four things to all be true at once: locks can't be shared (mutual exclusion), a thread holds one lock while waiting for another (hold-and-wait), nobody can yank a lock away (no preemption), and the waiting forms a loop (circular wait). Break any one and you're safe. The standard recipe breaks the loop: make every thread grab locks in the same fixed order (always lock A before B, never the reverse), so a cycle can't form.
The producer-consumer code ties it together. A bounded buffer is a shared queue with a maximum size. Producers call put; if the queue is full they wait on the condition variable (sleep until pinged). Consumers call get; if it's empty they wait. Each side calls notify_all after changing the queue to wake the other side. Notice the check is while, not if: a thread can be woken without the condition actually being true (a "spurious wake-up", and other woken threads may grab the slot first), so you must re-check the condition in a loop before trusting it.
Google and Meta loops often ask one OS/concurrency question. Don't go in blind.
First, the on-ramp: why any of this matters
A computer feels like it does many things at once — music plays while you type while a download finishes. Underneath, a single CPU core can only run one stream of instructions at a time. The operating system fakes the "at once" by slicing time into tiny intervals and switching the core between tasks dozens of times a second. That switch — saving everything the current task was doing (its registers, its place in the code) and loading the next task's saved state — is called a context switch. It is not free: it costs time, and on modern CPUs it also throws away cached data the old task had warmed up. This is the bedrock distinction the rest of the lesson builds on, so let us pin down the two words people constantly mix up.
- Concurrency is a structure: a program is broken into independent tasks that can make progress in overlapping time windows. One core rapidly interleaving tasks is concurrent even though, at any single instant, only one instruction is actually executing. Concurrency is about dealing with many things at once.
- Parallelism is an execution fact: two or more instructions literally run at the same physical instant, which requires two or more CPU cores. Parallelism is about doing many things at once.
- The relationship: you can have concurrency without parallelism (one core, time-sliced), and the bugs in this lesson — races, deadlocks — come from concurrency, the mere possibility of bad interleaving. You do not need two cores to hit a race; one core that switches at the wrong moment is enough.
- Process: own memory space. Expensive to create.
- Thread: shares memory with siblings in same process. Cheap context switch.
- Coroutine (async): cooperative — yields at await points. Thousands per thread, no kernel switch. Single-CPU-core (unless paired with multi-process).
Two threads do counter += 1. Each reads, adds, writes. Interleaved, one update is lost. The "critical section" needs mutual exclusion.
Process vs thread, defined precisely
A process is an instance of a running program with its own private address space — its own slice of memory that no other process can read or write directly. Open your code editor and your browser: two processes, each fenced off from the other. Because the fence is enforced by hardware, a crash in one process cannot corrupt another's memory, which makes processes safe but heavy. Creating one means asking the OS for a fresh address space and copying setup over.
A thread is a line of execution inside a process. A process starts with one thread (the one running main) and can spawn more. All threads of a process share that one address space — the same heap, the same globals, the same open files. That shared memory is why threads are cheap (no new address space) and fast to communicate (just read a shared variable). It is also the entire source of danger: when two threads write the same variable with no coordination, you get the race condition above. A context switch between two threads of the same process is cheaper than between processes, because the address space does not change — only the small per-thread state (registers, stack pointer, program counter) is swapped.
A concrete race condition, and the lock that fixes it
The walkthrough above told the story; here is the actual code. Two threads each increment a shared counter 100,000 times. The correct answer is 200,000. Run the unsafe version and you will usually get less — some increments evaporated exactly as the four-step diagram showed.
import threading
counter = 0
def bump():
global counter
for _ in range(100_000):
counter += 1 # NOT atomic: read counter, add 1, write back
t1 = threading.Thread(target=bump)
t2 = threading.Thread(target=bump)
t1.start(); t2.start()
t1.join(); t2.join() # join() = wait here until that thread finishes
print(counter) # EXPECTED 200000 — but often prints e.g. 137422
The reason the answer comes out low is that counter += 1 is not an atomic operation — "atomic" means indivisible, an action that completes in one step with no chance of another thread observing it half-done. The += is really three separate machine steps (load, add, store), and a context switch can land between any two of them, letting the other thread read a stale value. The cure is to wrap the three steps in a critical section — the stretch of code that touches shared state and must not be run by two threads at once — and protect that section with a mutex/lock, an object only one thread can "hold" at a time. A second thread that tries to acquire a held lock blocks (sleeps) until the holder releases it.
import threading
counter = 0
lock = threading.Lock() # the mutex
def bump():
global counter
for _ in range(100_000):
with lock: # acquire on entry, release on exit (even if it raises)
counter += 1 # critical section: now read-add-write can't be interrupted by the other thread
t1 = threading.Thread(target=bump)
t2 = threading.Thread(target=bump)
t1.start(); t2.start()
t1.join(); t2.join()
print(counter) # ALWAYS 200000 — the lock serializes the dangerous part
The lock does not make the work parallel — it does the opposite, forcing the critical section to run one thread at a time. That is the trade you are always making with locks: correctness in exchange for some lost concurrency. Keep critical sections as small as possible (lock late, unlock early) so threads spend most of their time outside the lock.
- Mutex: one thread holds at a time. Most common. Think "the one bathroom key."
- Semaphore: a counter that permits up to N holders at once.
acquiredecrements (and blocks at 0);releaseincrements. A mutex is essentially a semaphore with N=1. Useful for rate-limiting concurrent calls — e.g. "at most 5 in-flight requests to this API." - Read-write lock: many concurrent readers, one writer. Good when reads ≫ writes.
- Condition variable: wait until a predicate is true (paired with a mutex). Powers producer-consumer.
- Atomic / CAS: Compare-And-Swap — a single hardware instruction that does "if memory == expected, set it to new, all in one indivisible step." Lock-free counters and queues are built on this.
Deadlock, and how to dodge it
A deadlock is the worst-case outcome of locks: a set of threads each holds a lock the others need, and every one of them is asleep waiting forever. Nothing crashes; the program simply freezes. The canonical illustration is the dining philosophers problem — philosophers around a table, one fork between each pair, and each needs two forks to eat. If every philosopher grabs the fork on their left at the same time, each holds one fork and waits forever for the right one. The two-lock version below is the same trap with two threads and two mutexes:
import threading
fork_a = threading.Lock()
fork_b = threading.Lock()
def thread_one():
with fork_a: # grabs A first
with fork_b: # then wants B
...
def thread_two():
with fork_b: # grabs B first <-- OPPOSITE ORDER: this is the bug
with fork_a: # then wants A — but thread_one is holding A
...
# If both run at once: thread_one holds A waiting for B, thread_two holds B waiting for A. Frozen.
A deadlock can only happen when all four Coffman conditions hold simultaneously — so breaking any single one makes deadlock impossible. The cleanest fix here breaks circular wait with lock ordering: pick a single global order for the locks and make every thread acquire them in that order. If both threads grab fork_a before fork_b, no cycle can form — whoever gets A first will also get B, finish, and release.
# FIX: both threads acquire in the SAME order (A then B). No cycle possible.
def thread_two_fixed():
with fork_a: # same order as thread_one now
with fork_b:
...
- Mutual exclusion — locks aren't shareable.
- Hold and wait — a thread holds one lock while waiting for another.
- No preemption — locks can't be forcibly taken.
- Circular wait — A waits for B, B waits for A.
Prevention recipe: always acquire locks in a global total order (break circular wait). Or use try-lock with timeout (break hold-and-wait).
Producer-consumer: the pattern that uses everything
The producer-consumer problem is the classic coordination puzzle: one or more producer threads generate items, one or more consumer threads use them, and they hand off through a shared bounded buffer (a fixed-capacity queue). Two things must be coordinated — mutual exclusion on the queue itself, and waiting: producers must pause when the buffer is full, consumers must pause when it is empty. The condition variable handles the waiting. cv.wait() atomically releases the lock and sleeps; when another thread calls cv.notify_all(), the sleeper wakes, re-acquires the lock, and re-checks its condition.
from threading import Lock, Condition
class BoundedBuffer:
def __init__(self, cap):
self.q = []; self.cap = cap
self.lock = Lock(); self.cv = Condition(self.lock)
def put(self, item):
with self.cv:
while len(self.q) == self.cap:
self.cv.wait()
self.q.append(item)
self.cv.notify_all()
def get(self):
with self.cv:
while not self.q:
self.cv.wait()
v = self.q.pop(0)
self.cv.notify_all()
return v
Always while, never if: spurious wake-ups exist.
Why while and not if: a thread can wake from wait() even though nothing useful changed (a spurious wake-up), and even when a real notify happened, another woken thread may have already grabbed the only free slot before this one re-acquires the lock. Re-checking the predicate in a loop is therefore mandatory for correctness — an if would proceed on a false assumption.
Threads vs async, and a word on Python's GIL
Async (coroutines) is a different model for concurrency. Instead of the OS preemptively switching threads at unpredictable points, a coroutine runs until it voluntarily awaits — yielding control at a marked spot — and an event loop on a single thread runs the next ready coroutine. Because switches happen only at explicit await points, you have far fewer surprise interleavings, and you can have tens of thousands of coroutines cheaply (no per-thread OS stack). The catch: a coroutine that does heavy CPU work without awaiting blocks the whole loop. So the rule of thumb is async for I/O-bound work (waiting on network, disk, databases — lots of idle waiting to overlap) and threads or processes for CPU-bound work (real computation to spread across cores).
The GIL (Python), briefly: CPython has a Global Interpreter Lock — a single lock that lets only one thread execute Python bytecode at a time, even on a multi-core machine. Threads still help for I/O-bound work (a thread waiting on the network releases the GIL so another can run), but they give you no speed-up for CPU-bound Python code, because only one thread computes at once. For CPU parallelism in Python you use multiple processes (e.g. multiprocessing), each with its own interpreter and its own GIL. Note the GIL does not save you from race conditions: counter += 1 still spans multiple bytecodes and can be interrupted between them, so you still need the lock shown earlier.
Thread pools
Creating a thread per task does not scale — thread creation has overhead and thousands of threads thrash the scheduler with context switches. A thread pool fixes this: you create a fixed set of worker threads once, and feed tasks into a shared queue (a producer-consumer setup, in fact — the pool's internals are exactly the pattern above). Idle workers pull the next task, run it, and loop back for more. This caps concurrency at a known number, reuses threads instead of churning them, and naturally applies back-pressure when work arrives faster than it can be done. Most languages ship one (Python's concurrent.futures.ThreadPoolExecutor, Java's ExecutorService).
Pitfalls to avoid
- Forgetting to lock every access. A lock only protects shared state if all readers and writers use the same lock. One unlocked access reintroduces the race.
- Holding a lock too long. Doing I/O or slow work inside a critical section serializes everything and kills throughput. Lock just the shared-state touch, nothing more.
- Inconsistent lock order. The deadlock above. If your code takes two locks anywhere, document and enforce one global order.
- Using
ifinstead ofwhilearoundwait(). Spurious wake-ups and lost races make this subtly wrong. - Assuming
x += 1(or any read-modify-write) is atomic. It is not. Use a lock or an atomic primitive. - Confusing concurrency bugs for "flaky tests." An intermittent failure that depends on timing is usually a race, not bad luck — chase it down rather than retrying.
Go deeper (optional): the foundational treatment is "Operating Systems: Three Easy Pieces" (free online, the Concurrency chapters), which works through locks, condition variables, semaphores, and deadlock with the same examples used here. For lock-free programming and memory ordering, look up "compare-and-swap" and your language's atomics documentation.
Takeaway: a process is isolated memory; a thread shares memory and is cheap to switch but dangerous to share. Concurrency is structure (overlapping tasks), parallelism is execution on multiple cores. Unsynchronized shared writes cause race conditions; a mutex serializes the critical section to fix them. All four Coffman conditions are needed for deadlock, so global lock ordering prevents it. Use a condition variable with a while-loop re-check for producer-consumer; use async for I/O and threads/processes for CPU, remembering Python's GIL caps thread-based CPU parallelism.