Milestones 1–4

Course 1 · Part 0Go: what are we building?

The final result

A program called antfarm. At the end of the course you will be able to run:

$ antfarm run --ants 50000 --grid 512x512 --food 400 \
      --chaos crash=0.001,drop=0.02,slow=0.005 \
      --metrics :9090 --view web:8080

colony: 50000 ants, 512x512 grid, 400 food sources
tick 1200 | alive 49863 | carrying 8214 | food delivered 31902
           | restarts 137 | dropped msgs 4411 | p99 decide 412µs

while a browser tab shows the grid with pheromone trails intensifying along routes the colony has discovered, a Prometheus-style metrics endpoint exposes counters, and go tool pprof can attach to the running process and tell you where the CPU is going. In the final milestone the colony runs split across several operating-system processes that talk over TCP, and you can kill one of them and watch the rest continue.

Why this project is interesting

The Mewlang cat, looking up curiouslyAnt colonies are the canonical example of emergent behaviour: no ant knows where the food is, no ant is in charge, and yet the colony reliably finds short paths to food. The algorithm behind it is real (ant colony optimisation is a genuine technique for routing and scheduling problems), and it happens to be an almost perfect Go exercise, because the natural implementation is thousands of independent activities exchanging small messages.

It is also a trap, in a useful way. The obvious first implementation puts the world in a shared data structure and lets every ant touch it. That works until you add concurrency, at which point it corrupts itself in ways that only appear under load. Milestone 4 walks you into that bug deliberately, and Milestone 5 walks you out of it using the idiom Go actually recommends. That sequence is the single most valuable thing in this course.

Why Go in particular

Why are we using this language here?

The Mewlang cat, wearing glasses, looking confidentHonestly: Erlang would be better at the fault-tolerance half of this project, and you will see why in Course 4. Erlang gives you supervision, isolated heaps, and true preemption; in Go, a panicking goroutine kills the whole process unless you catch it, and a goroutine stuck in a tight loop cannot be cancelled by force. In Milestone 8 you will hand-build a supervisor that Erlang would have given you.

What Go wins on is the combination: it is fast, statically typed, trivially deployable, has excellent profiling, and its concurrency is cheap enough for this scale while remaining familiar enough that you can be productive in a week. Python's asyncio could express the structure but would be roughly two orders of magnitude slower at 50,000 agents; Rust would be faster and safer but would spend your attention on the borrow checker instead of on concurrency design; Java's virtual threads (Project Loom) are now genuinely comparable and would be a fair alternative.

What is genuinely Go-specific here

Worth separating, so you know what transfers:

Transfers to other languagesSpecific to Go
The idea that concurrency is a design problem about ownershipselect with multiple channel cases and a default
Backpressure, bounded queues, load sheddingChannel closing semantics and the "close means done" convention
Supervision and restart strategiescontext.Context as a cancellation value threaded through call chains
Profiling methodologyImplicit interface satisfaction and small interfaces
Chaos testingdefer/recover, and the culture of returning errors rather than throwing

Architecture we are building toward

                          ┌──────────────────────────┐
                          │        cmd/antfarm       │  flags, config, wiring
                          └────────────┬─────────────┘
                                       │
        ┌──────────────────────────────┼──────────────────────────────┐
        │                              │                              │
┌───────▼────────┐            ┌────────▼─────────┐          ┌─────────▼────────┐
│   sim engine   │            │   observability  │          │      viewer      │
│                │            │                  │          │                  │
│  world owner   │◄─ queries ─┤  metrics, pprof, │          │  terminal / web  │
│   goroutine    │            │  event log       │          │   (read-only)    │
└───┬────────┬───┘            └──────────────────┘          └─────────▲────────┘
    │        │                                                       │
    │        └───────────── snapshots (channel, buffered) ───────────┘
    │
    │  requests: Move, Sense, PickUp, Drop, Deposit
    │  replies:  per-request reply channel
    │
┌───▼──────────────────────────────────────────────────────────┐
│   ant goroutines  (N = 1..50,000)                            │
│                                                              │
│   ant 1 ──┐                                                  │
│   ant 2 ──┤                                                  │
│    ...    ├──► requests channel ──► world owner ──► replies  │
│   ant N ──┘                                                  │
└───┬──────────────────────────────────────────────────────────┘
    │
┌───▼───────────┐     ┌────────────────┐     ┌──────────────────┐
│  supervisor   │     │  pheromone     │     │  chaos injector  │
│  (restarts    │     │  evaporator    │     │  (crash, drop,   │
│   dead ants)  │     │  (ticker)      │     │   delay)         │
└───────────────┘     └────────────────┘     └──────────────────┘

Do not worry about understanding this yet. It is here so that when Milestone 5 introduces "the world owner goroutine" you can see where it fits. The important structural idea, which you will earn rather than be told, is that exactly one goroutine owns the world state, and everyone else asks it questions.

What you will know afterwards

You will be able to explain, from having done it: why a mutex-per-cell design deadlocks and a single-owner design does not; what the race detector actually detects and what it misses; why an unbuffered channel send is a synchronisation point; how to cancel 50,000 goroutines in under a millisecond; how to tell an allocation problem from a contention problem in a profile; and what "backpressure" means in code rather than in a blog post.


Course 1 · Part 1Install and first program

What the Go toolchain is

Go is a compiled, statically typed language with a garbage collector. "Compiled" means a program is translated ahead of time into machine code for your CPU and operating system, producing a single executable file with no runtime to install alongside it. "Statically typed" means the type of every variable is known at compile time and mismatches are compile errors. "Garbage collected" means you do not free memory by hand.

Unusually, almost everything you need is one command, go. There is no separate package manager (no npm, no pip), no separate build system (no Make required), no separate formatter, no separate test runner. This is deliberate and it is one of the pleasant parts of the language.

TaskCommandEquivalent elsewhere
rungo run ./cmd/antfarmpython main.py
buildgo build ./...make, cargo build
dependenciesgo get, go mod tidypip install, npm install
testgo test ./...pytest, jest
formatgofmt -w .black, prettier
lint (built in)go vet ./...flake8, eslint
docsgo doc fmt.Printlnhelp(), man
profilego tool pprofcProfile plus a viewer

Installing

The Mewlang cat, typing on a laptopYou want Go 1.22 or newer for this course, because we use a few features introduced there (ranging over integers, and the corrected loop-variable semantics). At the time of writing the current release is in the 1.25/1.26 range; my knowledge of releases stops in mid-2026, so check go.dev/dl for what is current and prefer the newest stable version.

macOS
# Option A: Homebrew
brew install go

# Option B: official package
# download the .pkg from https://go.dev/dl/ and double-click it

go version    # should print something like: go version go1.25.1 darwin/arm64
Linux

Distribution packages are often a year or more out of date, so install from the tarball:

# adjust the version and architecture to match what go.dev/dl offers
curl -LO https://go.dev/dl/go1.25.1.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.25.1.linux-amd64.tar.gz

# add to ~/.bashrc or ~/.zshrc, then restart the shell
export PATH=$PATH:/usr/local/go/bin
export PATH=$PATH:$(go env GOPATH)/bin

go version

The rm -rf /usr/local/go matters: the official instructions require removing the old installation rather than untarring over it, because leftover files from a previous version cause confusing errors.

Windows
:: Option A (PowerShell or cmd)
winget install --id GoLang.Go

:: Option B: download the .msi from https://go.dev/dl/ and run it

go version

The installer edits PATH for you, but you must open a new terminal window afterwards for that to take effect. If you plan to do the Perl and Erlang courses too, consider installing WSL2 (wsl --install in an administrator PowerShell) and doing everything inside Linux instead; the Go experience is good either way, but the other two courses are noticeably smoother on Linux.

Checking the installation
go version        # the compiler version
go env GOPATH     # where downloaded modules and installed binaries live
go env GOROOT     # where Go itself is installed (you rarely touch this)

GOPATH defaults to ~/go on macOS and Linux and %USERPROFILE%\go on Windows. If you read an old tutorial saying your code must live inside GOPATH: that has not been true since 2019. With modules, your code lives wherever you like, and GOPATH is just a cache plus a bin directory.

Creating the project

mkdir antfarm
cd antfarm
go mod init github.com/yourname/antfarm

That last command creates a file called go.mod:

module github.com/yourname/antfarm

go 1.25

Three things to understand here.

A module is a unit of versioning and dependency. It is roughly "one repository". The go.mod file records the module's own name, the minimum Go version, and every dependency with its exact version.

The module path is an identity, not a download instruction. github.com/yourname/antfarm is how other code refers to your packages. It looks like a URL because if you ever publish it, Go will fetch it from there. You do not need a GitHub repository to use this name, and nothing contacts the network because of it. Use your real username if you plan to publish; otherwise example.com/antfarm is fine.

There is no lockfile step. When you add a dependency, Go writes it to go.mod and records cryptographic hashes in go.sum. Both are committed to git.

Directory structure

Go has no enforced project layout, but there is a strong convention that you should follow because every Go programmer reads it instantly:

antfarm/
├── go.mod                  module definition
├── go.sum                  dependency hashes (appears when you add deps)
├── cmd/
│   └── antfarm/
│       └── main.go         the executable: flags, wiring, nothing clever
├── internal/               importable only by this module
│   ├── sim/                the simulation engine
│   ├── world/             the grid, food, pheromones
│   └── metrics/
├── pkg/                    (optional) code you intend others to import
└── README.md

Two rules worth knowing now:

Editor setup

Use gopls, the official Go language server. It gives completion, jump-to-definition, inline errors, and automatic import management.

Turn on format-on-save now rather than later. Go has exactly one formatting style, produced by gofmt, and nobody argues about it. Indentation is tabs, which surprises people coming from Python or JavaScript. Let the tool do it.

Hello, colony

Create cmd/antfarm/main.go:

package main

import "fmt"

func main() {
	fmt.Println("Hello, colony.")
}

Run it:

$ go run ./cmd/antfarm
Hello, colony.

Every line, explained

package main — every Go file starts by declaring which package it belongs to. The name main is special: a package named main that contains a function named main is compiled into an executable program. Every other package name produces a library. Note that the package name here is unrelated to the directory name antfarm; the directory determines the import path, the package line determines the name. For main packages the directory name becomes the binary's name.

import "fmt" — brings in the standard library package fmt (short for "format"), which handles printing and string formatting. The quoted string is an import path. For standard library packages it is just the name; for external ones it is the full module path, for example "github.com/spf13/cobra".

func main() { — declares a function called main taking no parameters and returning nothing. Go requires the opening brace on the same line as the declaration. This is not a style preference; the compiler inserts semicolons at line ends, so a brace on the next line produces a syntax error. In a main package, this function is where execution starts, and when it returns the program exits.

fmt.Println("Hello, colony.") — calls the Println function from the fmt package. The capital P is load-bearing: in Go, an identifier that starts with an uppercase letter is exported (visible to other packages), and lowercase means package-private. There is no public or private keyword. Println prints its arguments and appends a newline.

Different from what you are used to
  • Visibility is encoded in capitalisation, not keywords. Renaming a function from Start to start changes its visibility.
  • An unused import is a compile error, not a warning. So is an unused local variable. This feels hostile for about a day and then you stop noticing, because your editor removes them automatically.
  • There are no semicolons in the source, but there are semicolons in the grammar; the lexer inserts them. That is why brace placement is not negotiable.

Building, testing, documenting

# compile and run in one step (binary goes to a temp dir)
go run ./cmd/antfarm

# compile to a named file
go build -o bin/antfarm ./cmd/antfarm
./bin/antfarm

# compile and install into $(go env GOPATH)/bin, so it is on your PATH
go install ./cmd/antfarm

# format every file in the tree
gofmt -w .

# the built-in correctness checker: finds real bugs, not style nits
go vet ./...

# run all tests in all packages
go test ./...

# read documentation without a browser
go doc fmt.Println
go doc -all strings | less

The ./... pattern means "this directory and every package beneath it". You will type it constantly.

For documentation, pkg.go.dev hosts rendered docs for the standard library and for every public module. go doc gives you the same content offline. go help and go help mod explain the toolchain itself.

Exercise 1.1

Modify the program so that it prints three lines: the Go version it was built with, the name of the operating system, and the number of CPU cores available. Then build it as a binary named antfarm in a bin/ directory and run that binary.

Hints: the runtime package has Version(), GOOS, and NumCPU(). Use go doc runtime to explore. Note that GOOS is a constant, not a function.

Solution 1.1 — open after trying
package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println("go version:", runtime.Version())
	fmt.Println("os:        ", runtime.GOOS)
	fmt.Println("cpus:      ", runtime.NumCPU())
}
$ go build -o bin/antfarm ./cmd/antfarm
$ ./bin/antfarm
go version: go1.25.1
os:         darwin
cpus:       10

The grouped import ( ... ) form is what gofmt produces once you have more than one import. runtime.NumCPU() is the number of cores visible to the process, which becomes relevant in Milestone 11 when we talk about parallelism versus concurrency.

Common first-day errors
  • The Mewlang cat, giving an unimpressed side-eyego: cannot find main module — you are not inside a directory containing go.mod, or below one. Run go mod init.
  • imported and not used: "runtime" — remove the import, or use it. Not a warning.
  • declared and not used: x — same idea for local variables. Assigning to _ silences it deliberately: _ = x.
  • syntax error: unexpected newline, expecting { after ... — you put the opening brace on its own line.
  • package antfarm is not a main package — you wrote package antfarm in a file you are trying to run. Executables must say package main.
  • go: command not found after installing — your PATH does not include Go's bin directory, or you did not restart the terminal.

Checkpoint

  1. What makes a package produce an executable rather than a library?
  2. Where does the name github.com/yourname/antfarm come from and what is it used for?
  3. Why can code outside your module never import yourmodule/internal/sim?
  4. Your colleague's file has an import they are not using. Will it compile?
  5. What is the difference between go build, go run, and go install?

Course 1 · Part 2Language crash course

This part teaches only what the ant colony needs. Go is a small language and this is most of it, but I am deliberately skipping generics (until Milestone 11), reflection, struct tags, and embedding beyond one mention. Each concept follows the same rhythm: the idea, a small example, a line-by-line reading, and the mistakes people make. Exercises appear after every group of related concepts, with the solution folded away underneath.

Work through this with a scratch project open. Make a directory scratch, run go mod init scratch, and put experiments in main.go. Type the examples rather than reading them.

2.1 Variables, types, and zero values

The idea

Every variable has a type fixed at compile time. You can write the type explicitly, or let Go infer it from the initial value. A variable declared without a value is not undefined or null; it holds its type's zero value, which is a real, usable value.

package main

import "fmt"

func main() {
	var ticks int = 10          // explicit type, explicit value
	var grid = "512x512"        // type inferred: string
	var alive bool              // no value: zero value, which is false
	name := "worker-7"          // short declaration, only inside functions

	const maxAnts = 50000       // compile-time constant

	fmt.Println(ticks, grid, alive, name, maxAnts)
}
Line by line
Typical language vs Go

In Python or JavaScript a "not yet set" variable is a distinct state you must check for (None, undefined). In Go the zero value is designed to be useful: a zero sync.Mutex is an unlocked mutex, a zero bytes.Buffer is an empty buffer ready to write to, a nil slice behaves like an empty slice for len, range, and append. When you design your own structs, you should aim for the same property. Our Ant struct will be designed so that its zero value is a valid, if boring, ant.

Mistakes
  • Using := at package level. It does not work there; use var.
  • no new variables on left side of := — you used := when every variable already exists. Use =.
  • Shadowing: inside an if block, err := f() creates a new err that disappears at the closing brace, leaving the outer one untouched. This is the single most common Go bug. go vet catches some cases; the shadow analyser catches more.
  • Mixing numeric types. Go does not implicitly convert. var a int = 1; var b int64 = a is an error; you must write int64(a).
Exercise 2.1

Write a function zeroReport() string that declares four local variables — int, string, bool, and []int — without assigning any of them a value, and returns one formatted string showing all four zero values. Then explain why the slice's zero value prints as [] rather than crashing the program the way an uninitialised pointer would in C.

Solution 2.1 — open after trying
func zeroReport() string {
	var n int
	var s string
	var b bool
	var xs []int
	return fmt.Sprintf("%d %q %v %v", n, s, b, xs)
}
// "0 \"\" false []"

A nil slice is a valid, usable value: len(xs) is 0, ranging over it does nothing, and %v prints it as [], exactly like an empty slice. There is no separate "uninitialised" state to crash on, because a slice's zero value is a real three-word struct (pointer, length, capacity) with the pointer set to nil and the other two fields 0 — reading it is always safe, only writing through it would panic, and xs here is never written to.

2.2 Functions, multiple returns, and defer

The idea

Functions can return more than one value, and this is how Go handles errors: a function returns its result and an error side by side. defer schedules a call to run when the surrounding function returns, no matter how it returns.

func divide(a, b int) (int, error) {
	if b == 0 {
		return 0, fmt.Errorf("divide %d by zero", a)
	}
	return a / b, nil
}

func report(name string) {
	defer fmt.Println("done:", name)   // runs last, whatever happens
	fmt.Println("start:", name)
	if name == "" {
		return                          // the deferred call still runs
	}
	fmt.Println("working:", name)
}
Line by line
Mistakes
  • Deferring inside a loop. The calls pile up until the function returns, so a loop over 10,000 files holds 10,000 open handles. Move the body into its own function, or call Close explicitly.
  • Expecting deferred arguments to be evaluated late. defer fmt.Println(i) in a loop prints the value of i at the moment of the defer, not at return time. Wrap in a closure if you want late evaluation: defer func(){ fmt.Println(i) }().
  • Ignoring the error return. value, _ := divide(1, 0) compiles happily and gives you nonsense. errcheck and most linters flag this.
Exercise 2.2

Write firstAndRest(xs []int) (first int, rest []int, ok bool) that reports the first element and a slice of the remaining ones, with ok false and the other two results left at their zero values for an empty slice. Then write logged(name string, f func()) that prints "start: "+name, calls f, and is guaranteed to print "done: "+name afterwards — using defer, not a plain call written after f().

Solution 2.2 — open after trying
func firstAndRest(xs []int) (first int, rest []int, ok bool) {
	if len(xs) == 0 {
		return 0, nil, false
	}
	return xs[0], xs[1:], true
}

func logged(name string, f func()) {
	defer fmt.Println("done:", name)
	fmt.Println("start:", name)
	f()
}

Writing fmt.Println("done:", name) as a plain statement after f() looks identical when f behaves. It stops being identical the moment f panics: a plain statement after f() never runs, because the panic skips straight past it, while the deferred call still fires during the unwind. "Runs no matter how the function returns" includes the paths you did not write a test for.

2.3 Control flow: if, for, switch

The idea

Go has one loop keyword, for, which covers every loop shape. if can declare a variable scoped to the statement. switch does not fall through by default.

// classic three-part loop
for i := 0; i < 5; i++ {
	fmt.Println(i)
}

// while loop
n := 10
for n > 0 {
	n--
}

// infinite loop
for {
	break
}

// range over a slice: index and value
ants := []string{"a1", "a2", "a3"}
for i, name := range ants {
	fmt.Println(i, name)
}

// range over an integer (Go 1.22+)
for i := range 3 {
	fmt.Println(i)     // 0 1 2
}

// if with an initialiser: err exists only inside the if/else
if q, err := divide(10, 2); err != nil {
	fmt.Println("failed:", err)
} else {
	fmt.Println("got", q)
}

// switch on a value, no break needed
switch state {
case "searching":
	fmt.Println("looking for food")
case "carrying", "returning":     // multiple values in one case
	fmt.Println("heading home")
default:
	fmt.Println("idle")
}

// switch with no expression: a tidy if/else chain
switch {
case energy < 10:
	fmt.Println("starving")
case energy < 50:
	fmt.Println("hungry")
default:
	fmt.Println("fine")
}
Notes on the pieces
Mistakes
  • Writing while. It does not exist.
  • Assuming range over a map has a stable order. It is deliberately randomised, to stop you depending on it. Collect and sort the keys if order matters.
  • Modifying the loop variable expecting it to affect the collection. for _, a := range ants { a.Energy = 0 } mutates a copy when the element is a struct value. Use for i := range ants { ants[i].Energy = 0 }.
Exercise 2.A

Write a function summary(values []int) (min int, max int, err error) that returns the smallest and largest values in a slice, and an error if the slice is empty. Then write a main that calls it twice, once with data and once with an empty slice, printing both outcomes. Do not use any library beyond fmt and errors.

Solution 2.A — open after trying
package main

import (
	"errors"
	"fmt"
)

var ErrEmpty = errors.New("summary: empty slice")

func summary(values []int) (min int, max int, err error) {
	if len(values) == 0 {
		return 0, 0, ErrEmpty
	}
	min, max = values[0], values[0]
	for _, v := range values[1:] {
		if v < min {
			min = v
		}
		if v > max {
			max = v
		}
	}
	return min, max, nil
}

func main() {
	if lo, hi, err := summary([]int{4, 9, 1, 7}); err != nil {
		fmt.Println("error:", err)
	} else {
		fmt.Println("min", lo, "max", hi)
	}

	if _, _, err := summary(nil); err != nil {
		fmt.Println("error:", err)   // error: summary: empty slice
	}
}

Three things worth noticing. The return values are named in the signature, which documents them and lets you write a bare return; use this sparingly, since bare returns in long functions are hard to read. values[1:] is a slice expression meaning "from index 1 to the end". And summary(nil) works because a nil slice has length zero, which is the zero-value-is-useful principle in action.

2.4 Slices

The idea

A slice is Go's growable list. Under the hood it is a three-word value: a pointer to an underlying array, a length, and a capacity. Understanding that is not optional, because it explains the behaviour that surprises everyone.

positions := []int{10, 20, 30}      // literal, len 3, cap 3
positions = append(positions, 40)   // append returns a NEW slice value
fmt.Println(len(positions), cap(positions))

grid := make([]int, 0, 1000)        // len 0, capacity 1000: no reallocation
                                    // for the first 1000 appends

view := positions[1:3]              // len 2, shares memory with positions
view[0] = 99                        // this modifies positions[1] too!
fmt.Println(positions)              // [10 99 30 40]
Line by line
Typical language vs Go

Python's list[1:3] copies. Go's slice[1:3] aliases. That aliasing is the point: it makes subslicing free, which matters when you are scanning large buffers. It also means that passing a slice to a function lets that function modify your elements, even though the slice header itself is passed by value. "Slices are references" is the common shorthand, but the precise version is "slices are values containing a pointer", and the distinction shows up when a function appends to a slice you passed in.

Mistakes
  • Discarding append's result. Always s = append(s, x).
  • Keeping a small slice of a huge array alive and wondering why memory never drops. The whole backing array stays reachable. slices.Clone breaks the link.
  • Appending to a shared slice from two goroutines. This is a data race, and Milestone 4 will show you exactly what it looks like.
  • Confusing len and cap. len is how many elements exist; cap is how many fit before reallocation.

2.5 Maps

The idea

A hash table with typed keys and values. Reading a missing key gives the zero value rather than an error, and there is a special two-value form to tell "missing" from "present but zero".

pheromone := make(map[string]float64)
pheromone["12,7"] = 0.8

v := pheromone["99,99"]              // 0, no error, no panic
v, ok := pheromone["99,99"]          // v = 0, ok = false

if strength, ok := pheromone["12,7"]; ok {
	fmt.Println("found", strength)
}

delete(pheromone, "12,7")
fmt.Println(len(pheromone))

for cell, strength := range pheromone {   // order is randomised on purpose
	fmt.Println(cell, strength)
}
Notes
Exercise 2.5

Write countByFirstLetter(words []string) map[byte]int that counts how many words start with each byte, skipping empty strings. Then print the result in a stable order — maps do not range in a stable order, so collect the keys into a slice and sort it first.

Solution 2.5 — open after trying
func countByFirstLetter(words []string) map[byte]int {
	counts := make(map[byte]int)
	for _, w := range words {
		if len(w) == 0 {
			continue
		}
		counts[w[0]]++
	}
	return counts
}

func printSorted(counts map[byte]int) {
	keys := make([]byte, 0, len(counts))
	for k := range counts {
		keys = append(keys, k)
	}
	sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
	for _, k := range keys {
		fmt.Printf("%c: %d\n", k, counts[k])
	}
}

counts[w[0]]++ works with no prior check, because reading a missing key returns the zero value and ++ then writes 1. Sorting the keys before printing is not paranoia: for k := range counts is deliberately randomised by the runtime specifically so that code cannot come to depend on an order that was never promised.

2.6 Structs, pointers, and methods

The idea

A struct is a fixed collection of named fields. A pointer holds the address of a value. A method is a function with a receiver, which is the value it is attached to. Go has no classes and no inheritance.

type Position struct {
	X, Y int
}

type Ant struct {
	ID       int
	Pos      Position
	Energy   int
	Carrying bool
}

// value receiver: operates on a copy
func (a Ant) Describe() string {
	return fmt.Sprintf("ant %d at (%d,%d)", a.ID, a.Pos.X, a.Pos.Y)
}

// pointer receiver: can modify the original
func (a *Ant) Move(dx, dy int) {
	a.Pos.X += dx
	a.Pos.Y += dy
	a.Energy--
}

func main() {
	a := Ant{ID: 1, Pos: Position{X: 5, Y: 5}, Energy: 100}
	a.Move(1, 0)                 // Go automatically takes &a
	fmt.Println(a.Describe())    // ant 1 at (6,5)

	p := &a                      // p is a *Ant
	p.Move(0, 1)                 // no need to write (*p).Move
	fmt.Println(a.Pos)           // {6 6} — the original changed
}
Line by line

The rule for choosing a receiver: use a pointer receiver if the method modifies the receiver, or if the struct is large enough that copying it matters, or if any other method on the type needs a pointer. Mixing value and pointer receivers on one type is a smell. For Ant, which we will mutate constantly, everything will be a pointer receiver.

Typical language vs Go

There is no class, no constructor, no inheritance, no this. The conventional replacement for a constructor is a plain function named New or NewAnt that returns a value or pointer. The replacement for inheritance is embedding (putting one struct inside another without a field name, which promotes its methods) plus interfaces. Go's designers left inheritance out on purpose, and after a week you stop reaching for it.

2.7 Interfaces

The idea

An interface is a set of method signatures. A type satisfies an interface simply by having those methods. There is no implements keyword and no declaration of intent, which means you can define an interface after the fact, in the package that consumes it.

type Behaviour interface {
	Decide(a *Ant, w *World) Action
	Name() string
}

type Forager struct{}

func (Forager) Name() string { return "forager" }
func (Forager) Decide(a *Ant, w *World) Action {
	if a.Carrying {
		return Action{Kind: ReturnHome}
	}
	return Action{Kind: SearchFood}
}

// anything with Decide and Name can be passed here
func step(b Behaviour, a *Ant, w *World) {
	act := b.Decide(a, w)
	w.Apply(a, act)
}
Notes
Typical language vs Go

In Java or C# an interface is a contract the implementer signs up to in advance, so the library author must anticipate your needs. In Go, the consumer defines the interface it needs, and existing types satisfy it retroactively. The cultural consequence is that idiomatic Go interfaces are tiny, often one method (io.Reader, io.Writer, error), and the proverb is "accept interfaces, return structs". We will follow that: our simulation accepts a Behaviour, and our constructors return concrete types.

Exercise 2.B

Define a Grid struct holding width, height, and a one-dimensional []int of cells. Give it a constructor NewGrid(w, h int) *Grid, an At(x, y int) int method, and a Set(x, y, v int) method. Cells outside the grid should be treated as 0 for At and ignored for Set rather than panicking. Explain to yourself why the cells are a flat slice instead of a slice of slices.

Solution 2.B — open after trying
type Grid struct {
	W, H  int
	cells []int
}

func NewGrid(w, h int) *Grid {
	return &Grid{W: w, H: h, cells: make([]int, w*h)}
}

func (g *Grid) inBounds(x, y int) bool {
	return x >= 0 && y >= 0 && x < g.W && y < g.H
}

func (g *Grid) At(x, y int) int {
	if !g.inBounds(x, y) {
		return 0
	}
	return g.cells[y*g.W+x]
}

func (g *Grid) Set(x, y, v int) {
	if !g.inBounds(x, y) {
		return
	}
	g.cells[y*g.W+x] = v
}

Why flat: one allocation instead of h+1, contiguous memory so scanning a row is cache-friendly, and a single copy can snapshot the whole grid, which we will need when the viewer asks for a frame. cells is lowercase so that nothing outside the package can index it without bounds checking. inBounds is lowercase for the same reason: it is an internal helper.

2.8 Errors

The idea

An error is a value implementing one method. You return it, you check it, you wrap it with context as it travels up. Go has panics too, but they are for programmer mistakes and truly unrecoverable situations, not for control flow.

// the entire definition, from the standard library:
// type error interface { Error() string }

var ErrNoFood = errors.New("no food available")     // a sentinel error

func (w *World) TakeFood(p Position) (int, error) {
	amount := w.food.At(p.X, p.Y)
	if amount == 0 {
		return 0, fmt.Errorf("take at (%d,%d): %w", p.X, p.Y, ErrNoFood)
	}
	w.food.Set(p.X, p.Y, amount-1)
	return 1, nil
}

func forage(w *World, p Position) {
	got, err := w.TakeFood(p)
	if errors.Is(err, ErrNoFood) {      // unwraps through %w
		// expected: move elsewhere
		return
	}
	if err != nil {
		log.Printf("unexpected: %v", err)
		return
	}
	_ = got
}

// a custom error type, when callers need structured detail
type OutOfBounds struct{ X, Y int }

func (e *OutOfBounds) Error() string {
	return fmt.Sprintf("position (%d,%d) is outside the grid", e.X, e.Y)
}

// retrieving it:
var oob *OutOfBounds
if errors.As(err, &oob) {
	fmt.Println("bad x was", oob.X)
}
Notes
Typical language vs Go

Exceptions are invisible in a function signature and travel silently through every frame. Go's errors are ordinary values, so the signature tells you what can fail, and the if err != nil blocks are explicit. The cost is verbosity, and it is real: around a third of the lines in a typical Go program are error handling. The benefit is that for a long-running simulation you can see every failure path in the code rather than discovering it in production. This is a genuine trade-off, not a clear win, and Rust's ? operator arguably gets a better deal.

2.9 Goroutines

The idea

Put go in front of a function call and it runs concurrently. A goroutine is not an operating-system thread; it is a much cheaper thing that the Go runtime multiplexes onto a small pool of threads.

func main() {
	go fmt.Println("from a goroutine")     // may never print!
	fmt.Println("from main")
	time.Sleep(10 * time.Millisecond)      // crude, but proves the point
}

Two facts to absorb immediately:

time.Sleep is never the answer. The real tool for "wait for a group of goroutines" is sync.WaitGroup:

var wg sync.WaitGroup

for i := range 5 {
	wg.Add(1)                    // register one pending goroutine, BEFORE go
	go func() {
		defer wg.Done()          // signal completion, even on panic
		fmt.Println("ant", i, "reporting")
	}()
}

wg.Wait()                        // blocks until the counter reaches zero
fmt.Println("all ants reported")
Notes
Threads vs goroutines
Traditional threads                 Goroutines
───────────────────                 ──────────
~1-8 MB stack reserved each         ~2 KB stack, grows on demand
created by the OS, expensive        created by the runtime, ~1µs
scheduled by the OS kernel          scheduled by the Go runtime onto
                                      GOMAXPROCS OS threads
blocking syscall blocks a thread    runtime moves other goroutines to
                                      another thread automatically
10,000 is a lot                     1,000,000 is fine
you coordinate with locks           you coordinate with channels
                                      (locks also available)

This is why "one goroutine per ant" is a sane architecture and "one thread per ant" is not. It is also why Go can afford a blocking programming style: a goroutine waiting on a channel or a network read costs almost nothing, so you write straight-line code instead of callbacks or async/await colouring.

Exercise 2.9

Launch 10 goroutines that each increment a shared atomic.Int64 1,000 times, wait for all of them with a WaitGroup, and print the final total. Run it several times and confirm it is exactly 10,000 every time. Then explain why a plain var counter int with counter++ in the same loop would not reliably give you 10,000.

Solution 2.9 — open after trying
var counter atomic.Int64
var wg sync.WaitGroup

for i := 0; i < 10; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		for j := 0; j < 1000; j++ {
			counter.Add(1)
		}
	}()
}

wg.Wait()
fmt.Println(counter.Load())   // always 10000

counter.Add(1) is a single indivisible hardware operation, so 10,000 of them from however many goroutines always land exactly once each. counter++ on a plain int is not one operation; it is read, increment, write, and two goroutines can both read the same value before either writes back, so one increment is silently lost. The bug does not show up every run — it depends on the scheduler interleaving two increments at exactly the wrong moment — which is what makes unsynchronised counters so easy to ship and so hard to diagnose from a bug report that says "the number is sometimes wrong".

2.10 Channels

The idea

A channel is a typed pipe that also synchronises. One goroutine sends, another receives, and the channel handles the handover safely. The slogan is: do not communicate by sharing memory; share memory by communicating.

ch := make(chan int)            // unbuffered
buf := make(chan int, 100)      // buffered, holds 100 before blocking

go func() {
	ch <- 42                    // send: blocks until someone receives
}()

v := <-ch                       // receive: blocks until someone sends
fmt.Println(v)                  // 42

close(ch)                       // no more values will be sent
v, ok := <-ch                   // ok is false once drained and closed

// range over a channel until it is closed
results := make(chan string, 3)
go func() {
	defer close(results)        // the sender closes, always
	results <- "found food"
	results <- "laid pheromone"
}()
for msg := range results {
	fmt.Println(msg)
}
Semantics you must know
Operationnil channelopen, empty/fullclosed
sendblocks foreverblocks until spacepanics
receiveblocks foreverblocks until valuereturns zero value immediately, ok false
closepanicssucceedspanics
Exercise 2.10

Write sum(nums []int) int that splits nums into chunks of 4, launches one goroutine per chunk to add up its slice and send the partial sum on a shared buffered channel, then receives exactly as many values as there are chunks and adds them up. Do not use a WaitGroup. Explain why counting channel receives is enough to know you are done.

Solution 2.10 — open after trying
func sum(nums []int) int {
	const chunkSize = 4
	partial := make(chan int, (len(nums)+chunkSize-1)/chunkSize)

	chunks := 0
	for i := 0; i < len(nums); i += chunkSize {
		end := min(i+chunkSize, len(nums))
		chunks++
		go func(chunk []int) {
			s := 0
			for _, v := range chunk {
				s += v
			}
			partial <- s
		}(nums[i:end])
	}

	total := 0
	for i := 0; i < chunks; i++ {
		total += <-partial
	}
	return total
}

A WaitGroup answers "have all the goroutines finished?"; here the question is narrower — "have I received one value from each of them?" — and a channel already answers that on its own: each goroutine sends exactly one value, so receiving chunks times is receiving every value that will ever be sent, no more bookkeeping required. A WaitGroup earns its place when goroutines do work you care about after their last send, or send zero or multiple values; when "one value per goroutine" is the whole contract, counting receives is simpler and just as correct.

2.11 select

The idea

select waits on several channel operations at once and proceeds with whichever is ready. It is the control structure that makes channels composable.

func worker(jobs <-chan int, done <-chan struct{}) {
	timeout := time.After(5 * time.Second)

	for {
		select {
		case job, ok := <-jobs:
			if !ok {
				fmt.Println("jobs channel closed")
				return
			}
			fmt.Println("working on", job)

		case <-done:
			fmt.Println("asked to stop")
			return

		case <-timeout:
			fmt.Println("took too long")
			return
		}
	}
}

// non-blocking variants
select {
case v := <-ch:
	fmt.Println("got", v)
default:
	fmt.Println("nothing waiting")     // never blocks
}

select {
case out <- value:
	// sent
default:
	// receiver is busy: drop the value rather than block.
	// This is load shedding, and it is a real strategy.
}
Notes
Exercise 2.11

Write firstOf(a, b <-chan int) int that returns whichever of two channels produces a value first. Use select with no default. Then explain what would go wrong if you added a default case to that select.

Solution 2.11 — open after trying
func firstOf(a, b <-chan int) int {
	select {
	case v := <-a:
		return v
	case v := <-b:
		return v
	}
}

With no default, select blocks until a or b has a value, which is exactly "whichever comes first". Adding default turns this into a non-blocking poll: if neither channel happens to be ready on this exact pass, the default case runs immediately and firstOf has nothing to return, breaking the function's contract entirely. default is for "check right now and move on", not for "wait for the first of several things", and mixing the two up is a common source of code that seems to work in testing and then returns garbage the moment either channel is even a microsecond slower to produce a value.

2.12 Mutexes and atomics

The idea

Channels are not always the right answer. When several goroutines genuinely need to read and write one piece of shared state, a mutex is simpler and faster. Go provides both and expects you to choose.

type Counters struct {
	mu       sync.Mutex
	delivered int
	byAnt    map[int]int
}

func (c *Counters) Record(antID int) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.delivered++
	c.byAnt[antID]++
}

// for a single number, an atomic is cheaper than a mutex
var ticks atomic.Int64

func tick() {
	ticks.Add(1)
}

func report() int64 {
	return ticks.Load()
}
Notes
Channels or mutexes?

The Go community's rule of thumb, from the standard library's own comments: use channels for passing ownership of data and coordinating the flow of work; use mutexes for protecting shared state with simple invariants, especially caches and counters. A mutex around a counter is clear and fast. A channel used as a lock is clever and slow. Our project uses channels for ant-to-world communication (ownership handover) and mutexes and atomics for the metrics package (shared counters). Both, deliberately.

Exercise 2.12

Add an Errors int field to Counters, protected by the same mutex, and a method IncError() that increments it. Then write a second version of Record, called RecordUnsafe, that locks and unlocks manually — c.mu.Lock(), the two increments, c.mu.Unlock() — with no defer at all. Explain the one situation in which RecordUnsafe is a real bug that the defer version would not have been.

Solution 2.12 — open after trying
type Counters struct {
	mu        sync.Mutex
	delivered int
	byAnt     map[int]int
	Errors    int
}

func (c *Counters) IncError() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.Errors++
}

func (c *Counters) RecordUnsafe(antID int) {
	c.mu.Lock()
	c.delivered++
	c.byAnt[antID]++
	c.mu.Unlock()
}

As written, RecordUnsafe behaves identically to Record. The difference appears the moment something between Lock and the manual Unlock panics — c.byAnt[antID]++ on a nil map, say, if the caller forgot to initialise byAnt. The panic skips straight past the manual c.mu.Unlock(), the mutex stays locked forever, and every future caller of any method that locks c.mu blocks permanently — a single panic anywhere in the critical section turns into a total, silent deadlock of the whole Counters. defer c.mu.Unlock() runs during the panic's unwind regardless, so the mutex is released even though the program still crashes. This is exactly why "lock, defer unlock" as the very next line is the standing rule rather than a style preference.

2.13 context

The idea

context.Context carries a cancellation signal and a deadline down through a call tree. Every long-running operation in modern Go accepts one as its first parameter. It is the standard answer to "how do I stop 50,000 goroutines at once".

func run(ctx context.Context, id int) error {
	ticker := time.NewTicker(100 * time.Millisecond)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():                  // closed when cancelled
			return ctx.Err()                // context.Canceled or DeadlineExceeded
		case <-ticker.C:
			// do one unit of work
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()                          // always; releases resources

	for i := range 1000 {
		go run(ctx, i)
	}

	time.Sleep(time.Second)
	cancel()                                // all 1000 goroutines see this
}
Notes
Exercise 2.13

Write afterN(ctx context.Context, n int, work func()) error that calls work() up to n times, sleeping 10 ms between calls, and returns ctx.Err() the moment ctx is cancelled instead of making the remaining calls. It returns nil if all n calls complete first. Call it with a context that times out after 25 ms and n set to 10, and confirm work runs only two or three times, not ten.

Solution 2.13 — open after trying
func afterN(ctx context.Context, n int, work func()) error {
	for i := 0; i < n; i++ {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}
		work()
		if i < n-1 {
			select {
			case <-ctx.Done():
				return ctx.Err()
			case <-time.After(10 * time.Millisecond):
			}
		}
	}
	return nil
}

With a 25 ms timeout and a 10 ms gap between calls, work fires immediately (0 ms), again at 10 ms, again at 20 ms, and the wait before a fourth call would land at 30 ms — past the deadline — so the select on ctx.Done() wins instead and afterN returns context.DeadlineExceeded having called work two or three times depending on exact scheduling, never all ten. The check at the top of the loop matters too: without it, a context already cancelled before the first call would still let one call through, which is usually not what "cancelled" is supposed to mean.

2.14 Testing

The idea

Testing is in the standard library and requires no dependencies. A test is a function named TestXxx taking *testing.T, in a file ending _test.go, in the same package as the code.

internal/world/grid.go with tests in internal/world/grid_test.go:

package world

import "testing"

func TestGridSetAndAt(t *testing.T) {
	g := NewGrid(4, 3)
	g.Set(2, 1, 7)

	if got := g.At(2, 1); got != 7 {
		t.Errorf("At(2,1) = %d, want 7", got)
	}
}

// table-driven tests: the dominant Go style
func TestGridBounds(t *testing.T) {
	g := NewGrid(4, 3)

	cases := []struct {
		name string
		x, y int
		want int
	}{
		{"inside", 0, 0, 0},
		{"negative x", -1, 0, 0},
		{"beyond width", 4, 0, 0},
		{"beyond height", 0, 3, 0},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := g.At(tc.x, tc.y); got != tc.want {
				t.Errorf("At(%d,%d) = %d, want %d", tc.x, tc.y, got, tc.want)
			}
		})
	}
}
go test ./...              # everything
go test -v ./internal/world # verbose, one line per test
go test -run TestGridBounds/negative ./internal/world   # one subtest
go test -race ./...        # with the race detector (slower, essential)
go test -cover ./...       # coverage percentage
go test -bench=. ./...     # run benchmarks
Notes
Exercise 2.C — the capstone of Part 2

The Mewlang cat, raising a paw for a high-fiveWrite a small program that models a very simple version of what we are about to build. Requirements:

  • A Report struct with an AntID int and a Found bool.
  • A function ant(ctx context.Context, id int, out chan<- Report) that sends a report every 50 ms until the context is cancelled, with Found true roughly one time in four. It must return promptly on cancellation and must not block forever if nobody is reading out.
  • A main that starts 100 ants, collects reports for 500 ms, then cancels, waits for every goroutine to finish, and prints the total number of reports and the number of finds.
  • No data races: verify with go run -race ..

Hints: one select in the ant with three cases; a WaitGroup; a buffered out channel plus a default case on the send if you want to avoid blocking; the collector goroutine should stop only after the channel is closed, and the channel should be closed only after all senders are done.

Solution 2.C — open after trying
package main

import (
	"context"
	"fmt"
	"math/rand"
	"sync"
	"time"
)

type Report struct {
	AntID int
	Found bool
}

func ant(ctx context.Context, id int, out chan<- Report) {
	ticker := time.NewTicker(50 * time.Millisecond)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			r := Report{AntID: id, Found: rand.IntN(4) == 0}
			select {
			case out <- r:            // delivered
			case <-ctx.Done():        // cancelled while waiting to send
				return
			default:                  // collector is behind: drop it
			}
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	out := make(chan Report, 1024)

	var wg sync.WaitGroup
	for i := range 100 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			ant(ctx, i, out)
		}()
	}

	// close out only after every sender has returned
	go func() {
		wg.Wait()
		close(out)
	}()

	var total, finds int
	done := time.After(500 * time.Millisecond)

collect:
	for {
		select {
		case r, ok := <-out:
			if !ok {
				break collect
			}
			total++
			if r.Found {
				finds++
			}
		case <-done:
			cancel()          // tell the ants to stop; keep draining
		}
	}

	fmt.Printf("reports=%d finds=%d\n", total, finds)
}

Points worth studying in this solution, because every one of them recurs in the real project:

  • The nested select on send. Sending on a channel can block, and a blocked send ignores cancellation. Wrapping the send in its own select with ctx.Done() and default makes it both cancellable and non-blocking.
  • Who closes. There are 100 senders, so no single sender may close. A separate goroutine waits for all of them and closes afterwards. This is the standard fan-in shutdown pattern.
  • The labelled break. break inside a select breaks the select, not the enclosing for. You need a label, here collect:, to leave the loop. This trips up everyone once.
  • Draining after cancel. After cancel(), the loop keeps receiving until the channel closes, so no reports are lost and no sender is left blocked. Exiting immediately would leak goroutines.
  • rand.IntN is the modern math/rand/v2 spelling; on older Go it is rand.Intn from math/rand.

Part 2 checkpoint

  1. Why does append return a value instead of modifying in place?
  2. You have var m map[string]int. Which of reading, writing, and len works?
  3. What is the difference between a nil channel and a closed channel when used in a select?
  4. Why must wg.Add be called before go rather than inside the goroutine?
  5. A function takes ctx context.Context but runs a tight arithmetic loop for ten seconds. Does cancel() stop it? Why not?
  6. Your struct has some methods with value receivers and some with pointer receivers. Name one concrete problem this causes.
  7. What is the difference between errors.Is and errors.As, and when does == on errors fail?
Why are we using this language here? (Part 2 summary)

Everything in this crash course exists in other languages. Go's contribution is not novelty, it is proportion: the language is small enough to hold in your head after a week, and the concurrency primitives are first-class rather than bolted on. You have now seen essentially all of Go except generics and reflection. Compare that to the fraction of C++ or Scala you would know after the same effort.

The honest cost: verbosity in error handling, no sum types so "this is either A or B" is awkward, and a type system that will feel thin if you come from Haskell or Rust. For a simulation whose difficulty is concurrency rather than data modelling, that trade lands well.


NextWhere we go from here

The next instalment starts building. Milestone 1 creates the module properly, defines World, Grid and Ant, and runs a single ant through a deterministic tick loop with a seeded random source, a command-line flag or two, and the first real tests. It is intentionally not concurrent, because the whole point of Milestone 4 is to break it.

Before then, two things worth doing:

  1. Finish exercise 2.C and run it under -race. If you have not seen race detector output yet, deliberately introduce a shared counter incremented by all 100 goroutines without synchronisation, and read what it tells you.
  2. Create the antfarm module and commit the empty skeleton. Milestone 1 assumes it exists.

The Mewlang cat, seen from behind, walking offInstalment 1 of the five-course curriculum. Next: Go Milestones 1–4.

Continue