Instalment 5 · Course 1 (Go) · Advanced phase and finish
Six advanced topics with working code, one substantial final challenge with its solution withheld, a knowledge check of forty-one questions, and everything you need to put this on GitHub and defend it in an interview.
The colony works. These six topics are what you would reach for if this were a system you had to operate rather than a system you had to finish. Each is short, each has code you can drop in, and each names what to measure afterwards.
"One goroutine per ant" is right because ants are the unit of simulation. "One goroutine per unit of work" is wrong for most other things: parsing 50,000 files, calling an API for each of 10,000 records, or replaying a chaos schedule. Unbounded goroutines mean unbounded memory, unbounded file descriptors, and a remote service you have accidentally attacked.
The Go idiom for a bound is a buffered channel used as a counting semaphore. A slot in the buffer is a permit.
// Pool runs at most n tasks at once. The semaphore is a buffered channel:
// each slot in the buffer is a permit.
type Pool struct {
sem chan struct{}
wg sync.WaitGroup
}
func NewPool(n int) *Pool { return &Pool{sem: make(chan struct{}, n)} }
// Go blocks until a permit is free, which is where backpressure happens.
func (p *Pool) Go(ctx context.Context, f func()) error {
select {
case p.sem <- struct{}{}:
case <-ctx.Done():
return ctx.Err()
}
p.wg.Add(1)
go func() {
defer p.wg.Done()
defer func() { <-p.sem }()
f()
}()
return nil
}
func (p *Pool) Wait() { p.wg.Wait() }
Three details that separate this from the versions you find on blogs. The acquire is a select with ctx.Done(), so a saturated pool can still be shut down. The release is a defer inside the goroutine, so a panicking task still returns its permit. And Go blocks rather than queueing, which means the caller feels the backpressure and stops producing work: a queue you cannot see is a queue that grows until you run out of memory.
With generics, the same idea becomes a reusable parallel map:
// MapConcurrent applies f to every element with bounded concurrency and
// returns results in input order. Generics make it reusable; the result
// slice is preallocated so workers write disjoint indices with no lock.
func MapConcurrent[T, R any](ctx context.Context, in []T, workers int,
f func(context.Context, T) (R, error)) ([]R, error) {
out := make([]R, len(in))
errs := make([]error, len(in))
pool := NewPool(workers)
for i, v := range in {
if err := pool.Go(ctx, func() {
out[i], errs[i] = f(ctx, v)
}); err != nil {
break
}
}
pool.Wait()
for _, err := range errs {
if err != nil {
return out, err
}
}
return out, ctx.Err()
}
[T, R any] declares two type parameters; any is the constraint meaning "any type at all". The important trick is not the generics, it is that writing to distinct indices of a preallocated slice from many goroutines is race-free. Each index is a separate memory location and the slice header never changes, so no synchronisation is needed. Appending to a shared slice would be a race; this is not. That distinction is worth internalising, because it turns a whole class of "collect the results" problems into lock-free code.
What to measure: throughput against worker count, and the memory high-water mark with and without the bound. The optimum worker count for CPU work is around GOMAXPROCS; for I/O-bound work it is much higher and should be found empirically.
You have now implemented three of these four without naming them. Naming them is what lets you choose deliberately.
| Policy | Code shape | Use when | In the colony |
|---|---|---|---|
| Block | ch <- v | The producer can usefully slow down | Ant requests to the owner |
| Shed | select with default | The message is optional or stale-able | Pheromone deposits, evaporation ticks |
| Time out | select with a timer | Waiting longer has no value | Round trips, frame requests |
| Reject | Return an error at admission | You must protect a downstream | Not implemented; see below |
The one you have not built is admission control: refusing work at the front door so that accepted work still completes on time. It is the difference between a system that degrades and a system that collapses, and the shape is three lines:
if depth := len(e.reqs); depth > e.cfg.QueueSize*3/4 {
e.M.Rejected.Inc()
return response{err: ErrOverloaded} // fail fast, do not queue
}
The counter-intuitive part is that rejecting 10% of requests instantly can leave the other 90% faster than accepting 100% and serving them all slowly, because queueing delay compounds. When every request waits 8 ms behind a queue, clients time out and retry, which adds load, which lengthens the queue. Little's Law states it precisely: for a stable system, average queue length = arrival rate × average time in system. You cannot choose all three, so choose which one you will control.
What to measure: queue depth as a time series (not an average), latency percentiles at several arrival rates, and the point where p99 latency turns sharply upward. That knee is your capacity, and it is always lower than the throughput number you can quote.
Five techniques, in increasing order of how much they will surprise you.
1. Run tests repeatedly under the race detector. go test -race -count=20 ./... in CI. A single pass proves almost nothing about interleaving, and twenty passes cost seconds.
2. Keep a deterministic oracle. This is why the sequential Sim from Milestone 3 was never deleted. It is slow, boring and exactly reproducible, which makes it the reference implementation: given the same seed and the same number of ticks, the concurrent engine should agree with it on invariants (food conserved, no negative cells, ants inside the grid) even though it cannot agree on exact positions. A slow, obviously-correct implementation is a testing asset, not dead code.
3. Test invariants, not outputs. Every genuine bug in this project was caught by conservation, not by an expected value. Concurrent systems have no expected output; they have properties that must hold.
4. Fuzz the behaviours. A Behaviour is untrusted input to the engine, so fuzz it:
func FuzzApplyAction(f *testing.F) {
f.Add(5, 5, 1, 0, uint8(1))
f.Fuzz(func(t *testing.T, x, y, dx, dy int, kind uint8) {
e := NewEngine(Config{Width: 16, Height: 16, Ants: 1}, Forager{})
act := Action{Kind: ActionKind(kind % 4), Dir: world.Position{X: dx, Y: dy}}
// The engine must never panic, whatever an ant asks for.
e.applyAction(request{id: 0, pos: world.Position{X: x, Y: y}, act: act})
})
}
Run with go test -fuzz FuzzApplyAction. The fuzzer mutates inputs, keeps anything that reaches new code paths, and writes failing cases to testdata/fuzz/ where they become permanent regression tests. It found nothing in my version because every grid access is bounds-checked, which is precisely the kind of "nothing" you want evidence for.
5. Detect leaks explicitly. Our poll-and-compare test is the homemade version; go.uber.org/goleak is the real one and reports the leaked stacks. Add it to TestMain and every test in the package is covered at once.
Recent Go versions add testing/synctest, which runs a test in a bubble with a fake clock: time.Sleep(time.Hour) returns instantly, and the bubble waits until every goroutine in it is blocked before advancing time. That turns "run for 300 ms and hope" tests, which this project has several of, into deterministic ones that finish in microseconds. It arrived as an experiment behind GOEXPERIMENT=synctest and has been stabilising since; check whether your toolchain has it before designing around it, and if it does, rewriting the timing-dependent tests here is an excellent exercise.
Milestone 8's chaos rolls dice inside each ant, which makes a failing run unreproducible in the one way that matters: you cannot replay it. The fix is to decide the failures up front.
// Schedule is a precomputed list of failures. Because it is built once from
// a seed, a chaotic run replays exactly, which is the difference between a
// bug you can fix and a bug you can only describe.
type Schedule struct {
faults []Fault
next int
}
func BuildSchedule(seed uint64, ticks, ants int, ratePerTick float64) *Schedule {
rng := rand.New(rand.NewPCG(seed, 0xA5A5))
var faults []Fault
for t := range ticks {
if rng.Float64() >= ratePerTick {
continue
}
faults = append(faults, Fault{
Tick: t,
AntID: rng.IntN(ants),
Kind: FaultKind(rng.IntN(3)),
})
}
return &Schedule{faults: faults}
}
// Due reports the faults scheduled at or before tick t. Called only by the
// owner goroutine, so it needs no synchronisation and no allocation.
func (s *Schedule) Due(t int) []Fault {
start := s.next
for s.next < len(s.faults) && s.faults[s.next].Tick <= t {
s.next++
}
return s.faults[start:s.next]
}
Now a failing CI run reports "seed 8841, 400 ticks" and you reproduce it exactly on your laptop. The schedule can also be written to a file and checked in next to the bug it exposed.
This is one step short of what the state of the art does. FoundationDB and TigerBeetle run their entire system inside a deterministic simulator where time, scheduling, disk and network are all controlled by one seed, so every interleaving is reproducible and the fuzzer can search the space of schedules. That is a large engineering investment and it is why those systems are unusually trustworthy. Knowing it exists changes what you consider possible.
What to measure: that the same seed produces byte-identical failure output twice. If it does not, you still have a source of nondeterminism you have not captured.
Counters tell you a rate; logs tell you a story. Go 1.21 added log/slog to the standard library, so structured logging no longer needs a dependency:
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
slog.Info("colony started", "ants", cfg.Ants, "grid", cfg.Width, "seed", cfg.Seed)
// A logger carrying context, created once per ant rather than per line.
log := slog.With("ant", a.ID, "gen", a.Generation)
log.Warn("restarting after crash", "pos", a.Pos.String(), "carrying", a.Carrying)
Key-value pairs rather than formatted strings, so a log aggregator can filter on ant=4471 without regular expressions. Two rules for a system like this one: never log per tick (three million requests a second will produce more log volume than simulation), and sample or aggregate anything that can happen per ant. A log line costs microseconds; a counter costs nanoseconds. Log the exceptional, count the routine.
Then there is the tool almost nobody uses and everybody should:
$ curl -o trace.out "127.0.0.1:9090/debug/pprof/trace?seconds=5"
$ go tool trace trace.out
This opens a browser view of what every goroutine did, when, on which processor, including scheduler latency, garbage collection pauses, syscall blocking and network waits. Where pprof tells you where time went, the tracer tells you why a goroutine was not running. For this project the interesting view is "Synchronization blocking profile", which shows the ants queued behind the owner as a wall of parked goroutines. No other mainstream language ships anything this good with the runtime.
# a stamped, stripped, static binary
go build -trimpath -ldflags "-s -w -X main.version=$(git describe --tags --always)" \
-o bin/antfarm ./cmd/antfarm
# cross-compile: no toolchain to install, no container needed
GOOS=linux GOARCH=arm64 go build -o bin/antfarm-linux-arm64 ./cmd/antfarm
-trimpath removes your home directory from the binary, -s -w strip debug information (halving the size, at the cost of readable panic traces: keep them if you value stack traces more than megabytes), and -X sets a string variable at link time, which is the standard way to embed a version. Cross-compilation needs nothing installed, which is genuinely unusual and is why so much infrastructure tooling is written in Go.
FROM golang:1.25 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /antfarm ./cmd/antfarm
FROM gcr.io/distroless/static-debian12
COPY --from=build /antfarm /antfarm
EXPOSE 8080 9090
USER 65534:65534
ENTRYPOINT ["/antfarm"]
CGO_ENABLED=0 produces a binary with no libc dependency, which is what makes the final image a few megabytes with no operating system in it. Distroless (or scratch) means no shell, no package manager and nothing for an attacker to pivot into.
Go sets GOMAXPROCS from the number of CPUs it can see, and in a container that is the number of host cores, not your CPU limit. A binary limited to 0.5 cores on a 64-core host will happily create 64 scheduler threads and spend its life being throttled. Set GOMAXPROCS explicitly from your limit, or use go.uber.org/automaxprocs, which reads the cgroup quota at startup. This is one of the most common and least-known causes of "our Go service is mysteriously slow in Kubernetes".
Similarly, Go's garbage collector targets a heap multiple, not a memory limit, so a container can be OOM-killed while the GC is being patient. GOMEMLIMIT (Go 1.19+) gives it a soft ceiling; set it to about 90% of the container limit.
Everything up to here had a solution a few paragraphs later. This one does not, and you should spend real time on it before opening the last section. It is deliberately at the edge of what you can now do, and it is the kind of thing that makes a portfolio project memorable.
Milestone 12 put the whole world in one remote process. Now split it: K world processes, each owning a region of the grid, with ants that walk from one region into another.
ID, Carrying, Energy, Generation) must migrate to the new node's ant table. Exactly once. An ant may never exist on two nodes, and may never disappear./metrics endpoint. go test -race -count=5 ./... passes, the leak test still passes, and go vet is silent.docs/handoff.md with a message diagram and a table of every failure point and what happens there.Attempt it before reading on. Even a partial implementation with an honest failure analysis is worth more than the section below.
A two-phase handoff with an idempotency key, which is the standard answer and worth being able to derive rather than recall.
Node A (source) Node B (destination)
─────────────── ───────────────────
ant crosses boundary
mid := uuid()
state := freeze(ant) ─ HANDOFF(mid, ant) ─►
mark ant "migrating" if seen(mid): reply ACK (dedupe)
(still owned by A, frozen) else: insert ant, remember mid
◄──── ACK(mid) ───── reply ACK
delete ant locally
remember mid until B's
ack-of-ack window closes
retry HANDOFF until ACK (at-least-once)
B suppresses duplicates by mid (idempotent)
The states an ant can be in, from the source node's point of view:
| State | Meaning | May act? |
|---|---|---|
owned | Normal. This node answers for it. | yes |
migrating | HANDOFF sent, no ACK yet. Still ours, frozen. | no |
gone | ACK received. Deleted; mid retained briefly. | no |
Freezing during migrating is what makes this safe. The ant exists on exactly one node at all times: either A still owns it (frozen, so it cannot change), or B owns it. There is no window in which both may act on it, and none in which neither exists.
type MigrationID struct {
From uint64 // node ID, so IDs from different nodes cannot collide
Seq uint64 // monotonic per node
}
type handoff struct {
ID MigrationID
Ant sim.Ant
Tick uint64 // source node's logical clock, for the dedupe window
}
// receive is idempotent: the same handoff delivered ten times inserts one
// ant and sends ten acks.
func (n *Node) receive(h handoff) ack {
n.mu.Lock()
defer n.mu.Unlock()
if _, dup := n.seen[h.ID]; dup {
n.M.DedupeHits.Inc()
return ack{ID: h.ID, OK: true} // re-acknowledge, do not re-insert
}
n.seen[h.ID] = h.Tick
n.ants[h.Ant.ID] = &h.Ant
n.M.Migrations.Inc()
return ack{ID: h.ID, OK: true}
}
// send retries until acknowledged or the destination is declared down.
func (n *Node) send(ctx context.Context, to NodeID, a *sim.Ant) error {
h := handoff{ID: n.nextMigrationID(), Ant: *a, Tick: n.tick.Load()}
n.markMigrating(a.ID)
for attempt := 0; ; attempt++ {
if !n.membership.Up(to) {
n.unmarkMigrating(a.ID) // bounce: keep the ant here
n.M.FailedHandoffs.Inc()
return ErrDestinationDown
}
if _, err := n.peer(to).Handoff(ctx, h); err == nil {
n.delete(a.ID) // only now is it safe to forget
return nil
}
select {
case <-time.After(backoff(attempt)):
case <-ctx.Done():
n.unmarkMigrating(a.ID)
return ctx.Err()
}
}
}
Bounding the dedupe table. seen cannot grow forever. Each entry carries the source's tick, and a source node includes its "oldest unacknowledged migration" in every heartbeat. Once B knows A has no outstanding migrations older than tick T, every mid from A with a tick below T can never be retried, so B forgets it. That is a low-water-mark garbage collection, the same mechanism distributed logs use to truncate, and it is the honest answer to "for how long?": until the sender proves it will never ask again.
| Crash point | Outcome | Why it is safe |
|---|---|---|
| A dies before sending | Ant is lost with A's state | Accounted: on restart, A's ants are gone and the ledger records them as lost. No duplicate. |
| HANDOFF lost in flight | A retries | B never saw it; the retry is the first delivery. |
| B dies after inserting, before ACK | A retries; B (restarted, empty) inserts again | One copy. A's original was frozen and is deleted on the eventual ACK. |
| ACK lost | A retries; B dedupes and re-acks | Idempotent receiver. One copy. |
| A dies after ACK, before deleting | A restarts without the ant (state was in memory) | One copy, on B. With persistence you would need the gone marker on disk. |
| B declared down (wrongly) | A unfreezes the ant and keeps it | Safe: A never released ownership. The ant bounces off the boundary. |
| B declared down after ACK | Ant is on B, unreachable | Ant is idle until B returns. Not lost, not duplicated, just unavailable. |
If you can explain that last bullet in an interview, you are ahead of most candidates with "distributed systems" on their CV. And notice how much of this Erlang gives you as library code: monitors are a failure detector, global is a distributed registry, and the partition question has a documented, arguable answer. That is Course 4.
select be useful rather than a bug?select chooses randomly among ready cases, and give a concrete bug that behaviour caused in this project.wg.Add not run concurrently with wg.Wait, and how did this project guarantee that?context need to be passed as a parameter rather than stored in a struct?append return a slice, and what is the classic aliasing bug?panic/recover with exceptions. Where is recover legitimate, and why is a recover in every goroutine a bad idea?-count=20 and running it under load.WaitGroup proves all senders have finished), because a closed channel broadcasts to every receiver and drains its buffer first. A panic waiting to happen when other goroutines may still send: "send on closed channel" is unrecoverable and, with thousands of senders, unavoidable. Use context cancellation instead.select is simply never ready. Setting a channel variable to nil is therefore the idiomatic way to disable one case of a select dynamically, for instance to stop reading input while a buffer is full.reply and ctx.Done() ready, chose Done half the time, and discarded a unit of food the world had already removed from the grid.Wait may return the moment the counter hits zero, and a concurrent Add could then start work after shutdown has been declared. This project made the supervisor the only goroutine that calls Add after startup, and waited for the supervisor to exit (<-supervisorDone) before calling Wait.ctx.Done() never stops; you cannot kill it, so the only fix is to write loops that check. A goroutine blocked in a socket read is blocked in the kernel, where a context is invisible; you must close the connection or set a deadline. Both are the same fact: there is no goroutine.Kill().ctx.*MyError to an error leaves the type half set. Rule: functions that can fail return error, never a concrete error type.m["k"].Mutate() does not compile for a pointer-receiver method; store pointers in the map, or read-modify-write the whole value.b := a[1:3]; b = append(b, x) writes into a[3] when capacity allows, silently modifying a.append writes the length and possibly reallocates the backing array, which is shared state, so concurrent appends race on the header and can lose elements.recover is legitimate at a genuine isolation boundary (one request, one plugin, one ant) and when converting a panic to an error at a library's edge. Recovering everywhere produces a program that keeps running with invariants already broken, which is worse than crashing. ch <- v) for ant requests, where slowing the producer is correct. Shed (select with default) for pheromone deposits and evaporation ticks, where a lost message is cheap. Time out (select with a timer) for round trips, where waiting longer has no value. A fourth, reject at admission, protects a downstream by failing fast.atomic.Int64 (simple, loses range and precision) or math.Float64bits with a compare-and-swap loop (exact, needs a retry loop and is slower under contention). A third option is to not share the float at all and give each shard its own.Predict the output or behaviour of each, then check. All ten were run to confirm the answers.
// 1
a := []int{1, 2, 3, 4, 5}
b := a[1:3]
b = append(b, 99)
fmt.Println(a, len(b), cap(b))
// 2
func() {
for i := range 3 {
defer fmt.Print(i, " ")
}
}()
// 3
var m map[string]int
fmt.Println(m["x"], len(m), m == nil)
// and then: m["x"] = 1
// 4
var nilCh chan int
select {
case <-nilCh:
fmt.Println("received")
default:
fmt.Println("default")
}
// 5
ch := make(chan int, 2)
ch <- 7
close(ch)
v1, ok1 := <-ch
v2, ok2 := <-ch
fmt.Println(v1, ok1, v2, ok2)
// 6
type OOB struct{ X int }
func (e *OOB) Error() string { return "oob" }
func find() *OOB { return nil }
var err error = find()
fmt.Println(err == nil, find() == nil)
// 7
type Ant struct{ Energy int }
func (a Ant) Drain() { a.Energy = 0 }
ant := Ant{Energy: 100}
ant.Drain()
fmt.Println(ant.Energy)
// 8
done := make(chan int, 3)
for i := range 3 {
go func() { done <- i }()
}
sum := 0
for range 3 {
sum += <-done
}
fmt.Println(sum)
// 9
type P struct{ X, Y int }
seen := map[P]int{{1, 2}: 5}
fmt.Println(P{1, 2} == P{1, 2}, seen[P{1, 2}], seen[P{9, 9}])
// 10
var wg sync.WaitGroup
for i := range 3 {
go func() {
wg.Add(1)
defer wg.Done()
work(i)
}()
}
wg.Wait()
fmt.Println("done")
1. [1 2 3 99 5] 3 4
2. 2 1 0
3. 0 0 true then: panic: assignment to entry in nil map
4. default
5. 7 true 0 false
6. false true
7. 100
8. 3
9. true 5 0
10. prints "done" immediately, usually before any work runs
b has length 2 and capacity 4 (from index 1 to the end of the backing array), so append writes in place into a[3]. The classic aliasing bug.len is 0; it compares equal to nil. Writing panics. This asymmetry catches everyone once.default runs. Without the default this would deadlock.7, true) and then the zero value with ok false, forever.err holds (type *OOB, value nil), so it is not nil; the direct pointer comparison is. The nil-interface trap.Drain zeroes the copy and the original is untouched. If your state refuses to change, check the receiver first.i, so the goroutines send 0, 1 and 2 in some order and the sum is 3. On Go 1.21 and earlier this printed 6 or another value, because all three closures shared one variable that had usually reached 3.== and usable as map keys. A missing key returns the zero value.wg.Add(1) runs inside the goroutine, so Wait very likely sees a counter of zero and returns before anything has started. Add must be called before go. This one is not "what does it print" so much as "why is this wrong", which is the more useful question.Each gives a symptom and a suspect. Diagnose before opening the answer.
roundTrip, and the owner goroutine shows blocked in handle at a channel send. Nothing crashes; the process sits at 0% CPU forever.c := &antClient{reply: make(chan response)} // note: unbufferedfatal error: concurrent map writes, roughly once every twenty runs, always under load, never under a debugger.type Scout struct{ headings map[int]world.Position }
func (s *Scout) Decide(a *Ant, sn Sense, rng *rand.Rand) Action {
if _, ok := s.headings[a.ID]; !ok {
s.headings[a.ID] = randomDir(rng)
}
return Action{Kind: ActMove, Dir: s.headings[a.ID]}
}runtime.NumGoroutine() climbs from 1,500 to 40,000 over ten minutes.func (e *Engine) evaporate(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-time.After(e.cfg.EvaporateEvery):
e.reqs <- request{kind: reqEvaporate}
}
}
} case ActPickUp:
a.Carrying = true
if err := e.world.TakeFood(r.pos); err != nil {
return response{err: err}
}
return response{ok: true}func startSecond(m metrics.Set) func() { // note: by value
return serve(":9091", m.Handler(), "metrics2")
}make(chan response, 1). Diagnostic route: the goroutine profile showing the owner blocked on a send is the giveaway, because the owner should never be blocked.Decide on the same *Scout, so all of them write one map. This is not a data race the detector merely warns about; the runtime deliberately aborts the process on concurrent map writes. It is load-dependent because two writes must land in the same window. Fixes, best last: a mutex (serialises every decision), a slice indexed by ant ID (fast, fragile), or per-ant fields on Ant (no sharing at all).time.After in a loop. Each call allocates a timer that stays alive until it fires, and the goroutine count climb is the tell. Here the deeper problem is the unguarded send: when the owner is saturated, this goroutine blocks, and the next tick's timer is created anyway. Replace with one time.NewTicker plus defer ticker.Stop(), and make the send non-blocking.metrics.Set contains atomics, so passing it by value copies the counters; the new handler reports its own frozen zeros. go vet catches this as "passes lock by value" for sync types and, in recent versions, for atomic types too. Pass *metrics.Set.Behaviour that maintains a small per-ant memory of where food was found (last three positions, on the Ant struct) and biases search toward those areas when pheromone is absent. Measure whether it beats TrailFollower over ten seeds, and report both mean and win count.-race clean with 1,000 ants on one connection.Distinct from the final challenge in Part B, and smaller, but not easy.
Build a scheduling fuzzer. Make the interleaving itself a controlled variable. Introduce a Scheduler abstraction that every goroutine in internal/sim consults at yield points (before a send, after a receive, around each tick), which in test mode advances goroutines in an order chosen by a seed. Then write a test that runs 10,000 seeds against the conservation invariant and reports any seed that breaks it.
Requirements: production mode must compile to zero overhead (the scheduler calls must be inlinable no-ops, verified with -gcflags=-m); the fuzzer must find the "select chose Done and discarded the reply" bug from Milestone 11 when you reintroduce it; and a failing seed must be replayable. Hints: an interface with two implementations, one of which has empty methods; the real difficulty is not the scheduler but finding the right yield points, and the answer is "wherever the program's behaviour could depend on ordering".
The Go memory model in terms of happens-before, and how channel operations and mutexes create it.select, including random choice, default, and disabling a case with nil.errors.Is/As, and when a custom type beats a sentinel. WaitGroups, proven leak-free by a test.# antfarm
A concurrent ant-colony simulation used as a laboratory for Go concurrency,
fault tolerance and observability. Thousands of ants, each an independent
goroutine, forage for food and lay pheromone trails in a world owned by a
single goroutine. Failures are injected on purpose; everything is measured.
No third-party dependencies. Standard library only.
## What it does
- Each ant runs in its own goroutine and communicates by message passing.
- Exactly one goroutine owns the world; there is no mutex in the engine.
- Ants lay pheromone trails on the way home, which measurably improves
foraging (~25% more food delivered than a random walk, same seed and budget).
- Chaos injection: random crashes, dropped messages and slow workers, with a
supervisor that restarts crashed ants and accounts for their lost cargo.
- Prometheus-format metrics, pprof, a terminal renderer and a browser view
fed by server-sent events.
- A sharded engine with atomic reads that is 1.4-2.9x faster than the
single-owner engine on a single core.
- The world can run in a separate process; ants reconnect when it restarts.
## Quick start
go run ./cmd/antfarm -ants 2000 -grid 128x128 -food 10 -duration 10s \
-metrics :9090 -view :8080
Then open http://localhost:8080 for the live view and
http://localhost:9090/metrics for the counters.
go run ./cmd/antfarm -term -ants 500 # terminal renderer
go run ./cmd/antfarm -chaos crash=0.001,drop=0.02,slow=0.005
## Flags
-grid WxH world size (default 64x64)
-ants N number of ants
-food N number of food sources
-food-per-source N units per source
-behaviour forager|trail
-seed N same seed replays the same run
-duration D how long to run
-chaos k=v,... crash, drop, slow, slowfor
-metrics addr serve /metrics and /debug/pprof
-view addr serve the browser view
-term draw in the terminal
## Architecture
cmd/antfarm flags, wiring, signals, HTTP servers
internal/sim ants, behaviours, engines, chaos, supervision
internal/world grids, food, pheromone, atomic grid
internal/metrics counters, histograms, exposition, pprof
internal/view terminal renderer, SSE stream, browser client
internal/netsim gob-over-TCP server and reconnecting client
Two engines are kept deliberately:
- `Engine` (single owner) is the correct, readable design. Read this first.
- `FastEngine` (sharded, atomic reads) is the profile-driven rewrite.
The mutex-based version from the concurrency milestone is also kept, in
`concurrent.go`, because the point of this project is the comparison.
## Testing
go test ./... # everything
go test -race -count=5 ./... # what CI runs
go test -bench . ./... # benchmarks
Tests are invariant-based: the central one asserts that food is conserved
(delivered + on the ground + carried + lost equals the starting total) under
concurrency, chaos and shutdown. It has caught every real bug in this project.
## Measurements
All on one core, Go 1.22:
| Thing | Result |
|-----------------------------------|---------------------------|
| Sense, in-process (atomic reads) | 29 ns |
| Sense, over loopback TCP | 10.7 µs (368x) |
| Round trip p50 / p99, 1500 ants | 2 µs / 8 ms |
| Requests handled, 4 s run | 3,034,578 |
| Trail following vs random walk | +25% food delivered |
| Sharded vs single owner | 1.4-2.9x throughput |
## Known limitations
- Ant state is in memory; a crashed process loses it by design.
- The network protocol has no request IDs, so one connection serves one ant.
- The distributed mode has no consensus, so a partition can split ownership.
## Licence
MIT
Three things that README does deliberately. It leads with measurements rather than adjectives. It keeps the "worse" implementations and explains why, which signals that the comparison was the point. And it has a known limitations section, which is the single strongest credibility signal a portfolio project can carry: it says you understand your own design well enough to know where it ends.
A concurrent ant-colony simulation in Go, used as a laboratory for message passing, supervision, chaos injection and observability. Thousands of goroutines, no mutex in the engine, everything measured.
Topics: go, golang, concurrency, goroutines, channels, simulation, agent-based-model, chaos-engineering, observability, distributed-systems, pprof, ant-colony-optimization.
make([]*Ant, 0, n) versus growth is roughly twenty allocations and a hundred thousand copies at 50,000 ants.-benchmem test.time.Now() calls per round trip cost about 15% of CPU at three million round trips per second. Sample.GOMAXPROCS and GOMEMLIMIT in containers.cmdline leaks arguments, and profile lets anyone impose CPU load. Mount it explicitly (never via DefaultServeMux), bind it to localhost, and reach it through a tunnel.crypto/tls and a shared credential before this leaves a laptop; gob will also happily decode adversarial input, so treat the port as trusted-network-only.io.LimitReader around the connection.http.Server has none; a slow client can hold connections indefinitely. ReadHeaderTimeout at minimum.fmt.Fprint into HTML is an injection; use html/template, which escapes by context.-ants 100000000 will exhaust memory. Validate flags, and cap what a remote caller can request (the frame cols parameter comes from a query string).Do not present this as "an ant simulation". Present it as what it is: a study of one concurrency problem solved four ways, with measurements. The narrative that makes it interesting is the sequence of failures.
Keep a docs/ folder with the profile output, the benchmark table and one architecture diagram. Screenshot the browser view for the README. A reviewer who spends ninety seconds on your repository should come away knowing you can measure, not just build.
What to cut if you want it tighter: the terminal renderer (charming, not load-bearing) and the sequential Sim, unless you keep it explicitly as a test oracle and say so.
| Question | What a strong answer includes |
|---|---|
| Walk me through the concurrency design. | Ownership as the organising idea: who owns what, why the world has exactly one owner, and where you kept a mutex anyway (the connection registry) and why that was right. |
| Why not just use a mutex? | You did, you measured it, it was slower than sequential, and you can say why: total serialisation plus scheduling overhead. Then the structural reasons the owner won. |
| How do you know it is correct? | Conservation invariants under race, chaos and shutdown; -race -count in CI; a deterministic sequential oracle; the two bugs the invariant caught that the race detector could not. |
| Tell me about a bug you found. | The shutdown food loss: select chose ctx.Done() over a ready reply, discarding work the world had already performed. Include how you found it (invariant test, then ledger instrumentation) and the fix. |
| How did you make it faster? | Profile first. Quote what dominated (selectgo, channel locks, timers) and note that simulation code was not in the top fourteen. Then the three changes and the measured result, with the single-core caveat stated. |
| What happens when a worker crashes? | Recover in the same goroutine, report to a supervisor over a channel, reset state, restart with backoff and jitter, and account for in-flight work. The last part is what separates a real answer from a tutorial answer. |
| How would you make this production-ready? | Admission control, restart intensity limits, TLS and auth on the wire, message size caps, GOMAXPROCS/GOMEMLIMIT, structured logs, alerting on queue depth and p99, and persistence for the state you currently lose. |
| Exactly-once delivery: how? | You cannot. At-least-once plus an idempotent receiver, with a dedupe key and a bounded window for forgetting it. Explain the low-water-mark garbage collection. |
| When would you not use Go for this? | Erlang if fault tolerance were the whole problem (isolated heaps, forced kill, supervision as configuration); Rust if the race had to be impossible rather than detectable. Name what Go wins on: tooling, deployment, and time to competence. |
| What would you do differently? | Start with the owner design rather than arriving at it; build the deterministic chaos schedule before the random one; design the metric set before the counters accumulated ad hoc; and put request IDs on the wire from the start. |
GOOS=js GOARCH=wasm) so the simulation runs in the browser with no server.
2,961 lines of dependency-free Go across six packages, four engine designs with measurements comparing them, a supervisor, a chaos subsystem, a metrics and profiling endpoint, two viewers, and a network protocol that survives its server being killed. More importantly: a habit of measuring before optimising, and of testing invariants rather than outputs.
The central question of this curriculum was what kinds of problems does this language make unusually natural to solve? Go's answer, stated as precisely as this project allows: problems with many independent activities that need to communicate, where you want the concurrency to be visible in the code, the failures to be detectable at run time, and the result to be a single binary you can deploy and profile in production. Not the fastest, not the safest, not the most expressive. The one where a competent team can build a correct concurrent system quickly and then find out what it is actually doing.
Next instalment begins Course 2: Ruby, and the automation DSL. The change of gear is total. Nothing will be about throughput; everything will be about expressiveness, and the first question will be why a configuration file should be a program.
Next: Ruby instalment