The problem: race conditions
A race condition happens when two threads update shared state at the same time.
Example: incrementing a shared counter.
import threading
counter = 0
def inc():
global counter
for _ in range(100_000):
counter += 1
t1 = threading.Thread(target=inc)
t2 = threading.Thread(target=inc)
t1.start(); t2.start()
t1.join(); t2.join()
print("counter:", counter)You might expect 200000, but results can vary.
Fix with Lock
import threading
counter = 0
lock = threading.Lock()
def inc():
global counter
for _ in range(100_000):
with lock:
counter += 1
t1 = threading.Thread(target=inc)
t2 = threading.Thread(target=inc)
t1.start(); t2.start()
t1.join(); t2.join()
print("counter:", counter)Best practices
- Keep locked sections small.
- Prefer
with lock:so you don’t forget to release. - Avoid deadlocks (don’t acquire multiple locks in random order).
flowchart TD A["counter += 1"] --> B["read counter"] B --> C["add 1"] C --> D["write counter"] B -.->|"another thread can run HERE"| E["it reads the SAME old value"] E --> F["both write the same result"] F --> G["one increment is lost"] H["with lock:"] --> I["read, add, write -- uninterrupted by other threads"] I --> J["the invariant holds"]
Why the counter goes wrong
The line counter += 1 looks like a single step, but Python runs it as three: read the current value, add one, write the result back. Now imagine two threads. Both read 100, both add one, and both write 101. Two increments happened but the counter only moved once. The interleaving depends on exactly when the operating system switches threads, so the error is random. On some Python versions and machines the result looks correct for small loops, which makes the bug even more dangerous: it can hide in testing and appear in production.
How the fix works
A Lock is like a key to a single room. A thread must hold the key to enter the block, and while it does, any other thread that asks for the key waits outside. In lock_fix.py the read-add-write sequence is inside with lock:, so the two threads take turns, one increment at a time, and the answer is always 200000. The price is speed: the protected part runs one thread at a time, so keep it as small as possible.
What should be locked?
Lock the code that touches the shared data, and nothing else. The heavy work should stay outside:
import threading
total = 0
lock = threading.Lock()
def worker(numbers: list[int]) -> None:
global total
partial = sum(n * n for n in numbers) # slow part: no lock needed
with lock: # quick part: update shared state
total += partial
chunks = [list(range(0, 1000)), list(range(1000, 2000)), list(range(2000, 3000))]
threads = [threading.Thread(target=worker, args=(c,)) for c in chunks]
for t in threads:
t.start()
for t in threads:
t.join()
print(total) # 8995500500Each thread computes its own partial sum privately and takes the lock only for the single addition to total. This is a common pattern: work on local variables, then merge under a lock.
Common mistakes
- Each thread creating its own lock. A lock only works if all threads share the same one.
- Locking only the writes. Reads of data that is being modified need the lock too.
- Using
acquire()and forgettingrelease()after an exception. Thewithstatement is safer. - Holding the lock during slow work such as network calls, which turns your threads into a queue.
- Nesting locks in different orders, which leads to deadlock.
When to use it
Use a lock whenever several threads change the same variable, list, dictionary or file. If you find yourself adding many locks, consider passing data through a queue.Queue instead, so threads never share mutable state at all.
Check yourself
Why can `counter += 1` lose updates across threads?
Both threads read the same old value, compute the same result, and write it. The second write does not add anything — it repeats the first.
Show answer
B — It is a read-modify-write, and another thread can run between the read and the write — Both threads read the same old value, compute the same result, and write it. The second write does not add anything — it repeats the first.
An exception is raised inside `with lock:`. What happens to the lock?
Verified. A manual `acquire()` with no `try`/`finally` left the lock HELD after an exception, and the next thread to ask for it would wait forever.
Show answer
B — It is released — that is what the context manager guarantees — Verified. A manual `acquire()` with no `try`/`finally` left the lock HELD after an exception, and the next thread to ask for it would wait forever.
The same thread calls `acquire()` twice on a plain `threading.Lock`. What happens?
Measured: the second `acquire(timeout=0.2)` returned False. `RLock` records its owner and a count, so the owner may re-enter — and must release the same number of times.
Show answer
B — It deadlocks against itself — Measured: the second `acquire(timeout=0.2)` returned False. `RLock` records its owner and a count, so the owner may re-enter — and must release the same number of times.
When do you NOT need a lock?
Reads alone cannot tear a value. The moment anything writes, a read-modify-write needs protection — and 'only one writer' is not enough if readers can observe a half-updated structure.
Show answer
B — When the shared state is only ever read — Reads alone cannot tear a value. The moment anything writes, a read-modify-write needs protection — and 'only one writer' is not enough if readers can observe a half-updated structure.
🧪 Try It Yourself
Exercise 1 – Basic Lock Usage
Tab indents. Press Escape, then Tab, to leave the editor. Control plus Enter runs the code; Control plus Shift plus Enter submits it.
Press Run to see what your code prints.Exercise 2 – Lock as Context Manager
Tab indents. Press Escape, then Tab, to leave the editor. Control plus Enter runs the code; Control plus Shift plus Enter submits it.
Press Run to see what your code prints.Exercise 3 – Detect Locked State
Tab indents. Press Escape, then Tab, to leave the editor. Control plus Enter runs the code; Control plus Shift plus Enter submits it.
Press Run to see what your code prints.