Exercise 04 of 4/Applied/1 hour 15 minutes

A load balancer you can register backends with

A round-robin load balancer with register and deregister, called while traffic is flowing. Four lines of routing code, and every one of them assumes the backend list stopped moving while it looked.

Language

Round-robin is the easy half of a load balancer: counter++ % size, one line, correct forever — as long as the backend list never changes.

The backend list always changes. Instances come up, health checks fail, deploys roll, someone scales down.

So the real object here is not a router. It is a registry read thousands of times a second and written occasionally, and that asymmetry is what makes this a concurrency problem rather than an arithmetic one.

Build the registry

Do this

Write a LoadBalancer with three methods: register(backend), deregister(id), and next() returning the next backend in rotation. Do not think about threads yet.

What you're building

A LoadBalancer with three methods: register(backend), deregister(id), and next() which returns the next backend in rotation. next() is called from many threads at once. Registration happens on a different thread, while traffic is flowing, and must never take the balancer down.

Here is the version everybody writes first:

public final class LoadBalancer {
    private final List<Backend> backends = new ArrayList<>();
    private final AtomicInteger counter = new AtomicInteger();
 
    public void register(Backend b)     { backends.add(b); }
    public void deregister(String id)   { backends.removeIf(b -> b.id().equals(id)); }
 
    public Backend next() {
        int n = backends.size();                       // (1)
        if (n == 0) throw new IllegalStateException("no backends");
        return backends.get(counter.getAndIncrement() % n);   // (2)
    }
}
type LoadBalancer struct {
	backends []*Backend
	counter  uint64
}
 
func (lb *LoadBalancer) Register(b *Backend)  { lb.backends = append(lb.backends, b) }
func (lb *LoadBalancer) Deregister(id string) {
	out := lb.backends[:0]
	for _, b := range lb.backends {
		if b.ID != id {
			out = append(out, b)
		}
	}
	lb.backends = out
}
 
func (lb *LoadBalancer) Next() (*Backend, error) {
	n := len(lb.backends)                               // (1)
	if n == 0 {
		return nil, errors.New("no backends")
	}
	i := atomic.AddUint64(&lb.counter, 1)
	return lb.backends[int(i)%n], nil                   // (2)
}
class LoadBalancer:
    def __init__(self) -> None:
        self._backends: list[Backend] = []
        self._counter = 0
 
    def register(self, b: Backend) -> None:
        self._backends.append(b)
 
    def deregister(self, id: str) -> None:
        self._backends = [b for b in self._backends if b.id != id]
 
    def next(self) -> Backend:
        n = len(self._backends)                          # (1)
        if n == 0:
            raise RuntimeError("no backends")
        self._counter += 1
        return self._backends[self._counter % n]         # (2)

The counter is atomic in two of the three. Does that make next() thread-safe?

AnswerCommit to one first

No. The counter was never the problem.

An atomic counter guarantees that no two callers get the same number. It says nothing about the list those numbers are used to index — and that list is being mutated by another thread while next() reads it.

This is why the bug survives review: the one line that looks like it needs synchronising already has it.

Break it

Do this

Confirm it distributes evenly across three backends with 10,000 calls from 50 threads and a static list. Then deregister a backend from another thread while those calls are in flight, and make it fail reliably rather than occasionally.

next() touches the backend list twice. Which two reads are they, and why does that matter?

AnswerCommit to one first

backends.size() and backends.get(i).

Two separate operations against a structure somebody else is allowed to mutate in between. The size is true when you read it and false by the time you use it.

sequenceDiagram
  participant A as next() thread
  participant L as backends
  participant R as registrar thread
  Note over L: [b0, b1, b2]
  A->>L: size()
  L-->>A: 3
  R->>L: deregister(b2)
  Note over L: [b0, b1]
  A->>A: counter=5, 5 % 3 = 2
  A->>L: get(2)
  L-->>A: IndexOutOfBounds
Thread A sizes a 3-element list, then indexes into a 2-element one. Nothing in the code reads as unsafe; the unsafe thing is that two reads assumed one list.

next() reads the list twice. It reads the size, then it reads an element. Those are two separate operations against a structure somebody else is allowed to mutate in between.

sequenceDiagram
  participant A as next() thread
  participant L as backends
  participant R as registrar thread
  Note over L: [b0, b1, b2]
  A->>L: size()
  L-->>A: 3
  R->>L: deregister(b2)
  Note over L: [b0, b1]
  A->>A: counter=5, 5 % 3 = 2
  A->>L: get(2)
  L-->>A: IndexOutOfBounds
Thread A sizes a 3-element list, then indexes into a 2-element one. Nothing in the code reads as unsafe; the unsafe thing is that two reads assumed one list.

This is the same shape as the bank's check-then-act, wearing different clothes. There the check was balance >= amount; here it is size(). In both cases the decision was made against a world that had moved on by the time you acted on it.

And notice the second, quieter failure: even when the index happens to be in range, % n with a changing n means the rotation is not round-robin at all. Remove one backend from three and every request lands somewhere different from where it would have — the mapping reshuffles rather than closing the gap.

Fix it from the inside

Do this

Make registration and deregistration safe under live traffic, with no lock held on the read path. Prove it: 10,000 calls from 50 threads while a registrar thread adds and removes continuously, with zero exceptions and nothing routed to a deregistered backend.

Hint 1Stop reading the list twice

Whatever you do about locking, next() must get one view of the backend list and use it for both the size and the element. Ask what the smallest change is that makes those two reads see the same thing.

Hint 2Count the reads and the writes

next() runs perhaps 10,000 times a second. register runs a few times an hour. A design that makes both sides pay equally is the wrong trade. What can you make expensive on the write path to make the read path free?

Hint 3What if the list were immutable?

If the list never changed, there would be no bug. So do not change it — replace it. A writer builds a whole new list and swaps it in with a single assignment; readers grab the current one and are unaffected by anything that happens next.

Solution — Java: snapshot the reference, and let the writer payTry it first
public final class LoadBalancer {
    // The reference is volatile; the list it points at is never mutated.
    private volatile List<Backend> backends = List.of();
    private final AtomicInteger counter = new AtomicInteger();
    private final Object writeLock = new Object();
 
    public void register(Backend b) {
        synchronized (writeLock) {                 // writers serialise
            var next = new ArrayList<>(backends);
            next.add(b);
            backends = List.copyOf(next);          // one atomic publish
        }
    }
 
    public void deregister(String id) {
        synchronized (writeLock) {
            backends = backends.stream()
                    .filter(b -> !b.id().equals(id))
                    .toList();
        }
    }
 
    public Backend next() {
        List<Backend> snapshot = backends;         // ONE read of the reference
        int n = snapshot.size();
        if (n == 0) throw new IllegalStateException("no backends");
        return snapshot.get(Math.floorMod(counter.getAndIncrement(), n));
    }
}

Four things are doing the work.

One read of the field. List<Backend> snapshot = backends; is the whole fix. After that line, snapshot cannot change — the writer swaps the field, and a reader already holding the old list keeps a perfectly valid, internally consistent list. Reading backends twice inside next() would reintroduce the bug exactly.

volatile is required, not decorative. Without it there is no happens-before edge between the writer's publish and the reader's read, so a reader may never observe the new list at all — a registered backend that silently receives no traffic. volatile also guarantees that the fully-constructed list is visible, not a half-initialised one.

The list is immutable and the writer copies. List.copyOf and .toList() return unmodifiable lists, so nobody can mutate a snapshot a reader is iterating. Registration does O(n) work; next() does none. With 10,000 reads per write that is the right side of the trade, and it is exactly what CopyOnWriteArrayList does — which you could use directly here and which exists for this precise shape.

Math.floorMod, not %. getAndIncrement overflows to Integer.MIN_VALUE eventually, and in Java % on a negative operand returns a negative result — so get(-1) and a crash after about two billion requests. That is a real outage with a very confusing postmortem.

Solution — Go: atomic.Pointer to an immutable sliceTry it first
type LoadBalancer struct {
	backends atomic.Pointer[[]*Backend] // never mutated in place
	counter  atomic.Uint64
	mu       sync.Mutex // writers only
}
 
func (lb *LoadBalancer) Register(b *Backend) {
	lb.mu.Lock()
	defer lb.mu.Unlock()
	cur := lb.load()
	next := make([]*Backend, len(cur), len(cur)+1)
	copy(next, cur)
	next = append(next, b)
	lb.backends.Store(&next)
}
 
func (lb *LoadBalancer) load() []*Backend {
	if p := lb.backends.Load(); p != nil {
		return *p
	}
	return nil
}
 
func (lb *LoadBalancer) Next() (*Backend, error) {
	snapshot := lb.load() // ONE load; the slice header cannot change under us
	n := len(snapshot)
	if n == 0 {
		return nil, errors.New("no backends")
	}
	i := lb.counter.Add(1)
	return snapshot[i%uint64(n)], nil
}

Same idea, and Go makes one part of it sharper.

A slice is three words, so it cannot be swapped atomically. This is the Go-specific trap.

lb.backends = next on a plain []*Backend field writes three things — a pointer, a length, a capacity — and not as one store. So a reader can pair the new pointer with the old length, index past the end of the new array, and read memory that is not part of the slice.

atomic.Pointer[[]*Backend] swaps a single word. The indirection is the fix, not ceremony.

copy into a fresh array, never append to the shared one. append may write in place when capacity allows, which mutates the slice readers are holding. Allocating with an explicit length is what makes the old snapshot genuinely immutable.

sync.RWMutex is the obvious alternative and it is fine. It is also strictly more expensive on the read path — an RLock is an atomic increment plus a decrement and it contends across cores — where the atomic load is a single uncontended read. At load-balancer call rates that gap is worth having.

Solution — Python: rebinding is atomic, the counter is notTry it first
import itertools, threading
 
class LoadBalancer:
    def __init__(self) -> None:
        self._backends: tuple[Backend, ...] = ()   # immutable
        self._counter = itertools.count()          # thread-safe in CPython
        self._write_lock = threading.Lock()
 
    def register(self, b: Backend) -> None:
        with self._write_lock:
            self._backends = self._backends + (b,)     # rebind, never mutate
 
    def deregister(self, id: str) -> None:
        with self._write_lock:
            self._backends = tuple(b for b in self._backends if b.id != id)
 
    def next(self) -> Backend:
        snapshot = self._backends        # ONE read of the attribute
        n = len(snapshot)
        if n == 0:
            raise RuntimeError("no backends")
        return snapshot[next(self._counter) % n]

Attribute rebinding is a single bytecode, so self._backends = ... publishes atomically in CPython — Python gets the safe publish for free where Java needs volatile and Go needs atomic.Pointer. The read path needs no lock at all.

A tuple, not a list. A list would still be a shared mutable object; someone would eventually call .append on a snapshot. A tuple removes the option.

self._counter += 1 was the other bug, and it is the bank all over again. It is a load, an add and a store, so two threads hand out the same index and one backend gets double traffic. itertools.count() is implemented in C and advances under a single bytecode, so next() on it is atomic — which is why it is the idiomatic answer here rather than a lock.

On free-threaded builds (3.13+) the tuple rebinding still holds, because it remains a single attribute store, but self._counter += 1 would break far more aggressively. Writing it with itertools.count now is what makes this code survive the GIL's removal.

Fix it at the boundary instead

Everything above fixes next() by being careful inside it. There is a stronger move: change the signature so carelessness is not available.

Split the two responsibilities. The registry owns the set of backends. The strategy picks one from a list it is handed:

public interface RoutingStrategy {
    Backend route(List<Backend> nodes);
}
 
public final class RoundRobinStrategy implements RoutingStrategy {
    private final AtomicInteger counter = new AtomicInteger();
 
    @Override
    public Backend route(List<Backend> nodes) {
        if (nodes.isEmpty()) throw new IllegalStateException("no backends");
        return nodes.get(Math.floorMod(counter.getAndIncrement(), nodes.size()));
    }
}

The strategy has no field holding the backends, so there is nothing it can read twice. It receives one list and both reads go to that list. The race is not fixed, it is unconstructible — and it stays unconstructible for every strategy anyone writes later, including ones that iterate the list or read the size three times.

The registry's job is then to pass a genuine snapshot:

public Backend route() {
    return strategy.route(snapshot());   // one immutable list, per call
}

The general move is worth internalising:

  • A lock makes the unsafe thing safe, as long as everyone remembers to take it.
  • A boundary that only ever hands out immutable values removes the need to remember.

The second one keeps working when someone who has never read this page writes the twenty-first strategy.

Why is this stronger than reading the field once into a local?

AnswerCommit to one first

Because it removes the option rather than the mistake.

Reading the field once is a rule someone has to follow. A strategy that holds no field cannot read one twice — and that stays true for the twenty-first strategy, written by someone who never read this page.

Find the two bugs the boundary does not fix

Both of these survive the redesign, and both are in the code above for a reason.

counter % size breaks at overflow. AtomicInteger.getAndIncrement wraps to Integer.MIN_VALUE, and Java's % keeps the sign of the dividend:

count=2147483647   idx= 1   -> n1
count=-2147483648  idx=-2   -> ArrayIndexOutOfBoundsException
Math.floorMod(-2147483648, 3) = 1

At a few thousand requests a second that arrives in under a fortnight of uptime, as an index-out-of-bounds with no obvious cause. Math.floorMod is the fix; counter.getAndIncrement() & Integer.MAX_VALUE also works.

An empty list is a division, not an index. count % 0 throws ArithmeticException: / by zero before get is ever reached, so the error you get names arithmetic rather than backends. Check isEmpty() before the modulo, not after.

Your service handles 3,000 requests a second. How long until the counter overflows?

AnswerCommit to one first

About eight days.

Integer.MAX_VALUE is roughly 2.1 billion; at 3,000 a second you reach it in under a fortnight of uptime. It arrives as an index-out-of-bounds with no deploy, no traffic change and no obvious cause — which is the worst kind of eight-day timer to have running.

Find the one that only shows up in production

If the registry stores backends in a hash set — ConcurrentHashMap.newKeySet() is the natural choice, since registration wants deduplication — then snapshot() has no defined order:

insertion order : [10.0.0.1, .2, .3, .4, .5]
iteration order : [10.0.0.4, .3, .5, .2, .1]

after registering .6:
before: [.4, .3, .5, .2, .1]
after : [.4, .3, .6, .5, .2, .1]      <- .6 lands in the middle

counter=2   before -> .5   after -> .6   CHANGED
counter=3   before -> .2   after -> .5   CHANGED

A new node is inserted wherever it hashes, not at the end. So registering one backend reshuffles the rotation for every position after it.

This is a nasty one to catch, because nothing looks wrong:

  • Your tests pass. The distribution across backends is still even.
  • No error is raised, and no metric moves.
  • In production, a single deploy silently re-points a large share of traffic.

If you need stable rotation, the snapshot has to be ordered — sort by a stable id, or keep the backends in a CopyOnWriteArrayList and use the set only for the duplicate check.

Your test asserts traffic is evenly distributed across backends. Does it catch this?

AnswerCommit to one first

No. The distribution stays perfectly even — every backend still gets its share.

What changes is which backend a given position maps to. No counter is skipped, no backend is starved, and no error is raised. The only symptom is that a deploy silently re-points a large share of traffic, which looks like a client problem rather than a routing one.

Publishing a new list, in three languages

Every solution is the same three moves: make the shared thing immutable, have the writer build a replacement, publish it with one store. What differs is what "one store" costs you.

Java

Needs volatile for visibility, or a registered backend may never be seen by a reader at all. CopyOnWriteArrayList packages the whole pattern and is the right production answer. The sharp edge is arithmetic rather than memory: AtomicInteger overflows into negative numbers and % preserves the sign, so use Math.floorMod or you have an index-out-of-bounds scheduled for request two billion.

Go

The only one where the obvious field assignment is genuinely unsafe: a slice header is three words and cannot be stored atomically, so a reader can pair a new pointer with a stale length. atomic.Pointer to a slice is the fix, and copy into a fresh array — not append — is what keeps old snapshots immutable. RWMutex also works and costs more on the read path than the read path deserves.

Python

Gets the safe publish for free, because rebinding an attribute is one bytecode. Use a tuple so a snapshot cannot be mutated, and replace counter += 1 with itertools.count() — the counter, not the list, is where the GIL stops protecting you, and it is the part that breaks first on a free-threaded interpreter.

The transferable lesson. When state is read constantly and written rarely, stop thinking about locking it and start thinking about replacing it. Readers take one consistent snapshot and are immune to everything that happens afterwards; writers pay a copy nobody notices. This is the same instinct behind MVCC in a database, persistent data structures in functional languages, and the config-reload path in every server you have ever operated.

Before you move on

You should be able to explain each of these without looking.

  • Why the bug lives between the size read and the element read, and why an atomic counter never addressed it.
  • Why one read of the field into a local is the entire fix.
  • Why Java needs volatile, Go needs atomic.Pointer, and Python needs neither.
  • Why a Go slice cannot be published with a plain assignment.
  • Why % on an overflowed AtomicInteger crashes, and what to use instead.
  • Why itertools.count() replaces counter += 1 in Python.
  • Whether your rotation stays fair across a deregister, or reshuffles.

Go further

  • Fix the fairness problem. % n over a changing n reshuffles the whole mapping when a backend leaves. Keep a per-backend cursor, or hash the request rather than counting it — this is the doorway to consistent hashing, and the reason it exists.
  • Make register idempotent: registering the same id twice should replace, not duplicate. Note that you now have contains + size + add — three safe calls that are not safe together — so the write lock has to span all three. A ConcurrentHashMap-backed set makes each call atomic and does nothing at all for the sequence.
  • Add a capacity limit and reject registration past it. Same observation: the check and the add must be one unit.
  • Add weights, so a backend can take twice the traffic. Smooth weighted round-robin is a genuinely elegant algorithm and about fifteen lines.
  • Measure the copy. Time register with 10 backends and with 10,000. Find the size at which copy-on-write stops being free, and decide what you would do past it.
  • Keep this registry. Every remaining exercise in the track builds on it — exercise 05 replaces round-robin with least-busy routing, and by exercise 10 the same object is doing health checks, pooling connections, breaking circuits and draining gracefully.