← Systems under pressure STAGE 06 · 30 MIN

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.

Open in the laboratory ↗

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
Both threads read 5 before either writes, so both write 6: one increment is lost. With a lock around the read and the write, the second thread would read 6 and write 7.

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.

Python
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.

Python
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.

Python
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.

RETRIEVAL CHECK / APPLY THE IDEA

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.