Here is a page-view counter that eight threads share:
class PageViews:
def __init__(self) -> None:
self.total = 0
def record(self) -> None:
current = self.total
save_to_cache(current + 1) # a slow step between the read and the write
self.total = current + 1
Each thread calls record() 25 times, so you expect a total of 200. Run it,
and the total comes out as 25.
How an update gets lost
record() reads the total, does some work, and writes a new total. When two
threads run it at once, this can happen:
| Thread A | Thread B | total |
|---|---|---|
| reads 41 | 41 | |
| reads 41 | 41 | |
| writes 42 | 42 | |
| writes 42 | 42 |
Both threads recorded a view, but the total went up by one. Thread B wrote a value it had computed from out-of-date data. This bug is a race condition. The result depends on the order in which the threads happen to run.
The slow step in the middle makes the gap wide, so the race happens on every run. Real code often has a step like that, such as a function call, a log line, or a request to a cache or database. The wider the gap between the read and the write, the more likely another thread runs during it.
A lock makes threads take turns
A threading.Lock is an object that only one thread can hold at a time. A
thread that tries to take a held lock waits until the holder releases it.
Put the read, the work and the write inside one with block:
import threading
class PageViews:
def __init__(self) -> None:
self.total = 0
self._lock = threading.Lock()
def record(self) -> None:
with self._lock:
current = self.total
save_to_cache(current + 1)
self.total = current + 1
A second thread that calls record() now waits at with self._lock: until
the first thread leaves the block. No thread can read total in the middle
of another thread's update, and the total comes out as 200.
Three rules make a lock work:
- Use one lock for one piece of shared data, and make every thread use that same lock. A lock that only some of the threads take protects nothing.
- Put the whole update inside the lock, including the read, the change and the write. Reads that need an up-to-date value take the lock too.
- Keep work that does not touch the shared data outside the lock. Every other thread that needs the lock waits while you hold it.
Avoid holding two locks at once
Suppose thread A holds lock 1 and waits for lock 2, while thread B holds lock 2 and waits for lock 1. Both wait forever. This is a deadlock. The simplest way to avoid one is to never hold more than one lock at a time. If you must hold two, always take them in the same order.
Share less
Often the easiest fix is to stop sharing. Let each thread keep its own count
and add them up at the end, or pass work between threads through a
queue.Queue, which does its own locking. Data that no thread
changes once the threads have started needs no lock at all.
Check your understanding
count += 1 on a shared integer, with no slow step, passes your two-thread test every time. Is it safe?
Show answer
Hide answer
No. count += 1 is still a read, an add and a write. On a standard Python
install, a thread switch in the middle of it is rare, so a small test passes
almost every time. The bug shows up later, under load, or on Python's
free-threaded build, where threads do run at the same time. Use a lock
whenever more than one thread changes the value.