Exercise 01 of 4/Foundations/45 minutes
The bank that loses money
A bank with two accounts and one method. Under load it will either crash or quietly invent money, and which one it does depends on a decision that looks like a style choice.
Every concurrency course starts with a counter. That is a bad place to start, because nobody cares if a counter is off by three. You care a great deal if a bank is.
So we build a bank. Two accounts, one operation, about forty lines. It will pass every test you would think to write, and then lose money the moment two threads touch it.
Work through the steps in order. Each one asks you to do something or answer something before it tells you anything.
Write the version everyone writes
Do this
Write a Bank holding two account balances, with one method: transfer(from, to, amount). It must refuse to overdraw an account. Do not think about threads yet.
Here is what almost everyone produces:
public final class Bank {
private final Map<String, Long> balances = new HashMap<>();
public Bank() {
balances.put("alice", 1000L);
balances.put("bob", 1000L);
}
public void transfer(String from, String to, long amount) {
long src = balances.getOrDefault(from, 0L);
if (src < amount) return; // insufficient funds
balances.put(from, src - amount);
balances.merge(to, amount, Long::sum);
}
public long total() {
return balances.values().stream().mapToLong(Long::longValue).sum();
}
}package bank
type Bank struct {
balances map[string]int64
}
func New() *Bank {
return &Bank{balances: map[string]int64{"alice": 1000, "bob": 1000}}
}
func (b *Bank) Transfer(from, to string, amount int64) {
if b.balances[from] < amount {
return // insufficient funds
}
b.balances[from] -= amount
b.balances[to] += amount
}
func (b *Bank) Total() int64 {
var sum int64
for _, v := range b.balances {
sum += v
}
return sum
}class Bank:
def __init__(self) -> None:
self._balances = {"alice": 1000, "bob": 1000}
def transfer(self, src: str, dst: str, amount: int) -> None:
if self._balances[src] < amount:
return # insufficient funds
self._balances[src] -= amount
self._balances[dst] += amount
def total(self) -> int:
return sum(self._balances.values())Read it as if it were the only thing running. Is it correct?
AnswerCommit to one first
Yes. Read sequentially it is completely correct — the overdraft check guards the debit, the debit and credit are adjacent, and total is obviously the sum.
That is exactly why it survives review. Nothing here looks like a concurrency bug, because nothing here mentions concurrency.
Predict what happens under load
Do this
Start 1,000 threads. Each one transfers 1 from alice to bob. Wait for all of them, then check the total.
Both accounts start at 1000, so the total is 2000 and should stay there — money moving between accounts cannot change the sum.
Before you run it: what will the total be, and will you get an error?
AnswerCommit to one first
It depends entirely on the language, and that is the lesson of this exercise:
- Java — no error. A wrong total, different on every run.
- Go — with a
map, the process is killed outright. Change one line and it goes silent. - Python — no error, and a wrong total at the same rate as the other two, despite the GIL.
One of the three tells you. Two do not.
Run it, and see what your language does
Nothing tells you. HashMap under concurrent write does not throw — it silently drops entries, and historically could spin forever inside get on a corrupted bucket chain. There is no flag equivalent to Go's race detector; the nearest tools are jcstress for targeted concurrency tests and ThreadSanitizer on recent builds, and neither runs by default.
So the Java version simply returns a wrong total, and the number is different every run. Run it 1,000 times with a CountDownLatch to start every thread at once, and watch the total drift.
Java also hands you two hazards the other two do not have, and both are in the solution below: a non-volatile long is not guaranteed to be read atomically, and without a happens-before edge a reader is not required to ever observe another thread's write.
Go is the only one of the three that tells you, and it tells you twice.
With a map, the runtime kills the process outright:
fatal error: concurrent map writes
Note that this is a fatal error, not a panic — you cannot recover from it, because by the time it is detected the map's internal state is already meaningless. This is the runtime doing you an enormous favour.
Now make one change any reviewer would wave through — the bank only ever has two accounts, so replace the map with two int64 fields — and the crash disappears along with about a hundred units of money.
You did not fix anything. You removed the detector. The map crashed because maps are instrumented; two int64 fields are not. That is the important moment in this exercise.
The second thing Go gives you is go test -race, which names both conflicting lines with both stacks. Reach for it before you reach for reasoning.
Nothing crashes, and that is the problem.
The GIL means only one thread runs bytecode at a time, so the dict is never corrupted — which is routinely mistaken for thread safety. But self._balances[src] -= amount is several bytecodes, and the interpreter can switch threads between any two of them:
>>> import dis; dis.dis("d['a'] -= 1")
LOAD_NAME d # thread switch possible here
LOAD_CONST 'a'
...
BINARY_OP -= # ...and here
STORE_SUBSCR # ...and hereSo you get exactly the interleaving in the diagram, at the same rate as the other two, with none of the noise. With the default 5ms switch interval the race is real but infrequent — sys.setswitchinterval(1e-6) turns it into a near-certainty.
Work out why
How many machine operations is a single balance -= amount?
AnswerCommit to one first
Three: a load, a subtract, and a store. It is one line of source and three things the machine does, and another thread can run between any two of them.
sequenceDiagram participant A as Thread A participant M as balances["alice"] participant B as Thread B Note over M: 1000 A->>M: read M-->>A: 1000 B->>M: read M-->>B: 1000 A->>A: compute 1000 - 1 = 999 B->>B: compute 1000 - 1 = 999 A->>M: write 999 Note over M: 999 B->>M: write 999 Note over M: 999 (should be 998)
Step through it one operation at a time:
The lost update, one operation at a time
load balances["alice"]→ 1000load balances["alice"]→ 1000sub 1 -> 999sub 1 -> 999store balances["alice"] = 999store balances["alice"] = 999
That is a lost update: two withdrawals happened, and the balance fell by one.
Fix it
Do this
Make the test pass reliably — not once, but with 1,000 threads, repeated 100 times. Then change half the threads to transfer the other way and check the total again.
Hint 1What the race actually is
b.alice -= amount is not one operation. It is a load, a subtract, and a store. Two goroutines can both load 1000, both compute 999, and both store 999. Two transfers happened; one unit of debit vanished.
Hint 2Make the failure reproducible
Start every thread at the same instant with a CountDownLatch, run 1,000 of them, and assert the total in a loop. An intermittent failure you cannot reproduce is not evidence you can work with.
go test -race will name the exact two lines that conflict, with both stacks. Get in the habit of reaching for this before you reach for reasoning — it is right far more often than you are.
sys.setswitchinterval(1e-6) makes the interpreter switch threads far more aggressively, turning an occasional lost update into a near-certain one.
Solution — Java: and why ConcurrentHashMap is not the fixTry it first
public final class Bank {
private final Map<String, Long> balances = new HashMap<>();
public Bank() {
balances.put("alice", 1000L);
balances.put("bob", 1000L);
}
public synchronized void transfer(String from, String to, long amount) {
long src = balances.getOrDefault(from, 0L);
if (src < amount) return; // check and act, together
balances.put(from, src - amount);
balances.merge(to, amount, Long::sum);
}
public synchronized long total() {
return balances.values().stream().mapToLong(Long::longValue).sum();
}
}The shape is the same as Go's. What differs is everything around it.
ConcurrentHashMap would not have fixed this, and that is the trap. It is the first thing a Java developer reaches for, and it makes every individual get and put thread-safe. The transfer is neither: it is a get, a compare, a put, and another put. A concurrent map guarantees each step is safe and says nothing about the four of them together. You would trade a possibly-corrupt map for a reliably-wrong ledger — and you would have removed the only symptom that was telling you something was wrong.
Java will not crash the way Go does. HashMap under concurrent write does not throw; it silently drops entries, and historically could spin forever in get on a cyclic bucket chain. There is no -race flag. The nearest equivalents are jcstress for targeted concurrency tests and ThreadSanitizer via -XX:+UseTSAN on recent builds, and neither is something you have running by default. Java's default posture on this bug is silence.
A non-volatile long is not guaranteed to be read atomically. This is the genuinely startling one, and it has no analogue in the Go exercise above. Section 17.7 of the JLS permits a 64-bit read or write to be split into two 32-bit halves, so an unsynchronised reader can observe a long that is the high half of one value and the low half of another — a number that was never written by anybody. A 64-bit HotSpot will not actually do this, but the guarantee is not in the language, and "my JVM happens not to" is not a property you want a ledger resting on.
Visibility is a separate guarantee from atomicity. Even if the increment were atomic, without a happens-before edge between the writing and reading threads the JMM does not require the reader to ever observe the write. Not "later" — ever. synchronized gives you mutual exclusion and that edge in the same construct, which is why it is the right tool here and why volatile alone is not: volatile fixes visibility and leaves -= as broken as before.
synchronized methods versus a private lock. Locking on this publishes your lock: any caller holding a Bank reference can synchronized (bank) { ... } and block your transfers. For real code, prefer a private final Object lock (or a ReentrantLock when you need tryLock or a timeout — which exercise 02 will need).
Solution — Go: the mutex, and where it has to goTry it first
Guard the whole operation, not the individual writes:
type Bank struct {
mu sync.Mutex
balances map[string]int64
}
func (b *Bank) Transfer(from, to string, amount int64) {
b.mu.Lock()
defer b.mu.Unlock()
if b.balances[from] < amount {
return
}
b.balances[from] -= amount
b.balances[to] += amount
}
func (b *Bank) Total() int64 {
b.mu.Lock()
defer b.mu.Unlock()
var sum int64
for _, v := range b.balances {
sum += v
}
return sum
}Three things worth saying explicitly, because they are the actual content of this exercise.
The critical section is the invariant, not the statement.
It is tempting to lock around each line, or reach for atomic.AddInt64 on each balance. Both are wrong here, in an instructive way:
- Atomic adds make each individual write indivisible, so no single unit is lost.
- But between the debit and the credit, the money exists in neither account.
Total()can observe exactly that moment and report 1999.
The thing that must be atomic is the transfer, because the invariant you promised is about the pair.
Total() needs the lock too. A reader that does not synchronise is still racing, and -race will tell you so. A lock held only by writers protects writers from each other and nobody from the readers.
The overdraft check is a separate bug, and the mutex is what fixed it. Look at the original:
if b.balances[from] < amount { return }
b.balances[from] -= amountThat is check-then-act. Between the check and the act, another goroutine can drain the account, and the balance goes negative — an overdraft the code explicitly tried to prevent. This bug is not a data race and the race detector will not always point at it; it is a logic error that exists because the decision and the action were not taken together. Had you "fixed" the first problem with atomic adds, you would have kept this one. The mutex fixes both precisely because it makes check-and-act a single unit.
Solution — Python: the GIL is not a lock you can useTry it first
import threading
class Bank:
def __init__(self) -> None:
self._lock = threading.Lock()
self._balances = {"alice": 1000, "bob": 1000}
def transfer(self, src: str, dst: str, amount: int) -> None:
with self._lock:
if self._balances[src] < amount:
return
self._balances[src] -= amount
self._balances[dst] += amount
def total(self) -> int:
with self._lock:
return sum(self._balances.values())Python is the language where this exercise is hardest to believe, because the GIL means only one thread runs bytecode at a time, and that sounds like it should make the problem go away.
What the GIL actually promises. It serialises the interpreter, so a single bytecode instruction is never interleaved. dict insertion will not corrupt the dict, which is why Python never crashes here the way Go does. That is the whole guarantee.
What the transfer needs. self._balances[src] -= amount compiles to a load, a subtract, and a store — several bytecodes. The interpreter can switch threads between any two of them (every 5ms by default; see sys.setswitchinterval). So you get precisely the lost update from the Go version, at exactly the same rate, with none of the noise.
>>> import dis; dis.dis("d['a'] -= 1")
LOAD_NAME d # thread switch possible here
LOAD_CONST 'a'
...
BINARY_OP -= # ...and here
STORE_SUBSCR # ...and hereMake the failure visible. With the default switch interval the race is real but infrequent. sys.setswitchinterval(1e-6) turns it into a near-certainty, which is Python's equivalent of the time.Sleep trick used in the next exercise.
The GIL is a property of CPython, not of Python. Python 3.13 shipped an officially supported free-threaded build (PEP 703) and 3.14 made it a fully supported configuration. On a free-threaded interpreter the serialisation is gone, dict is protected by its own internal locking rather than a global one, and code that was accidentally surviving on GIL granularity stops surviving. If you have ever heard "Python threads are safe because of the GIL", this is the exercise that ends that belief: the GIL was never making your compound operations atomic, it was only making them look atomic often enough to pass your tests.
threading.Lock is not reentrant. Same as Go's mutex, unlike synchronized in Java. If transfer ever calls another locked method on the same object you will deadlock against yourself — use threading.RLock when you genuinely need that, and be suspicious of why you do.
Check the fix against two tempting shortcuts
Would an atomic add on each balance have worked instead of a lock?
AnswerCommit to one first
No, and the reason is worth holding on to.
Atomic adds make each individual write indivisible, so no single unit is lost. But between the debit and the credit there is a moment when the money exists in neither account — and a reader calling total() can observe exactly that moment and see 1999.
The thing that has to be atomic is the transfer, because the invariant you promised is about the pair, not about either balance.
The original had a second bug, and it is not a data race. What is it?
AnswerCommit to one first
The overdraft check:
if (balance < amount) return; // check
balance -= amount; // act
Between the check and the act, another thread can drain the account — so the balance goes negative, which the code explicitly tried to prevent. This is check-then-act, and the race detector will not always point at it, because it is a logic error rather than a memory error.
Had you "fixed" the first problem with atomic adds, you would still have this one. The mutex fixes both, because it makes the check and the act a single unit.
The same bug in three languages
The code is nearly identical. The experience of the bug is not, and that difference is the point of solving it three times.
Java
Quiet, with two extra hazards. No crash, no bundled detector. Beyond the lost update, the JMM permits 64-bit tearing on a non-volatile long (a value nobody wrote) and permits a reader to never observe another thread's write at all without a happens-before edge. The reflex fix — ConcurrentHashMap — is precisely wrong, because the unsafe unit is the transfer, not the map access.
Go
Loud, then quiet. A map triggers fatal error: concurrent map writes and kills the process — unrecoverable by design. Swap to struct fields and the same race goes silent. go test -race is built in, on by one flag, and names both conflicting lines. Go is the only one of the three that hands you the detector for free.
Python
Quiet, and actively misleading. The GIL prevents interpreter-level corruption, so nothing ever crashes, and that safety is routinely mistaken for thread safety. -= is several bytecodes and the interpreter switches between them, so the lost update is identical to Go's. On free-threaded 3.13+ the illusion is removed and the same code starts failing under real parallelism.
The transferable lesson. Every one of the three needed the same fix — make the whole invariant one critical section — and every one offered a plausible shortcut that fails for a language-specific reason: atomics in Go (breaks Total), ConcurrentHashMap in Java (compound operation), the GIL in Python (bytecode granularity). The shortcuts differ. The invariant does not. When you next reach for a lock-free trick, the question to ask is not "is this operation atomic" but "is the thing I promised atomic".
Before you move on
You should be able to explain each of these without looking.
- Why the
mapversion crashed but the two-field version did not, and why the second is worse. - Why
atomic.AddInt64on each balance is not sufficient, in one sentence aboutTotal(). - Why the overdraft bug is not a data race, and what it is instead.
- Why a read-only method needs the lock.
- Why
ConcurrentHashMapdoes not fix the Java version. - Why the GIL does not fix the Python version, in one sentence about bytecode.
- Which of the three languages tells you about the bug, and which two do not.
Go further
- Add a
Balance(name string) int64method. Now write a caller that checksBalance("alice")and then transfers that amount. You have reintroduced check-then-act across the API boundary, where no internal lock can save you. This is the problem that transactions exist to solve, and it is why a bank's API surface tends to look the way it does. - Swap
sync.Mutexforsync.RWMutexand let readers share. Measure it under a read-heavy load, then under a write-heavy one, and find the point where it is slower than the plain mutex. - Give every account its own lock, so unrelated transfers stop contending. This is the obvious next optimisation, and it introduces a new failure that the next exercise is entirely about.