Claude Sonnet 5.5
What goes wrong
This is a classic check-then-act race condition (TOCTOU: time-of-check to time-of-use).
Interleaving that breaks it:
| Step | Thread A | Thread B | balance |
|---|---|---|---|
| 1 | balance >= 80 → True | 100 | |
| 2 | sleep(0.01) (yields GIL) | balance >= 80 → True | 100 |
| 3 | sleep(0.01) | 100 | |
| 4 | balance -= 80 | 20 | |
| 5 | returns True | balance -= 80 | -60 |
| 6 | returns True | -60 |
Both withdrawals succeed, and the account ends at -60 even though only 100 was available. You've effectively dispensed 160 from a 100 balance.
A second, subtler problem: balance -= amount is not atomic in Python. It compiles to load, subtract, store. Even without the sleep, two threads could both load 100 and both store 20, losing an update. (In that case you'd get 20 rather than -60, which is a different bug: money is dispensed twice but only deducted once.)
How likely is it?
Very close to certain as written. The time.sleep(0.01) sits right between the check and the update, and sleep releases the GIL and forces a thread switch. If both threads start at roughly the same time, Thread B will almost always run its check during Thread A's sleep. The 10 ms window is enormous compared to the microseconds a thread needs to run the check.
Without the sleep, the window shrinks to a few bytecodes, so it would be rare (the GIL switches every ~5 ms by default, or on blocking I/O), but it would still be possible, and rare races are the worst kind because they pass tests and fail in production. The sleep here just stands in for real work such as a database call or network request, which is exactly what happens in real code.
The GIL does not protect you. It makes individual bytecodes safe, not multi-step logic.
The fix
Make the check and the update a single atomic critical section with a lock:
import threading
import time
balance = 100
balance_lock = threading.Lock()
def withdraw(amount):
global balance
with balance_lock:
if balance >= amount:
time.sleep(0.01) # simulated work, now safely inside the lock
balance -= amount
return True
return FalseNow one thread does check → sleep → update completely before the other can even check. The second thread then sees balance == 20, and withdraw(80) returns False.
Verification
if __name__ == "__main__":
results = []
threads = [
threading.Thread(target=lambda: results.append(withdraw(80)))
for _ in range(2)
]
for t in threads: t.start()
for t in threads: t.join()
print(results) # [True, False] (order may vary)
print(balance) # 20Better design notes
- Keep the lock scope tight. Holding a lock during
sleepor I/O serializes everything. Here it's necessary, because the work is part of the transaction. If the slow part didn't depend on the balance, move it outside the lock. - Encapsulate state and lock together rather than using globals, so no one can touch
balancewithout the lock:
class Account:
def __init__(self, balance):
self._balance = balance
self._lock = threading.Lock()
def withdraw(self, amount):
with self._lock:
if self._balance >= amount:
time.sleep(0.01)
self._balance -= amount
return True
return False- Across processes or machines, a
threading.Lockdoesn't help. Use database transactions (e.g.UPDATE accounts SET balance = balance - 80 WHERE id = ? AND balance >= 80and check the affected row count), which does the check and update atomically in one statement.