InstalmentMilestones 5–8

Instalment 2 · Course 1 (Go) · Milestones 1–4

One ant, then a thousand, then a thousand goroutines and a broken world

We build the simulation sequentially, prove it correct, then deliberately break it by making it concurrent. Every snippet here was compiled, vetted and tested before it reached the page.

How to work through this

Type the code rather than copying it, run the tests after each file, and commit at the end of each milestone (git commit -m "milestone 3: behaviour interface"). The diffs between milestones are part of the lesson. Everything below was verified against Go 1.22; anything newer is fine.

Milestone 1One ant on a grid, ticking

Goal

A program you can run that creates a world, puts one ant in it, and advances time. No food, no concurrency, no cleverness. By the end you can type go run ./cmd/antfarm -ticks 5 and watch an ant wander.

Concepts

Package layout across three directories, struct design, pointer receivers, constructors by convention, the flat-array trick for 2D grids, seeded randomness for reproducibility, command-line flags, and the first tests.

Design, before any code

The Mewlang cat, thinking, paw to chinThree decisions matter here, and they are the ones that make the next eleven milestones either easy or painful.

1. Separate the world from the simulation. internal/world knows about geometry, terrain and food. It knows nothing about ants, ticks or strategies. internal/sim knows about ants and time, and drives the world. The dependency points one way only:

cmd/antfarm  ──►  internal/sim  ──►  internal/world

  flags,            ants, ticks,        grid, positions,
  wiring,           behaviour,          food, nest
  printing          statistics          (knows nothing of ants)

Why bother this early? Because in Milestone 5 exactly one goroutine will be allowed to touch world, and a package boundary is the cheapest way to make that rule visible. If ants could reach into the grid from anywhere, the rule would be a comment instead of a structure.

2. The grid is a flat slice, not a slice of slices. A 2D field is naturally [][]int, and Go allows that, but []int of length w*h with index y*w+x is one allocation instead of h+1, contiguous in memory so scans are cache-friendly, and copyable with a single copy call. That last property is what lets the viewer in Milestone 10 grab a whole frame cheaply.

3. Randomness is owned and seeded. A simulation that cannot be replayed cannot be debugged. The Sim holds its own random number generator seeded from config, so the same seed always produces the same run, and a bug you saw once you can see again. This is also how we will detect, in Milestone 4, that concurrency has quietly destroyed reproducibility.

Implementation

Create the module if you have not already:

mkdir -p antfarm/cmd/antfarm antfarm/internal/world antfarm/internal/sim
cd antfarm
go mod init github.com/yourname/antfarm

internal/world/grid.go

package world

import "fmt"

// Position is a cell coordinate on the grid.
type Position struct{ X, Y int }

func (p Position) String() string { return fmt.Sprintf("(%d,%d)", p.X, p.Y) }

// Add returns the position offset by d. It does not check bounds.
func (p Position) Add(d Position) Position { return Position{p.X + d.X, p.Y + d.Y} }

// Grid is a fixed-size two-dimensional field of ints held in one flat slice.
type Grid struct {
	W, H  int
	cells []int
}

func NewGrid(w, h int) *Grid {
	if w < 1 || h < 1 {
		panic("world: grid dimensions must be positive")
	}
	return &Grid{W: w, H: h, cells: make([]int, w*h)}
}

func (g *Grid) InBounds(p Position) bool {
	return p.X >= 0 && p.Y >= 0 && p.X < g.W && p.Y < g.H
}

func (g *Grid) At(p Position) int {
	if !g.InBounds(p) {
		return 0
	}
	return g.cells[p.Y*g.W+p.X]
}

func (g *Grid) Set(p Position, v int) {
	if !g.InBounds(p) {
		return
	}
	g.cells[p.Y*g.W+p.X] = v
}

// AddAt adds delta to the cell and returns the new value.
func (g *Grid) AddAt(p Position, delta int) int {
	if !g.InBounds(p) {
		return 0
	}
	i := p.Y*g.W + p.X
	g.cells[i] += delta
	return g.cells[i]
}

// Total sums every cell.
func (g *Grid) Total() int {
	sum := 0
	for _, v := range g.cells {
		sum += v
	}
	return sum
}

Explanation

Typical language vs Go

In Python or Java you would likely write a Grid class with a get/set pair and a subclass for special grids. Go gives you no subclassing, so the design pressure is toward one concrete struct with a small method set, and toward putting variation in a separate type that satisfies an interface. That constraint sounds limiting and mostly produces flatter, more readable code. Where it hurts is when you genuinely want polymorphic data rather than polymorphic behaviour, and Go's answer there (interfaces plus type switches) is clumsier than a sealed class hierarchy.

internal/world/world.go (first version)

package world

// World holds everything the colony shares: terrain, food and the nest.
type World struct {
	W, H int
	Nest Position
	Food *Grid
}

func New(w, h int) *World {
	return &World{
		W:    w,
		H:    h,
		Nest: Position{X: w / 2, Y: h / 2},
		Food: NewGrid(w, h),
	}
}

func (w *World) InBounds(p Position) bool { return w.Food.InBounds(p) }

// Clamp keeps a position inside the grid instead of rejecting it.
func (w *World) Clamp(p Position) Position {
	if p.X < 0 {
		p.X = 0
	}
	if p.Y < 0 {
		p.Y = 0
	}
	if p.X >= w.W {
		p.X = w.W - 1
	}
	if p.Y >= w.H {
		p.Y = w.H - 1
	}
	return p
}

Clamp takes a Position by value and returns a modified copy, so p.X = 0 inside the function is not visible to the caller. That is deliberate: a function that returns a new value is easier to reason about than one that mutates through a pointer, and for a 16-byte struct the copy is free.

The constructor is called New, not NewWorld, because it lives in package world and callers write world.New(64, 64). Repeating the package name (world.NewWorld) is a classic non-Go smell that linters complain about.

internal/sim/ant.go

package sim

import "github.com/yourname/antfarm/internal/world"

// Ant is a single agent. Its zero value is a valid ant sitting at the
// origin with nothing to do, which keeps construction simple.
type Ant struct {
	ID       int
	Pos      world.Position
	Carrying bool
	Energy   int
}

Note the import path: github.com/yourname/antfarm/internal/world, which is the module path plus the directory. The package is then referred to by its package name, world, which usually matches the last path segment but does not have to.

internal/sim/sim.go (first version)

package sim

import (
	"math/rand/v2"

	"github.com/yourname/antfarm/internal/world"
)

// Config is everything the simulation needs to start.
type Config struct {
	Width, Height int
	Seed          uint64
}

// Sim owns the world and the ant in it.
type Sim struct {
	cfg   Config
	rng   *rand.Rand
	world *world.World
	ant   *Ant
	tick  int
}

// New builds a simulation. The same seed always produces the same run.
func New(cfg Config) *Sim {
	if cfg.Width <= 0 {
		cfg.Width = 64
	}
	if cfg.Height <= 0 {
		cfg.Height = 64
	}

	rng := rand.New(rand.NewPCG(cfg.Seed, cfg.Seed^0x9E3779B97F4A7C15))
	w := world.New(cfg.Width, cfg.Height)

	return &Sim{
		cfg:   cfg,
		rng:   rng,
		world: w,
		ant:   &Ant{ID: 0, Pos: w.Nest, Energy: 1000},
	}
}

func (s *Sim) Ant() *Ant   { return s.ant }
func (s *Sim) Tick() int   { return s.tick }

// directions are the eight neighbours of a cell.
var directions = [8]world.Position{
	{X: -1, Y: -1}, {X: 0, Y: -1}, {X: 1, Y: -1},
	{X: -1, Y: 0}, {X: 1, Y: 0},
	{X: -1, Y: 1}, {X: 0, Y: 1}, {X: 1, Y: 1},
}

// Step advances the simulation by one tick.
func (s *Sim) Step() {
	d := directions[s.rng.IntN(len(directions))]
	s.ant.Pos = s.world.Clamp(s.ant.Pos.Add(d))
	s.ant.Energy--
	s.tick++
}

func (s *Sim) Run(n int) {
	for range n {
		s.Step()
	}
}

Explanation

cmd/antfarm/main.go

package main

import (
	"flag"
	"fmt"
	"os"
	"strconv"
	"strings"

	"github.com/yourname/antfarm/internal/sim"
)

func main() {
	var (
		gridFlag = flag.String("grid", "64x64", "grid size as WxH")
		ticks    = flag.Int("ticks", 10, "how many ticks to run")
		seed     = flag.Uint64("seed", 1, "random seed; the same seed replays the same run")
	)
	flag.Parse()

	w, h, err := parseGrid(*gridFlag)
	if err != nil {
		fmt.Fprintln(os.Stderr, "antfarm:", err)
		flag.Usage()
		os.Exit(2)
	}

	s := sim.New(sim.Config{Width: w, Height: h, Seed: *seed})
	fmt.Printf("world %dx%d, ant starts at %v\n", w, h, s.Ant().Pos)

	for t := 1; t <= *ticks; t++ {
		s.Step()
		fmt.Printf("tick %-4d pos %v energy %d\n", t, s.Ant().Pos, s.Ant().Energy)
	}
}

func parseGrid(s string) (int, int, error) {
	parts := strings.Split(strings.ToLower(s), "x")
	if len(parts) != 2 {
		return 0, 0, fmt.Errorf("bad -grid %q: want WxH, for example 128x128", s)
	}
	w, err := strconv.Atoi(parts[0])
	if err != nil {
		return 0, 0, fmt.Errorf("bad width in -grid %q: %w", s, err)
	}
	h, err := strconv.Atoi(parts[1])
	if err != nil {
		return 0, 0, fmt.Errorf("bad height in -grid %q: %w", s, err)
	}
	if w < 1 || h < 1 {
		return 0, 0, fmt.Errorf("bad -grid %q: dimensions must be positive", s)
	}
	return w, h, nil
}
$ go run ./cmd/antfarm -ticks 4 -grid 32x32
world 32x32, ant starts at (16,16)
tick 1    pos (15,15) energy 999
tick 2    pos (16,16) energy 998
tick 3    pos (16,15) energy 997
tick 4    pos (15,14) energy 996

Explanation of the CLI

The first tests

Create internal/world/grid_test.go:

package world

import "testing"

func TestGridSetAndAt(t *testing.T) {
	g := NewGrid(4, 3)
	g.Set(Position{2, 1}, 7)
	if got := g.At(Position{2, 1}); got != 7 {
		t.Errorf("At(2,1) = %d, want 7", got)
	}
}

func TestGridBounds(t *testing.T) {
	g := NewGrid(4, 3)
	cases := []struct {
		name string
		pos  Position
	}{
		{"negative x", Position{-1, 0}},
		{"negative y", Position{0, -1}},
		{"beyond width", Position{4, 0}},
		{"beyond height", Position{0, 3}},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := g.At(tc.pos); got != 0 {
				t.Errorf("At(%s) = %d, want 0", tc.pos, got)
			}
			g.Set(tc.pos, 99) // must not panic and must not corrupt anything
			if got := g.Total(); got != 0 {
				t.Errorf("after out-of-bounds Set, Total() = %d, want 0", got)
			}
		})
	}
}
$ go test ./...
ok  	github.com/yourname/antfarm/internal/world	0.001s

The second test is table-driven: the cases are data, the assertion logic appears once, and t.Run gives each case a name so a failure says TestGridBounds/beyond_width rather than "line 41". Adding a case is one line. This is the dominant Go testing style and you should reach for it by default.

Exercise 1

Write TestSameSeedSameRun in internal/sim/sim_test.go: build two simulations with identical config, run each for 500 ticks, and assert that both ants end in the same position with the same energy. Then write TestDifferentSeedDiffersSomewhere, which runs two different seeds and asserts the paths are not identical. Think about why the second test is slightly risky and how to make it robust.

Solution 1 — open after trying
package sim

import "testing"

func TestSameSeedSameRun(t *testing.T) {
	cfg := Config{Width: 32, Height: 32, Seed: 42}

	a := New(cfg)
	a.Run(500)

	b := New(cfg)
	b.Run(500)

	if a.Ant().Pos != b.Ant().Pos {
		t.Fatalf("same seed diverged: %v vs %v", a.Ant().Pos, b.Ant().Pos)
	}
	if a.Ant().Energy != b.Ant().Energy {
		t.Errorf("energy differs: %d vs %d", a.Ant().Energy, b.Ant().Energy)
	}
}

func TestDifferentSeedDiffersSomewhere(t *testing.T) {
	a := New(Config{Width: 32, Height: 32, Seed: 1})
	b := New(Config{Width: 32, Height: 32, Seed: 2})

	same := 0
	for range 500 {
		a.Step()
		b.Step()
		if a.Ant().Pos == b.Ant().Pos {
			same++
		}
	}
	if same == 500 {
		t.Fatal("two different seeds produced identical paths for 500 ticks")
	}
}

The risk in the second test: two random walks on a small clamped grid can legitimately meet at the same cell on any given tick, so comparing a single tick would be flaky. Comparing the whole path makes a false failure essentially impossible. Flaky tests are worse than no tests, and "is this assertion true for every possible random sequence, or just most?" is the question to ask every time you test randomised code.

Note also a.Ant().Pos != b.Ant().Pos: struct comparison with == works because Position contains only comparable fields. If it contained a slice, this would not compile.

Experiment

Change Clamp so that instead of stopping at the edge it wraps around (a torus): position -1 becomes W-1. Run 2000 ticks with both versions and print the final position. The clamped ant spends much of its life pinned to a wall, because a random walk that is blocked in two directions is biased toward the corner. The wrapped ant does not. This is your first taste of the simulation's dynamics being driven by a boundary condition rather than by the interesting logic, which is a recurring theme in agent-based modelling.

Common mistakes in Milestone 1
  • The Mewlang cat, giving a disapproving lookpackage sim; import "internal/world" — import paths are always absolute from the module root, never relative. It must be github.com/yourname/antfarm/internal/world.
  • cannot use s.ant (variable of type *Ant) as Ant value — you mixed pointer and value. Pick pointers for Ant and stay consistent; ants are mutable identities, not values.
  • Calling s.rng.IntN(0) when a slice is empty: it panics with "invalid argument to IntN". Guard the length.
  • Forgetting flag.Parse(). Every flag silently keeps its default and you lose twenty minutes.
  • Writing func (s Sim) Step() with a value receiver. It compiles, it runs, and nothing ever changes, because every call mutates a copy. If your state refuses to update, check the receiver first.

Checkpoint

  1. Why is cells unexported while W and H are exported?
  2. What would break if Sim used the package-level rand.IntN instead of its own *rand.Rand?
  3. Clamp takes and returns a Position by value. Where does the copy happen, and why is that cheaper than it sounds?
  4. Why does NewGrid panic on bad dimensions while At quietly returns 0 on a bad position?
  5. What does flag.Int return, and why is it not an int?
Why are we using this language here?

Milestone 1 is deliberately small, and Go does not do much heavy lifting yet: a struct, a constructor function, and flag parsing would look almost identical in any statically typed language. Two small things are worth noticing anyway. The flat-array grid ([]int of length w*h instead of [][]int) is a pattern Go nudges you toward because slices of slices are genuinely more expensive here — multiple allocations, worse cache behaviour — where a language with true multi-dimensional arrays (Fortran, or NumPy in Python) would give you the fast layout by default. And go test, go vet and a formatter being built into the toolchain from day one means this project has real tests before it has any real complexity to test, with no dependency to install.

The honest cost: Go's error handling is already visible in parseGrid (fmt.Errorf plus a manual check), and for a program this small that is more ceremony than a language with exceptions would need for the same behaviour. At this scale, Go's discipline is mostly overhead you are paying in advance for milestones that have not arrived yet.

Milestone 2A thousand ants, still sequential

Goal

Scale from one ant to arbitrarily many, scatter food around the world, and report statistics. Still single-threaded. We also establish a performance baseline, because in Milestone 4 we will want to know whether concurrency actually bought us anything.

Concepts

Slices of pointers, preallocation with make, default values in config, snapshot value types, benchmarks, and measuring before optimising.

Design

Two questions worth answering deliberately.

[]Ant or []*Ant? A slice of values keeps all ants contiguous, which is faster to iterate. A slice of pointers means each ant is a stable, independently addressable object. We choose pointers, for one reason that is about the future: from Milestone 4 each ant is owned by its own goroutine, and a goroutine holding an index into a slice that might be reallocated is a bug waiting to happen. Also, for _, a := range ants gives you a copy when the element is a value, so mutations would silently vanish. That trap alone has cost more Go programmer-hours than the cache locality is worth here.

What is a "statistic"? Counting carrying ants by walking the slice is O(n) per call, which is fine at 1,000 ants and wasteful at 50,000 when a viewer asks 60 times a second. We will do the naive thing now and fix it in Milestone 9 with counters, because doing it now would be optimising a system whose shape we do not yet know. Write down the fact that it is O(n), then move on.

Typical approach vs Go's idiomatic approach

In Python, Ruby or Java, "a list of ants" is unambiguous: list/ArrayList holds references to objects, full stop, and there is no other option to weigh. Iterating and mutating an element in place just works, because every element was a reference to begin with.

Go makes you choose, because []Ant and []*Ant are genuinely different types with different behaviour: a value slice copies on iteration and can be more cache-friendly, a pointer slice gives every ant a stable address that a goroutine can hold onto safely later. Neither is the "normal" choice the way a reference-typed list is in those other languages — you have to decide, and the decision has consequences four milestones from now. The cost of that freedom is exactly the bug in this milestone's Common mistakes box: for _, a := range ants { a.Energy-- } silently does nothing with []Ant, a mistake that cannot even be expressed in a language where lists always hold references.

Implementation

Replace Config, add defaults, and grow Sim:

// Config is everything the simulation needs to start. Zero values are not
// useful here, so New fills in defaults.
type Config struct {
	Width, Height int
	Ants          int
	FoodSources   int
	FoodPerSource int
	StartEnergy   int
	Seed          uint64
}

func (c *Config) applyDefaults() {
	if c.Width <= 0 {
		c.Width = 64
	}
	if c.Height <= 0 {
		c.Height = 64
	}
	if c.Ants <= 0 {
		c.Ants = 1
	}
	if c.FoodSources < 0 {
		c.FoodSources = 0
	}
	if c.FoodPerSource <= 0 {
		c.FoodPerSource = 50
	}
	if c.StartEnergy <= 0 {
		c.StartEnergy = 1000
	}
}

applyDefaults has a pointer receiver so it can modify the config, and it is unexported because it is an implementation detail. New takes Config by value and calls cfg.applyDefaults() on its own copy, so the caller's struct is untouched. This "zero value means default" pattern is everywhere in Go: it lets sim.New(sim.Config{Ants: 500}, ...) work without a builder or twelve constructor overloads, neither of which Go has.

type Sim struct {
	cfg   Config
	rng   *rand.Rand
	world *world.World
	ants  []*Ant

	tick int
}

func New(cfg Config) *Sim {
	cfg.applyDefaults()

	rng := rand.New(rand.NewPCG(cfg.Seed, cfg.Seed^0x9E3779B97F4A7C15))
	w := world.New(cfg.Width, cfg.Height)

	s := &Sim{cfg: cfg, rng: rng, world: w}
	s.scatterFood()
	s.spawnAnts()
	return s
}

func (s *Sim) World() *world.World { return s.world }
func (s *Sim) Ants() []*Ant        { return s.ants }

func (s *Sim) scatterFood() {
	for range s.cfg.FoodSources {
		p := world.Position{
			X: s.rng.IntN(s.cfg.Width),
			Y: s.rng.IntN(s.cfg.Height),
		}
		s.world.Food.AddAt(p, s.cfg.FoodPerSource)
	}
}

func (s *Sim) spawnAnts() {
	s.ants = make([]*Ant, 0, s.cfg.Ants) // one allocation, not s.cfg.Ants of them
	for i := range s.cfg.Ants {
		s.ants = append(s.ants, &Ant{
			ID:     i,
			Pos:    s.world.Nest,
			Energy: s.cfg.StartEnergy,
		})
	}
}

Explanation

Now a snapshot type and the tick loop over all ants:

// Stats is a snapshot of the colony, cheap to copy.
type Stats struct {
	Tick          int
	Ants          int
	Carrying      int
	Delivered     int
	FoodRemaining int
	FailedPickups int
}

func (s Stats) String() string {
	return fmt.Sprintf("tick %-6d ants %-6d carrying %-6d delivered %-6d food left %-6d",
		s.Tick, s.Ants, s.Carrying, s.Delivered, s.FoodRemaining)
}

// Stats walks every ant, so it is O(n). Milestone 9 makes it O(1).
func (s *Sim) Stats() Stats {
	carrying := 0
	for _, a := range s.ants {
		if a.Carrying {
			carrying++
		}
	}
	return Stats{
		Tick:          s.tick,
		Ants:          len(s.ants),
		Carrying:      carrying,
		Delivered:     s.world.Delivered(),
		FoodRemaining: s.world.Food.Total(),
		FailedPickups: s.failedPickups,
	}
}

Stats is a plain value with no pointers, and String() has a value receiver so that both Stats and *Stats print nicely. Being pointer-free is not an accident: in Milestone 10 we will send these over a channel to a viewer, and a value with no references is safe to hand to another goroutine with no further thought. Designing for that now costs nothing.

Delivered() and Deliver() are new on World; they arrive properly in Milestone 3 along with food handling. For now, add:

// in internal/world/world.go, inside the World struct:
//     delivered int

// Deliver records one unit of food arriving at the nest.
func (w *World) Deliver() { w.delivered++ }

// Delivered reports how much food has reached the nest.
func (w *World) Delivered() int { return w.delivered }

The baseline benchmark

Add this to internal/sim/sim_test.go:

func BenchmarkStep1000Ants(b *testing.B) {
	s := New(Config{Width: 128, Height: 128, Ants: 1000, FoodSources: 50, Seed: 1})
	b.ResetTimer()
	for range b.N {
		s.Step()
	}
}
$ go test -bench BenchmarkStep1000Ants -benchmem ./internal/sim/
goos: linux
goarch: amd64
cpu: Intel(R) Xeon(R) Processor @ 2.10GHz
BenchmarkStep1000Ants-1   	  102345	     11234 ns/op	       0 B/op	       0 allocs/op
Exercise 2

Add a method func (s *Sim) Census() map[world.Position]int returning how many ants occupy each cell, and a test asserting that the sum over the map equals the number of ants and that at tick 0 every ant is on the nest. Then write a second benchmark for Census with 10,000 ants, and think about why it allocates when Step does not.

Solution 2 — open after trying
func (s *Sim) Census() map[world.Position]int {
	m := make(map[world.Position]int, len(s.ants)/4) // a guess, to reduce rehashing
	for _, a := range s.ants {
		m[a.Pos]++
	}
	return m
}
func TestCensusCountsEveryAnt(t *testing.T) {
	s := New(Config{Width: 16, Height: 16, Ants: 250, Seed: 3})

	c := s.Census()
	if got := c[s.World().Nest]; got != 250 {
		t.Errorf("at tick 0, nest holds %d ants, want 250", got)
	}

	s.Run(100)
	total := 0
	for _, n := range s.Census() {
		total += n
	}
	if total != 250 {
		t.Errorf("census total = %d, want 250 (ants cannot vanish)", total)
	}
}

m[a.Pos]++ works on a missing key because the read returns the zero value 0, then increments and stores. No if key in m dance.

Why it allocates: a map must allocate buckets, and it grows as keys are added. Step allocates nothing because it only writes to existing memory. The capacity hint in make(map[K]V, n) reduces but does not eliminate this. A serious version would reuse one map across calls, or replace it with a []int the size of the grid, which is the same flat-array trick again and allocates once.

If you tried map[*Ant]int instead, note that pointers are comparable and usable as keys, but hashing addresses gives you an unstable iteration order and makes the map meaningless after a restart.

Experiment

Run BenchmarkStep1000Ants with -benchtime 3s to get a steadier number, then change spawnAnts to use make([]*Ant, 0) with no capacity and re-run with 100,000 ants, timing New instead of Step. Write a BenchmarkNew100k to make that measurable. You should see the allocation count in -benchmem jump from a handful to dozens, with a matching increase in bytes.

Common mistakes in Milestone 2
  • make([]*Ant, n) followed by append: you now have 2n entries, half of them nil, and the next loop panics with invalid memory address or nil pointer dereference.
  • Mutating during range over a value slice: for _, a := range ants { a.Energy-- } does nothing when ants is []Ant. With []*Ant it works, because you are copying the pointer, not the ant.
  • Assuming Census iterates in a stable order. Map iteration order is randomised; sort the keys if you print them.
  • Benchmarking with the timer running during setup, then concluding that your code is slow.
  • Letting the compiler delete your benchmark body because the result is unused. If a benchmark shows 0.3 ns/op, it was optimised away; assign the result to a package-level variable to prevent it.

Checkpoint

  1. Why does New take Config by value but applyDefaults take a pointer receiver?
  2. What is the difference in memory behaviour between make([]*Ant, 0, 1000) and make([]*Ant, 0) after 1000 appends?
  3. Why is Stats deliberately free of pointers?
  4. What does allocs/op tell you that ns/op does not?
  5. We chose []*Ant over []Ant. Give one performance cost and one correctness benefit of that choice.
Why are we using this language here?

This milestone's real content — scattering food, growing a slice, computing statistics — is language-agnostic. What Go contributes is testing.B and -benchmem built into the standard toolchain: no separate benchmarking library, no separate profiler to wire up, just go test -bench . -benchmem and a number. Getting to "zero allocations per op" for Step is realistic specifically because Go gives you value types, arrays, and preallocated slices as first-class, unremarkable choices, not an optimisation you reach for only under pressure.

The honest counterweight: none of this matters yet. At 1,000 ants a Python or Ruby version of the same sequential loop would run measurably slower but would still be fast enough to develop against and to teach from; the zero-allocation discipline pays for itself starting around Milestone 4, when the ant count and the concurrency both grow by orders of magnitude. Optimising a program before you know its real shape is a waste of a milestone, and this one is honest that the payoff is deferred.

Milestone 3Behaviour: forage, carry, return

Goal

Ants stop wandering aimlessly. They search for food, pick it up, carry it back to the nest, and deliver it. The strategy lives behind an interface, so we can add smarter ants later without touching the engine. Food handling introduces real error values.

Concepts

Interfaces and implicit satisfaction, enum-like constants with iota, a command/action value type, sentinel errors, custom error types, errors.Is and errors.As, and the distinction between expected failure and a bug.

Design

The state machine is small:

                 food on this cell
   ┌──────────┐ ─────────────────► ┌──────────┐
   │ SEARCHING │                    │ CARRYING │
   └──────────┘ ◄───────────────── └──────────┘
                  delivered at nest

   SEARCHING: random walk; if food here, pick it up
   CARRYING : step toward nest; if at nest, drop

The important design question is not the state machine, it is who is allowed to change the world. Two options:

Option A: the ant acts directly
    ant.Decide() { if food here { world.TakeFood(pos); a.Carrying = true } }

Option B: the ant returns a request; the engine applies it
    act := ant.Decide()         // pure: reads the world, returns a value
    err := sim.apply(ant, act)  // the only code that mutates

We take Option B, and this is the single most consequential decision in the whole project. Three reasons:

  1. Testability. Decide is a pure function of ant and world, so a table test can check "carrying, standing on the nest → drop" with no setup and no side effects.
  2. One mutation point. When we go concurrent, the set of places that write to shared state is one function rather than scattered through every strategy anyone ever writes.
  3. Actions become messages. An Action is a small pointer-free value. In Milestone 5 we send exactly this type down a channel, unchanged. Option A cannot be retrofitted that way.

Implementation

internal/sim/ant.go — actions

// ActionKind enumerates everything an ant can ask the world to do.
type ActionKind int

const (
	ActNone ActionKind = iota
	ActMove
	ActPickUp
	ActDrop
)

func (k ActionKind) String() string {
	switch k {
	case ActMove:
		return "move"
	case ActPickUp:
		return "pickup"
	case ActDrop:
		return "drop"
	default:
		return "none"
	}
}

// Action is a request from an ant to the world. It is a plain value with
// no pointers, which matters later: we will send these over channels.
type Action struct {
	Kind ActionKind
	Dir  world.Position // only meaningful for ActMove
}

internal/sim/behaviour.go

package sim

import (
	"math/rand/v2"

	"github.com/yourname/antfarm/internal/world"
)

// Behaviour decides what one ant does on one tick. Anything with these two
// methods can drive an ant: the simulation never mentions Forager by name.
type Behaviour interface {
	Name() string
	Decide(a *Ant, w *world.World, rng *rand.Rand) Action
}

// Forager is the baseline strategy: wander until you find food, then carry
// it home in a straight line.
type Forager struct{}

func (Forager) Name() string { return "forager" }

func (Forager) Decide(a *Ant, w *world.World, rng *rand.Rand) Action {
	if a.Carrying {
		if a.Pos == w.Nest {
			return Action{Kind: ActDrop}
		}
		return Action{Kind: ActMove, Dir: stepToward(a.Pos, w.Nest)}
	}
	if w.Food.At(a.Pos) > 0 {
		return Action{Kind: ActPickUp}
	}
	return Action{Kind: ActMove, Dir: randomDir(rng)}
}

var directions = [9]world.Position{
	{X: -1, Y: -1}, {X: 0, Y: -1}, {X: 1, Y: -1},
	{X: -1, Y: 0}, {X: 1, Y: 0},
	{X: -1, Y: 1}, {X: 0, Y: 1}, {X: 1, Y: 1},
	{X: 0, Y: 0},
}

func randomDir(rng *rand.Rand) world.Position {
	return directions[rng.IntN(8)]
}

// stepToward returns a one-cell step from src in the direction of dst.
func stepToward(src, dst world.Position) world.Position {
	return world.Position{X: sign(dst.X - src.X), Y: sign(dst.Y - src.Y)}
}

func sign(n int) int {
	switch {
	case n > 0:
		return 1
	case n < 0:
		return -1
	default:
		return 0
	}
}

Explanation

internal/world/world.go — food, and errors that mean something

var (
	// ErrNoFood means the cell had nothing to take.
	ErrNoFood = errors.New("no food at position")
	// ErrNotCarrying means the ant tried to drop food it does not have.
	ErrNotCarrying = errors.New("ant is not carrying food")
)

// OutOfBoundsError reports a position outside the grid.
type OutOfBoundsError struct {
	Pos  Position
	W, H int
}

func (e *OutOfBoundsError) Error() string {
	return fmt.Sprintf("position %s is outside the %dx%d grid", e.Pos, e.W, e.H)
}

// TakeFood removes one unit of food from p.
func (w *World) TakeFood(p Position) error {
	if !w.InBounds(p) {
		return &OutOfBoundsError{Pos: p, W: w.W, H: w.H}
	}
	if w.Food.At(p) <= 0 {
		return fmt.Errorf("take food at %s: %w", p, ErrNoFood)
	}
	w.Food.AddAt(p, -1)
	return nil
}

Two kinds of error, chosen for two different reasons:

KindWhen to use itHow callers check
Sentinel (ErrNoFood)The caller only needs to know which thing went wrongerrors.Is(err, ErrNoFood)
Custom type (*OutOfBoundsError)The caller needs details: which position, which griderrors.As(err, &oob)

fmt.Errorf("take food at %s: %w", p, ErrNoFood) produces the message take food at (3,4): no food at position while keeping ErrNoFood retrievable through the wrap chain. The convention for error strings is lowercase, no trailing punctuation, and context prefixed in the form operation: cause, so that wrapping composes into a readable trail.

The nil-interface trap, in the wild

This is the Go bug that catches everyone exactly once, and error handling is where it bites:

func take() *OutOfBoundsError {   // concrete pointer type, not error
	return nil
}

func caller() {
	var err error = take()        // err holds (type=*OutOfBoundsError, value=nil)
	if err != nil {
		// THIS RUNS. err is not nil, because the interface has a type.
	}
}

An interface value is a pair (type, value) and is nil only when both halves are nil. Assigning a nil *OutOfBoundsError to an error gives you a non-nil interface holding a nil pointer. The rule that avoids it entirely: functions that can fail return error, never a concrete error type. Our TakeFood returns error, which is why it is safe.

Typical approach vs Go's idiomatic approach

In Python, Ruby or Java, a missing-food pickup would typically be an exception: raise NoFoodError, caught somewhere up the call stack, with the normal and the failure paths visually separated by try/except. It reads well for the happy path, and it is easy to let an exception cross a boundary it should not — a caller three frames up catching, or silently swallowing, a failure it does not understand.

Go's idiomatic answer makes failure an ordinary return value: TakeFood returns error alongside its other results, every caller sees it at the call site, and the compiler does not stop you from ignoring it (though go vet and linters will nag). The cost is if err != nil after nearly every call, which reads as noise until you have debugged a system where a silent exception three services away turned out to be the whole incident. Neither approach is strictly better; Go trades brevity for the failure path always being visible in the code you are reading.

internal/sim/sim.go — apply and handle

// Step advances the whole colony by one tick, sequentially.
func (s *Sim) Step() {
	for _, a := range s.ants {
		act := s.behaviour.Decide(a, s.world, s.rng)
		if err := s.apply(a, act); err != nil {
			s.handle(a, err)
		}
	}
	s.tick++
}

// apply is the only code allowed to change the world.
func (s *Sim) apply(a *Ant, act Action) error {
	switch act.Kind {
	case ActMove:
		a.Pos = s.world.Clamp(a.Pos.Add(act.Dir))
		a.Energy--
	case ActPickUp:
		if err := s.world.TakeFood(a.Pos); err != nil {
			return fmt.Errorf("ant %d pickup: %w", a.ID, err)
		}
		a.Carrying = true
	case ActDrop:
		if !a.Carrying {
			return fmt.Errorf("ant %d drop: %w", a.ID, world.ErrNotCarrying)
		}
		a.Carrying = false
		s.world.Deliver()
	case ActNone:
		// nothing to do
	}
	return nil
}

// handle turns errors into simulation outcomes. Expected failures are
// counted; anything unexpected is worth shouting about.
func (s *Sim) handle(a *Ant, err error) {
	switch {
	case errors.Is(err, world.ErrNoFood):
		s.failedPickups++
	case errors.Is(err, world.ErrNotCarrying):
		s.failedPickups++
	default:
		panic(err) // milestone 8 replaces this with something civilised
	}
}

Add behaviour Behaviour and failedPickups int to the Sim struct, and change the constructor to func New(cfg Config, b Behaviour) *Sim with a if b == nil { b = Forager{} } default.

Explanation

Tests, including the one that matters

func TestForagerDecide(t *testing.T) {
	w := world.New(16, 16) // nest at (8,8)
	rng := rand.New(rand.NewPCG(1, 2))
	w.Food.Set(world.Position{X: 3, Y: 3}, 5)

	cases := []struct {
		name string
		ant  Ant
		want ActionKind
	}{
		{"empty cell, not carrying", Ant{Pos: world.Position{X: 1, Y: 1}}, ActMove},
		{"standing on food", Ant{Pos: world.Position{X: 3, Y: 3}}, ActPickUp},
		{"carrying, away from nest", Ant{Pos: world.Position{X: 3, Y: 3}, Carrying: true}, ActMove},
		{"carrying, at nest", Ant{Pos: w.Nest, Carrying: true}, ActDrop},
	}

	var b Behaviour = Forager{}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			ant := tc.ant
			got := b.Decide(&ant, w, rng)
			if got.Kind != tc.want {
				t.Errorf("Decide = %v, want %v", got.Kind, tc.want)
			}
		})
	}
}

func TestColonyDeliversFood(t *testing.T) {
	s := New(Config{Width: 24, Height: 24, Ants: 200, FoodSources: 20, Seed: 7}, Forager{})
	before := s.Stats().FoodRemaining
	s.Run(2000)
	st := s.Stats()

	if st.Delivered == 0 {
		t.Fatalf("after 2000 ticks nothing was delivered: %v", st)
	}
	if st.FoodRemaining >= before {
		t.Errorf("food remaining did not fall: before %d, after %d", before, st.FoodRemaining)
	}
	if st.Delivered+st.FoodRemaining+st.Carrying != before {
		t.Errorf("food is not conserved: delivered %d + left %d + carried %d != %d",
			st.Delivered, st.FoodRemaining, st.Carrying, before)
	}
}

TestColonyDeliversFood is the most valuable test in the project, and it is worth understanding why. It asserts a conservation law: every unit of food is either still on the ground, being carried, or delivered. Not a specific number, not a specific path, just an invariant that must hold no matter how the simulation evolves. Invariant tests survive refactoring, catch bugs you did not think of, and are the only practical way to test a system whose exact output is uninteresting. When we make the simulation concurrent, this test is what detects that we have broken it.

Note ant := tc.ant in the table test: it copies the case's ant so Decide cannot mutate the table. var b Behaviour = Forager{} deliberately stores the concrete type in an interface variable, so the test exercises the dynamic dispatch path rather than a direct call.

$ go test ./...
ok  	github.com/yourname/antfarm/internal/sim	0.007s
ok  	github.com/yourname/antfarm/internal/world	0.002s

$ go run ./cmd/antfarm -ants 500 -grid 48x48 -food 30 -ticks 600 -every 200
colony: 500 ants, 48x48 grid, 30 food sources, seed 1
tick 200    ants 500    carrying 21     delivered 459    food left 1020
tick 400    ants 500    carrying 18     delivered 727    food left 755
tick 600    ants 500    carrying 21     delivered 920    food left 559
final: tick 600    ants 500    carrying 21     delivered 920    food left 559

The Mewlang cat, raising a paw for a high-fiveThat is real output from the code above. The colony works: 920 units delivered, food dropping steadily, about 20 ants in transit at any moment.

Exercise 3

Write a second behaviour, Scout, that satisfies the same interface but moves in a straight line for several ticks before turning, which explores faster than a pure random walk. Requirements:

  • It must satisfy Behaviour without any change to Sim.
  • The per-ant heading has to live somewhere. Think carefully: Scout is shared by every ant, so a single field on the struct would make all ants turn together. Solve it without adding a field to Ant if you can, then consider whether adding one is actually cleaner.
  • Add a table test asserting that a scout carrying food still heads for the nest.
  • Add a -behaviour flag to main.go selecting forager or scout, returning a usage error for anything else.

Hint: a map keyed by ant ID inside Scout works and will become a data race in Milestone 4. That is a useful thing to experience, so try it, then read the solution's discussion.

Solution 3 — open after trying
// Scout walks in a straight line for a few ticks before choosing a new
// heading, which covers ground faster than an unbiased random walk.
type Scout struct {
	headings map[int]world.Position // ant ID -> current heading
	left     map[int]int            // ant ID -> ticks left on this heading
	runLen   int
}

func NewScout(runLen int) *Scout {
	if runLen <= 0 {
		runLen = 8
	}
	return &Scout{
		headings: make(map[int]world.Position),
		left:     make(map[int]int),
		runLen:   runLen,
	}
}

func (*Scout) Name() string { return "scout" }

func (s *Scout) Decide(a *Ant, w *world.World, rng *rand.Rand) Action {
	if a.Carrying {
		if a.Pos == w.Nest {
			return Action{Kind: ActDrop}
		}
		return Action{Kind: ActMove, Dir: stepToward(a.Pos, w.Nest)}
	}
	if w.Food.At(a.Pos) > 0 {
		return Action{Kind: ActPickUp}
	}

	if s.left[a.ID] <= 0 {
		s.headings[a.ID] = randomDir(rng)
		s.left[a.ID] = s.runLen
	}
	s.left[a.ID]--
	return Action{Kind: ActMove, Dir: s.headings[a.ID]}
}

In main.go:

func pickBehaviour(name string) (sim.Behaviour, error) {
	switch name {
	case "forager":
		return sim.Forager{}, nil
	case "scout":
		return sim.NewScout(8), nil
	default:
		return nil, fmt.Errorf("unknown -behaviour %q: want forager or scout", name)
	}
}

The discussion that matters. This solution stores per-ant state in maps owned by the strategy, and it is correct today and broken in Milestone 4, because concurrent map access is not just a logical race but a hard runtime crash: fatal error: concurrent map writes, which the race detector reports and which the runtime kills the process over on purpose.

Three honest alternatives, in increasing order of quality:

  1. Put a mutex in Scout. Works, and serialises every ant's decision through one lock, which defeats the point of concurrency.
  2. Replace the maps with slices indexed by ant ID. Distinct indices in a slice can be written concurrently without a race, as long as the slice is never resized. Fast, and slightly delicate.
  3. Put the state on the Ant. Add Heading world.Position and HeadingTTL int fields. Each ant is touched by one goroutine, so there is no sharing at all and no synchronisation needed.

Option 3 is right, and the reason is a principle worth carrying into every concurrent design: state belongs with the thing that has exclusive access to it. The urge to keep Ant "clean" by pushing strategy state elsewhere creates sharing where none needed to exist. If Behaviour implementations need lots of private per-ant state, the honest fix is a per-ant strategy instance rather than a shared one.

Experiment

Set FoodSources to 1 and Ants to 2,000 on a 128×128 grid, and watch the delivery rate. It is terrible, because a random walk almost never finds a single cell in 16,384. Now set the food source to a 5×5 block. The rate jumps. This is precisely the problem pheromones solve in Milestone 7: an ant that finds food leaves a trail, and other ants follow the gradient instead of searching blindly. Feel the problem now so the solution means something later.

Common mistakes in Milestone 3
  • cannot use Forager literal (type Forager) as type Behaviour: missing method Decide — usually a signature mismatch (you wrote *world.World as world.World, or forgot the rng parameter), or you defined the method on *Forager and are passing Forager{}. A pointer receiver means only *Forager satisfies the interface.
  • Comparing errors with == after wrapping with %w. It fails, because the wrapper is a different value. Use errors.Is.
  • Using %v instead of %w in fmt.Errorf and then wondering why errors.Is returns false. %v flattens the error to text and discards the chain.
  • Setting a.Carrying = true before checking whether TakeFood succeeded, which silently creates food out of nothing. The conservation test catches it; that is what it is for.
  • Returning *OutOfBoundsError instead of error from a helper, and hitting the nil-interface trap.

Checkpoint

  1. Forager never mentions Behaviour. How does the compiler know it satisfies the interface, and when does it check?
  2. Why does Decide return an Action rather than mutating the world directly? Give two reasons, one about testing and one about Milestone 5.
  3. What does %w do that %v does not?
  4. When would you define a custom error type instead of a sentinel value?
  5. Write down the conservation invariant that TestColonyDeliversFood checks. Why is it more useful than asserting "delivered == 920"?
  6. Why is the zero value of Action "do nothing" rather than "move by zero"?
Why are we using this language here?

The Mewlang cat, wearing glasses, looking confidentNothing in Milestones 1–3 needed Go. This is ordinary sequential code, and Python would have been shorter to write, with the interface replaced by duck typing and the errors by exceptions. Java or C# would be about the same length as Go with a more expressive type system behind them.

Two things Go gave us that will matter shortly. First, Action and Stats are pointer-free value types, which is a property the type system lets you see at a glance and which becomes the basis of safe message passing. In Python every object is a reference and "is this safe to hand to another thread" is never answerable locally. Second, the benchmark showing zero allocations per tick is not achievable at all in a language where every small object is heap-allocated, and at 50,000 ants it is the difference between a smooth simulation and one that stutters under garbage collection.

The honest verdict on this milestone: Go was fine, not special. The next one is where it earns its place.

Milestone 4Each ant becomes a goroutine, and everything breaks

Goal

Give every ant its own goroutine. Observe the program become non-deterministic, then observe the race detector explain exactly why. Fix it with a mutex, measure the fix, and discover that the fix is unsatisfying. This milestone is deliberately a failure, and it is the centre of the course.

Concepts

Goroutine lifecycle, sync.WaitGroup, the Go memory model and happens-before, what a data race actually is, reading race detector output, mutex-based mutual exclusion, lock contention, and the difference between concurrency and parallelism.

Design

The change is small and the consequences are enormous:

BEFORE                             AFTER
──────                             ─────
for tick := range n {              for each ant:
    for _, ant := range ants {         go func() {
        act := decide(ant)                 for tick := range n {
        apply(ant, act)                        act := decide(ant)
    }                                          apply(ant, act)
}                                          }
                                       }()

one goroutine                      N goroutines
one thing happens at a time        N things happen at once
ticks are globally synchronised    every ant has its own clock

Notice what we lost without asking: the global tick. Sequentially, "tick 400" is a meaningful instant in which every ant has moved exactly 400 times. Concurrently, one ant may be on its 380th step while another is on its 420th. Whether that matters depends on what you are simulating, and it is the kind of thing that should be a decision rather than an accident. We are accepting it for now; Milestone 6 introduces a shared clock signal for the parts that need one.

Implementation: the wrong version, on purpose

New file internal/sim/concurrent.go:

package sim

import (
	"math/rand/v2"
	"sync"
)

// RunConcurrentUnsafe gives every ant its own goroutine and lets them all
// touch the same world. It is wrong on purpose: milestone 4 uses it to
// produce a real data race.
func (s *Sim) RunConcurrentUnsafe(ticks int) {
	var wg sync.WaitGroup

	for _, a := range s.ants {
		wg.Add(1)
		go func() {
			defer wg.Done()
			rng := rand.New(rand.NewPCG(s.cfg.Seed, uint64(a.ID)))
			for range ticks {
				act := s.behaviour.Decide(a, s.world, rng)
				if err := s.apply(a, act); err != nil {
					s.handle(a, err)
				}
			}
		}()
	}

	wg.Wait()
	s.tick += ticks
}

Explanation

Watching it break

First, determinism. Run the same seed three times:

run 0: start 2000 | delivered 1025 + left 938 + carried 37 = 2000
run 0: start 2000 | delivered 1032 + left 927 + carried 41 = 2000
run 0: start 2000 | delivered 1033 + left 930 + carried 37 = 2000

Same configuration, same seed, three different answers. The sequential version gives the identical number every time. We did not change any logic, and we lost reproducibility, because the interleaving of goroutines is decided by the scheduler and the operating system, not by our seed. Any bug that depends on interleaving is now a bug you cannot reliably reproduce, which is the defining misery of concurrent programming.

Second, the race detector. This is the part to pay attention to:

$ go test -race -run TestConcurrentUnsafeRace ./internal/sim/
==================
WARNING: DATA RACE
Read at 0x00c0000be940 by goroutine 8:
  github.com/yourname/antfarm/internal/world.(*Grid).At()
      /home/you/antfarm/internal/world/grid.go:34 +0x2c4
  github.com/yourname/antfarm/internal/sim.Forager.Decide()
      /home/you/antfarm/internal/sim/behaviour.go:29 +0x19a
  github.com/yourname/antfarm/internal/sim.(*Sim).RunConcurrentUnsafe.func1()
      /home/you/antfarm/internal/sim/concurrent.go:20 +0x233

Previous write at 0x00c0000be940 by goroutine 56:
  github.com/yourname/antfarm/internal/world.(*Grid).AddAt()
      /home/you/antfarm/internal/world/grid.go:50 +0x471
  github.com/yourname/antfarm/internal/world.(*World).TakeFood()
      /home/you/antfarm/internal/world/world.go:70 +0x145
  github.com/yourname/antfarm/internal/sim.(*Sim).apply()
      /home/you/antfarm/internal/sim/sim.go:128 +0x447
  github.com/yourname/antfarm/internal/sim.(*Sim).RunConcurrentUnsafe.func1()
      /home/you/antfarm/internal/sim/concurrent.go:21 +0x24e

Goroutine 8 (running) created at:
  github.com/yourname/antfarm/internal/sim.(*Sim).RunConcurrentUnsafe()
      /home/you/antfarm/internal/sim/concurrent.go:16 +0xa8
==================
WARNING: DATA RACE
Read at 0x00c00007e448 by goroutine 8:
  github.com/yourname/antfarm/internal/world.(*World).Deliver()
      /home/you/antfarm/internal/world/world.go:75 +0x28f
...

That is genuine output from this code. Read it as four facts:

  1. An address. 0x00c0000be940 is one specific memory location. The first report is a cell in the food grid; the second is the delivered counter.
  2. What just happened to it. A read, from Grid.At, called by Forager.Decide, called by the goroutine started at concurrent.go:16.
  3. What previously happened to it. A write, from Grid.AddAt, called by World.TakeFood, called by Sim.apply, in a different goroutine.
  4. Where each goroutine was created. The last block gives you the birth stack, which is how you identify which of 50,000 goroutines is involved.

What a data race actually is

Precisely: two goroutines access the same memory location, at least one access is a write, and there is no synchronisation event ordering them. The Go memory model defines a happens-before relation, and operations not ordered by it may be observed in any order, or not at all.

Concretely, s.delivered++ is three machine operations: load, add, store. Two goroutines can interleave as:

goroutine A          goroutine B          delivered
─────────────────────────────────────────────────────
load  → 100                                   100
                     load  → 100              100
add   → 101                                   100
                     add   → 101              100
store 101                                     101
                     store 101                101   ← one delivery lost

The Mewlang cat, wide-eyed with surpriseTwo ants delivered food; the counter says one. Nothing crashed and no test failed, unless you wrote the conservation test. That is why the invariant test exists.

It gets worse than lost updates. Without synchronisation, the compiler and the CPU are both permitted to reorder and cache your reads and writes, so a value written by one goroutine may never become visible to another, or may become visible in a different order than it was written. A data race is not a timing inconvenience, it is undefined behaviour, and "it works on my machine" is not evidence of anything.

Three things to know about the race detector
  • It has no false positives. If it reports a race, there is a race. Fix it; do not argue with it.
  • It has plenty of false negatives. It only detects races on code paths that actually executed and actually interleaved during that run. A clean -race run is evidence, not proof. Run it under load, under tests, repeatedly, with -count=10.
  • It costs 2–20× CPU and 5–10× memory. Use it in tests and development, not in production, and expect a race-enabled run of 50,000 ants to be uncomfortable. Scale down when hunting races.

Make go test -race ./... the command you run before every commit. In CI, run it always.

The fix, version one: one big lock

Add a mutex to Sim, next to the state it protects, with a comment saying what it protects:

type Sim struct {
	cfg       Config
	rng       *rand.Rand
	world     *world.World
	ants      []*Ant
	behaviour Behaviour

	// mu guards world, every Ant, tick and failedPickups whenever the
	// simulation is run concurrently. Milestone 5 deletes it.
	mu            sync.Mutex
	tick          int
	failedPickups int
}
// RunConcurrentLocked is the same design with one big lock around every
// access to shared state. It is correct, and it is a bottleneck.
func (s *Sim) RunConcurrentLocked(ticks int) {
	var wg sync.WaitGroup

	for _, a := range s.ants {
		wg.Add(1)
		go func() {
			defer wg.Done()
			rng := rand.New(rand.NewPCG(s.cfg.Seed, uint64(a.ID)))
			for range ticks {
				s.mu.Lock()
				act := s.behaviour.Decide(a, s.world, rng)
				if err := s.apply(a, act); err != nil {
					s.handle(a, err)
				}
				s.mu.Unlock()
			}
		}()
	}

	wg.Wait()
	s.tick += ticks
}

The lock covers Decide as well as apply, because Decide reads the food grid and another goroutine could be writing it. A lock that protects writes but not reads protects nothing.

There is no defer s.mu.Unlock() here, and that is a considered choice: defer runs at function exit, and this lock is taken and released inside a loop, so a deferred unlock would hold the lock for the entire run and deadlock every other ant. When the critical section is smaller than the function, unlock explicitly, and keep the section short enough that you can see both ends at once.

$ go test -race -run TestConcurrentLocked -count=1 ./internal/sim/
ok  	github.com/yourname/antfarm/internal/sim	1.068s

Clean. Food is conserved, the race detector is silent, the tests pass.

Measuring the fix

func BenchmarkSequential300(b *testing.B) {
	for range b.N {
		s := New(Config{Width: 64, Height: 64, Ants: 300, FoodSources: 30, Seed: 1}, Forager{})
		s.Run(200)
	}
}

func BenchmarkLocked300(b *testing.B) {
	for range b.N {
		s := New(Config{Width: 64, Height: 64, Ants: 300, FoodSources: 30, Seed: 1}, Forager{})
		s.RunConcurrentLocked(200)
	}
}
BenchmarkSequential300 	     5	    741998 ns/op
BenchmarkLocked300     	     5	   1303957 ns/op

Measured on a single-core machine, so read it as a lower bound on the damage: the concurrent version is 1.76× slower than the sequential one. We added 300 goroutines, a mutex, and a great deal of conceptual complexity, and made the program worse.

On your multi-core machine the numbers will differ, and you should run it, but the shape holds. Here is why:

The obvious next thought is finer-grained locking: a mutex per grid cell, or per region. Consider what that buys and costs before Milestone 5 shows a different answer. A 512×512 grid is 262,144 mutexes at 8 bytes each, which is fine on memory but means an ant reading its eight neighbours must take eight locks, in a consistent global order, or risk deadlock. Every new feature has to obey that ordering forever. This is exactly the design where "it worked until we added pheromone diffusion" comes from.

Why are we using this language here?

This milestone is where Go starts to pay. The race detector is the crucial piece: it turned an invisible, non-deterministic, undefined-behaviour bug into a precise report naming two stack traces and an address. Getting the same information out of C++ requires ThreadSanitizer and effort; out of Java, tooling that most people never run; out of Python, nothing at all, because the global interpreter lock hides most races until it does not.

Honest counterpoint, as promised. Rust would have refused to compile RunConcurrentUnsafe at all: sharing a &mut World across threads is a compile error, so the bug we just spent a milestone on would never have existed. That is a real, significant advantage, and anyone who tells you Go's approach is strictly better is selling something. Go's position is a trade: catch races at run time with excellent tooling, in exchange for a language you can learn in a week. Whether that trade is right depends on your team and your problem. For learning concurrency, actually experiencing the race is worth more than being prevented from writing it.

Exercise 4

Three tasks, increasing in difficulty.

  1. Make Stats() safe. It reads every ant and the world with no lock, so calling it while RunConcurrentLocked is running is a data race. Fix it, and write a test that spawns a goroutine calling Stats() in a loop during a concurrent run, then run it with -race to prove the fix.
  2. Add a -mode flag to main.go taking seq or locked, so you can compare the two at the command line.
  3. Find the contention. Run the locked benchmark with -mutexprofile and read the result with go tool pprof. Report which lock is hot and what fraction of time is spent blocked. Command: go test -bench BenchmarkLocked300 -mutexprofile mu.out ./internal/sim/ then go tool pprof -top mu.out.
Solution 4 — open after trying

1. Locking Stats.

func (s *Sim) Stats() Stats {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.statsLocked()
}

// statsLocked must be called with s.mu held.
func (s *Sim) statsLocked() Stats {
	carrying := 0
	for _, a := range s.ants {
		if a.Carrying {
			carrying++
		}
	}
	return Stats{
		Tick:          s.tick,
		Ants:          len(s.ants),
		Carrying:      carrying,
		Delivered:     s.world.Delivered(),
		FoodRemaining: s.world.Food.Total(),
		FailedPickups: s.failedPickups,
	}
}

The split into Stats and statsLocked is the standard Go answer to a real problem: Go's mutexes are not reentrant. If Stats() took the lock and some other locked method called Stats(), the goroutine would deadlock against itself, instantly and permanently. The convention is that a method suffixed Locked assumes the lock is already held and is called only from code that holds it. Write that assumption in a comment every time; the compiler cannot check it.

Here defer is right, because the critical section is the whole function.

The test:

func TestStatsDuringConcurrentRun(t *testing.T) {
	s := New(Config{Width: 32, Height: 32, Ants: 100, FoodSources: 10, Seed: 9}, Forager{})

	done := make(chan struct{})
	go func() {
		defer close(done)
		for range 500 {
			_ = s.Stats()
		}
	}()

	s.RunConcurrentLocked(200)
	<-done
}

It asserts nothing, and that is fine: under -race the detector is the assertion. Tests whose only job is to create an interleaving for the detector to inspect are a legitimate and underused technique.

2. The flag.

mode := flag.String("mode", "seq", "seq or locked")
// ...
switch *mode {
case "seq":
	s.Run(*ticks)
case "locked":
	s.RunConcurrentLocked(*ticks)
default:
	fmt.Fprintf(os.Stderr, "antfarm: unknown -mode %q: want seq or locked\n", *mode)
	os.Exit(2)
}

3. The mutex profile. Mutex profiling is off by default because it costs something; enable it with runtime.SetMutexProfileFraction(1) in a TestMain, or pass -mutexprofile, which enables it for you. The output ranks contended locks by cumulative blocked time, and you should see sim.(*Sim).RunConcurrentLocked holding essentially all of it. There is only one lock to blame, which is the clearest possible statement of the problem: a single lock is a single point of serialisation.

Look at -blockprofile too, which shows time spent blocked on any synchronisation, including channels. It will become useful from Milestone 5 onward.

Experiment

Run the locked benchmark with GOMAXPROCS set to 1, 2, 4 and 8:

GOMAXPROCS=1 go test -bench BenchmarkLocked300 ./internal/sim/
GOMAXPROCS=8 go test -bench BenchmarkLocked300 ./internal/sim/

A program that parallelises well gets faster as you add processors. This one probably gets slower, because more processors means more goroutines simultaneously failing to acquire the same lock. Then do the same for BenchmarkSequential300, which should be flat, since it uses one goroutine regardless. Seeing a concurrent program slow down under added parallelism is the clearest possible evidence that the bottleneck is contention rather than computation, and it is a diagnostic you can apply to real systems.

Common mistakes in Milestone 4
  • fatal error: all goroutines are asleep - deadlock! — the runtime detected that nothing can ever make progress. Usual causes: wg.Add called more times than wg.Done, or a lock taken twice by the same goroutine, or defer mu.Unlock() inside a loop.
  • sync: negative WaitGroup counter — Done called more often than Add, typically from a stray defer wg.Done() in a helper that also has one.
  • fatal error: concurrent map writes — not a race warning but a deliberate runtime abort. Somewhere a map is written from two goroutines. This is the Scout trap from Exercise 3.
  • Copying a struct that contains a mutex: s2 := *s gives s2 its own lock, and the two now protect nothing. go vet catches this with "passes lock by value".
  • Calling a method that locks from a method that already holds the lock. Go's mutexes are not reentrant and you deadlock instantly.
  • Assuming -race passing means the code is correct. It means no race was observed on the paths that ran.
  • Running go run with 50,000 ants under -race and concluding Go is slow. Race instrumentation is the cost; measure without it.

Checkpoint

  1. Define a data race precisely. Why is "the ++ isn't atomic" an incomplete explanation?
  2. The race report names two goroutines. What are the four pieces of information in it, and which one tells you which ant is involved?
  3. Why must the lock cover Decide and not only apply?
  4. Why is defer s.mu.Unlock() wrong inside the per-tick loop but right inside Stats()?
  5. The locked version is slower than the sequential one. Give two distinct reasons.
  6. What is the difference between concurrency and parallelism, in terms of this program?
  7. Why does a clean -race run not prove the absence of races?
  8. Sketch the deadlock you could create with one mutex per grid cell.

Where this leaves us

We have three implementations and none of them is what we want:

VersionCorrectDeterministicParallelExtensible
Sequentialyesyesnoyes
Unsafe concurrentnonoyes, and wrongno
Locked concurrentyesnono (serialised)fragile

The problem is not the lock. The problem is the shape: many goroutines reaching into one shared mutable world. Locking is the patch that shape forces on you, and every feature we add (pheromones, chaos, metrics, a viewer) makes the patch bigger and more fragile.

Milestone 5 changes the shape. Exactly one goroutine will own the world, and ants will send it Action values on a channel and receive replies. No mutex anywhere in the simulation package. That is what "share memory by communicating" means in practice, and after this milestone you will have felt why it is more than a slogan.

Repository state after Milestone 4

antfarm/
├── go.mod
├── cmd/
│   └── antfarm/
│       └── main.go              flags, wiring, output
└── internal/
    ├── sim/
    │   ├── ant.go               Ant, Action, ActionKind
    │   ├── behaviour.go         Behaviour, Forager, direction helpers
    │   ├── sim.go               Config, Sim, Step, apply, handle, Stats
    │   ├── concurrent.go        RunConcurrentUnsafe, RunConcurrentLocked
    │   ├── sim_test.go          decide tables, determinism, conservation, benchmarks
    │   └── concurrent_test.go   race reproduction, locked conservation, benchmarks
    └── world/
        ├── grid.go              Position, Grid
        ├── world.go             World, food, errors
        └── grid_test.go         bounds, food errors
$ gofmt -l .        # prints nothing: everything is formatted
$ go vet ./...      # prints nothing: no suspicious constructs
$ go test ./...     # all green
$ go test -race ./... -run 'Locked|Stats'   # all green
$ git commit -am "milestone 4: concurrency, races, and a lock we regret"

The Mewlang cat, stretching contentedlyInstalment 2 of the five-course curriculum. Next: Milestones 5–8, where the world becomes a single owning goroutine, pheromones arrive with a shared clock, context gives us clean shutdown, and we start deliberately crashing things.

Continue