Threads and locks
Share data between threads without losing updates, guard what must be shared with a lock, and avoid the waits that never end.
BY THE END, YOU CAN
- Explain why a read followed by a write can lose an update between threads
- Protect a critical section with a lock, and order locks to avoid deadlock
- Choose between sharing with a lock, passing messages, and not using threads at all
Diagram description: 1. count → Thread A: read 5; 2. count → Thread B: read 5; 3. Thread A → count: write 6; 4. Thread B → count: write 6; 5. count: 6, not 7: one update lost
A read and a write are two steps
Threads in one process share its memory: global variables and every object they can reach.
The interpreter can switch from one thread to another between any two steps of its bytecode, and count += 1 is several steps: load the value, add one, store the result. If another thread stores between your load and your store, one of the two updates is lost. CPython’s default build has a global interpreter lock, the GIL, which lets only one thread run Python code at a time.
It does not turn a sequence of steps into one; it only makes the loss rarer, which makes it harder to catch in a test. Free-threaded builds, optional since Python 3.13 and officially supported since 3.14, run threads truly in parallel, and the same loop loses updates readily there. Any pause between the read and the write widens the window.
In the example below, eight workers finish a thousand jobs, and the counter usually ends far below a thousand.
import time
from concurrent.futures import ThreadPoolExecutor
jobs_done = 0
def finish_job():
global jobs_done
current = jobs_done # read
time.sleep(0) # any pause lets another thread run here
jobs_done = current + 1 # write: may overwrite another thread's update
with ThreadPoolExecutor(max_workers=8) as pool:
for _ in range(1_000):
pool.submit(finish_job)
print(jobs_done) # often far below 1000
Guard the critical section, and keep it small
A threading.Lock can be held by one thread at a time; any other thread that asks for it waits.
Written as with lock:, it is acquired at the start of the block and released at the end, even when an exception leaves the block. Put the whole read-modify-write inside it, and make every piece of code that touches the shared data take the same lock: a lock that only some paths use protects nothing. Keep the protected section short and do no slow input or output inside it, because every other thread waits.
Two locks bring a new failure. If one thread holds the job lock and waits for the account lock while another holds the account lock and waits for the job lock, both wait forever: a deadlock. Prevent it by always acquiring locks in one agreed order, or by never holding two at once.
Passing a timeout to acquire turns a silent hang into an error you can see. A threading.Lock is not reentrant: a thread that asks again for a lock it already holds waits for itself.
import threading
lock = threading.Lock()
def finish_job_safely():
global jobs_done
with lock: # one thread at a time from here
current = jobs_done
time.sleep(0)
jobs_done = current + 1 # to here
# Always the same order when two locks are needed: job lock, then account lock.
Prefer not to share
The safest shared data is the data you do not share.
Give each thread its own work and send results back instead of updating a global: queue.Queue is safe to use from many threads, and a ThreadPoolExecutor returns each task’s result through a future. Built-in types such as dict and list perform a single operation safely, but logic of several steps, such as reading a value and then writing a new one, still needs a lock. Choose threads for work that mostly waits on input and output, such as calls to an email provider or reads from disk, where the waits overlap.
For work that keeps the processor busy in pure Python, the default build runs one thread at a time, so use processes, for example a ProcessPoolExecutor. For many network waits in one program, asyncio switches only at each await, which makes the switch points visible. A lock in one process does not coordinate other processes or other machines; across Relay’s workers, use the database or a lease.
from concurrent.futures import ThreadPoolExecutor
def send(job):
return provider.send(job["to"], job["subject"]) # waits on the network
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(send, jobs)) # no shared counter to protect
sent = sum(1 for result in results if result.ok)
COMMON FAILURE MODE
Deciding that code is thread-safe because its tests pass. On the default build a race can hide for a long time; reason about which data is shared and which lock guards it.
TRY IT YOURSELF
List every piece of data in Relay's worker that more than one thread can touch. For each, choose a lock (and which one), a queue, or no sharing. If a job ever needs two locks, write down the order every thread must use.
Make the call in the laboratory.
The two-question checkpoint, your notes, and your saved progress are in the laboratory. Free, with no account.
Open in the laboratory ↗CONNECT IT TO RELAY
Run Relay's email sends in a ThreadPoolExecutor and count results from the futures instead of a shared counter. Where state must be shared, guard it with one lock taken in a documented order, and add a test that runs many threads at once.