The World Vol 1 · A World That Ticks
ch 09 / 105
Chapter 09

Running Two at Once

Goroutine tick loop

worldd does one thing, finishes, and exits, which is a fine description of a batch job and a poor one for a server. The rule for making it a server is narrow: the tick loop runs on its own goroutine, and main reaches it only through a channel.

Two chapters ago the server grew a heartbeat, and one chapter ago it grew a memory: a fixed tick advancing the world in equal steps, and one JSON line appended to world.jsonl the moment anything happened.

Both of them ended up in the same place. The loop sits inside main, counting up to a fixed number of ticks, and while it runs there is no other code in this program.

A world that never resets cannot be a batch job. It has to keep ticking while something else is going on: drawing a frame, accepting a request, or noticing a Ctrl-C.

Chapter 8 already made the log durable, so the orderly-stop rule is not about rescuing lost bytes. An orderly stop means the world's last recorded event belongs to a tick that finished.

The log file is closed by the code that was writing to it, not abandoned by a dying process. When main prints its final report, the numbers in it describe a world that has actually stopped moving.

Not a shared variable. Not a flag one side writes and the other side reads. A channel, for a reason the end of this chapter proves with a tool instead of asserting.

The go keyword

Go spells concurrent work with a single keyword. Write go f(x) and Go calls f with x without waiting for it to return. The argument is evaluated right away, in the goroutine doing the calling; the body runs somewhere else, whenever the runtime gets to it. The statement produces no value at all, because by the time there could be one the caller has moved on to its next line.

A goroutine is not an operating-system thread. The Go runtime keeps a small pool of threads and multiplexes goroutines onto them, and each goroutine starts with a stack of a few kilobytes that grows when it needs to, so starting one costs closer to a function call than to a thread. A server can hold thousands. Today the world needs exactly one.

▣ Build · stage 1: the loop moves out of main's way
// cmd/worldd/main.go — chapter 8's setup, then chapter 7's loop with
// one new word in front of it
	w := sim.NewWorld(sim.Generate(12, 8, seed), seed)
	w.Record(lg)
	// ... the shrub and two walkers spawn here, as they did last chapter ...

	go func() {
		ticker := time.NewTicker(sim.TickDuration)
		defer ticker.Stop()
		for range ticker.C {
			must(w.Step())
			must(w.Faulted())
		}
	}()

	fmt.Println("worldd", version, "seed", seed, "ticking")
}
$ go run ./cmd/worldd
worldd 0.0.1 seed 5 ticking
$ cat world.jsonl
{"t":0,"ev":"start","what":"seed 5"}
{"t":0,"ev":"spawn","id":1,"to":{"x":2,"y":5},"what":"shrub"}
{"t":0,"ev":"spawn","id":2,"to":{"x":10,"y":6},"what":"walker"}
{"t":0,"ev":"spawn","id":3,"to":{"x":2,"y":2},"what":"walker"}

Four lines, every one of them stamped tick 0, and not a single move. The program came back to the prompt instantly. Read it the way the runtime did: go handed the loop to the scheduler and returned, main printed its banner and reached its closing brace, and when main returns the process exits. Every other goroutine stops where it stands, mid-instruction, with no deferred call run and nothing finished. ticker.Stop() never happened. The first tick was a hundred milliseconds away and never arrived at all.

The bill for concurrency arrives with the very first goroutine. main needs a reason to stay alive while the world ticks, a way to tell the loop to stop, and proof that the loop finished stopping. One mechanism covers the bill.

make, close and select

A channel is a typed pipe between goroutines, built with make(chan T), and one rule does most of the work: a receive blocks until a value is there, and a send on an unbuffered channel blocks until somebody receives it. Blocking sounds like a cost and is really the coordination. A goroutine parked on a receive burns no CPU, holds no lock, and wakes the moment a value lands.

Closing supplies the second property. close(ch) announces that no value will ever be sent on it again, and from then on every receive returns instantly, forever, with the zero value of the channel's type. One close reaches every receiver and none of them have to be counted, so a shutdown channel is normally closed and never sent on. The third piece is select, which waits on several channels at once and runs the branch belonging to whichever becomes ready first; if two are ready together it picks between them at random. Those three ideas are the whole concurrency vocabulary this volume needs.

▣ Build · stage 2: a loop that can be told to stop
// internal/sim/loop.go
package sim

import "time"

// Run ticks the world at the fixed rate until stop is closed. However
// this loop ends, it closes the event log on its way out.
func Run(w *World, lg *Log, stop <-chan struct{}) error {
	ticker := time.NewTicker(TickDuration)
	defer ticker.Stop()

	for {
		select {
		case <-stop:
			return lg.Close()
		case <-ticker.C:
			if err := w.Step(); err != nil {
				lg.Close()
				return err
			}
			if err := w.Faulted(); err != nil {
				lg.Close()
				return err
			}
		}
	}
}
// cmd/worldd/main.go — main starts the world, then goes back to its own job
	stop := make(chan struct{})
	done := make(chan error)

	go func() {
		done <- sim.Run(w, lg, stop)
	}()

	fmt.Println("worldd", version, "seed", seed, "ticking at", sim.TickRate, "Hz; ^C to stop")

	sigs := make(chan os.Signal, 1)
	signal.Notify(sigs, os.Interrupt)
	<-sigs
	fmt.Println("\nstop requested")
	close(stop)
	must(<-done)

	fmt.Print(w.Render())
	fmt.Println("ticks:", w.Tick(), " events written:", lg.Count())
$ go run ./cmd/worldd
worldd 0.0.1 seed 5 ticking at 10 Hz; ^C to stop
^C
stop requested
#@##########
#..........#
#...~......#
#.~~~~~....#
#.~~~~~~.@.#
#.o.~~~~...#
#...~......#
############
ticks: 40  events written: 77

Read Run as ordinary sequential code, because that is all it is: a loop around a select, with no go anywhere inside it. The concurrency lives at the call site, and keeping the two apart pays twice. Run can be read and tested as a loop, and the decision to run the world beside main instead of inside it is one line you can point at.

Two of the three channels here belong to the world and each carries traffic in one direction. stop is a chan struct{}: the empty struct is a type with no data in it at all, so this channel never carries a payload, only the fact of having been closed. done carries an error, which turns the loop's last act into a report: closing the log either worked or it did not, and main is what decides. The third channel, sigs, is filled by the runtime. signal.Notify asks Go to deliver a Ctrl-C as a value on a channel instead of killing the process outright, and the buffer of one means a signal arriving a moment before main reaches the receive is held instead of dropped.

One line moved rather than appeared: defer lg.Close() is gone from main, and Run closes the log on every path out of the loop. The move follows the resource rule: the goroutine that writes to a resource is the one that closes it, because it is the only one that knows when the last write happened. Your run will report a different tick number than this one, since it counts tenths of a second between the banner and your hand leaving the key.

Main and the tick loop, joined by two channels Top row: the main goroutine runs four steps in order, starting sim.Run with go, waiting for Ctrl-C, closing the stop channel, and receiving from done. Bottom: the sim goroutine is one select with two cases, one receiving from the ticker and stepping the world, one receiving from stop and closing the log. An arrow runs down from the close of stop into the select, and an arrow runs up from the select to the receive from done. MAIN GOROUTINE go sim.Run wait for ^C close(stop) err := <-done CHANNELS stop (main to world: shut down) done (world to main: log closed, err or nil) SIM GOROUTINE select case <-ticker.C: Step case <-stop: lg.Close() whichever channel is ready wins; no tick is ever cut in half

Figure 9.1: two goroutines, two channels, and one select deciding on every pass whether this is another tick or the end of the run.

77 lines at tick 40

Those last two printed lines are a claim about a file, so check them against the file. main said the world reached tick 40 and the log accepted 77 events.

▣ Build · stage 3: the world and its record, compared
$ wc -l world.jsonl
77 world.jsonl
$ tail -1 world.jsonl
{"t":40,"ev":"move","id":3,"from":{"x":0,"y":0},"to":{"x":1,"y":0}}

Seventy-seven lines for seventy-seven events, and the file's last line carries tick 40, the same tick main reported. The run lands the same way every time: interrupt this world after any 40 ticks and you get these 77 lines, because the seed fixes what happens and only the tick count depends on your thumb. The loop finished a tick, saw the stop branch ready on its next pass, closed the file, and reported the result; only then did main read the world and print anything about it.

Trace the chain once: every link in it is a channel operation waiting on the link before. close(stop) makes the stop branch ready. The loop's next trip through select takes that branch, so it is never mid-tick when it leaves, and calls lg.Close(). Run returns whatever that reported. done <- err blocks until main receives, and main has been sitting at <-done waiting for it. Only then does main render the map, read the counters, and return, ending the process. Nothing overlaps, and nothing had to be timed.

Delete the <-done and the printed report becomes fiction of a specific kind: main would be reading terrain and entity positions out of a world that is still being stepped by another goroutine, and the map it drew might show a walker in a cell the log never recorded it entering. Exercise 3 makes the tool say so out loud. The shutdown signal alone was never the point; the acknowledgement is.

Channel ordering

A channel operation does two jobs, and the second one is invisible in the code. The obvious job is delivery: a value, or the news that a channel is closed, moves from one goroutine to another. The quiet job is ordering. Go's memory model guarantees that everything a goroutine did before it sent on a channel is visible to the goroutine that receives, once the receive completes. The send and the matching receive are one event both goroutines agree on, and it cuts time into a before and an after.

That guarantee is the whole reason main can call w.Render() straight after <-done with no lock in sight. The tick loop moved the walkers, appended their lines, closed the file, and then sent on done; main read the world after receiving. Same memory, two goroutines, no race, because a channel stands between the writing and the reading.

Take that event away and two goroutines touching the same variable is a data race, which is a worse thing than an occasional stale value. A compiler with no reason to expect another goroutine may load a variable into a register once and reuse it for the rest of a loop; processors reorder loads and stores for their own reasons; and Go's memory model declines to define what a racing program does at all. "It printed the right answer" is not evidence of correctness under those terms, only evidence that the luck held for one run.

⚠ Worked failure: the flag that worked fine

Here is the shutdown a channel looks like overkill for. A package-level boolean, written by main when the interrupt arrives, read by the loop at the top of every tick. It is shorter than the select, it needs no Run function, and on the bench it behaves.

// cmd/worldd/main.go — the stop a boolean seemed able to serve
var stopped bool

func main() {
	// ... the same log, the same world, the same three entities ...

	done := make(chan error)
	go func() {
		ticker := time.NewTicker(sim.TickDuration)
		defer ticker.Stop()
		for range ticker.C {
			if stopped {
				done <- lg.Close()
				return
			}
			must(w.Step())
		}
	}()

	// ... wait for Ctrl-C, then raise the flag ...
	<-sigs
	fmt.Println("\nstop requested")
	stopped = true
	must(<-done)

	fmt.Println("ticks:", w.Tick(), " events written:", lg.Count())
}
$ go run -race ./cmd/worldd
worldd 0.0.1 seed 5 ticking at 10 Hz; ^C to stop
^C
stop requested
==================
WARNING: DATA RACE
Read at 0x0000006a8448 by goroutine 8:
  main.main.func1()
      /home/you/theworld/cmd/worldd/main.go:38 +0x130

Previous write at 0x0000006a8448 by main goroutine:
  main.main()
      /home/you/theworld/cmd/worldd/main.go:52 +0x7ec

Goroutine 8 (running) created at:
  main.main()
      /home/you/theworld/cmd/worldd/main.go:34 +0x524
==================
ticks: 38  events written: 72
Found 1 data race(s)
exit status 66

Look at what happened before the warning: the world stopped, the log closed, the numbers printed. By the only test available without a tool, the flag works. The detector disagrees, and its report is a list of facts. One address was read by goroutine 8 at line 38 and previously written by the main goroutine at line 52, and the two goroutines share no channel operation, no lock, nothing that puts those two accesses in an order. That is a data race by definition, and exit status 66 is the program's own tooling refusing to call the run a success. Your addresses and line numbers will differ, and the two accesses can be reported in either order; the pairing of a write in main against a read inside the loop is what to look for.

Reasoning from symptom to cause runs backwards here, which is what makes races dangerous: the symptom was that everything looked correct. A boolean carries no ordering event, so the compiler is entitled to hoist that read out of the loop and the processor to publish the write late; neither did today, on this machine, with this build. Underneath the memory-model defect sits a design one. A flag can only be polled, so the loop notices it at the top of an iteration, up to a full tick late, and if the loop ever parks on something else it never notices at all. A closed channel works as a wakeup, not as a value to be checked, and the select is already waiting for it.

⚑ Tool: the race detector

-race works on go run, go build and go test. It instruments memory accesses and reports pairs it cannot put in an order, so it finds races that actually execute during that run, at roughly ten times the cost in time and memory. Run the sim under it whenever you add a goroutine, and never ship the instrumented binary. Details: go.dev/doc/articles/race_detector.

Checkpoint

✓ Checkpoint: what you can now do
  • Say what go func() { ... }() starts and what it promises: work begins, nothing is waited for, and every goroutine dies the instant main returns, deferred calls and all.
  • Explain why a shutdown channel is closed instead of written to, and what a receive on a closed channel hands back.
  • Read the select inside Run and say what each branch does, including what happens when the ticker and the stop channel come ready together.
  • Trace the stop chain from close(stop) to the log file being closed, and name which step forces the next one.
  • Check an orderly stop against the evidence: the last line's tick against the tick main reported, and wc -l against the event count.
  • Prove a shutdown boolean is broken while it appears to work, using go run -race, and read the detector's two-address report.
⚡ Exercises: try first, then reveal
Exercise 1: forget to close. Comment out close(stop) in main, run worldd, and press Ctrl-C. Predict what you will see before you run it, then work out how to get your shell back.

stop requested prints, and after that nothing ever happens again. The world keeps ticking, because its stop branch never becomes ready, and main is parked at <-done waiting on a loop that has no reason to return. A second Ctrl-C does not help: signal.Notify took the interrupt away from the default handler and no goroutine is receiving from sigs any more. You end it from another terminal with pkill -9 worldd. Two goroutines waiting on each other is a deadlock even when one of them is busy, and this one is a single commented-out line.

Exercise 2: kill it the rude way. Start worldd, wait a few seconds, and run pkill -9 worldd from another terminal. Count the lines in world.jsonl. Given how little is missing, say precisely what the stop channel is protecting.

Nothing is missing: signal 9 cannot be caught or delayed, and the log still holds every event, because chapter 8 spends a write on each line as it happens. What the process loses is everything after that. The file is never closed by its writer, no report is printed, and the world stops wherever the kernel found it, so a tick that writes three lines can leave the log after the first. Today that window is microseconds wide. When a tick moves three thousand creatures it is a real fraction of a second, and a history ending halfway through a tick cannot be compared as a complete run.

Exercise 3: report on a moving world. Delete must(<-done), keeping close(stop), so main renders the map the instant it asks for a stop. Run it under the race detector.

go run -race ./cmd/worldd puts the read inside Render against the write inside Move, several frames deep in the tick loop:

$ go run -race ./cmd/worldd
WARNING: DATA RACE
Read at 0x00c0000201f8 by main goroutine:
  theworld/internal/sim.(*World).Render()
      /home/you/theworld/internal/sim/world.go:110 +0x11b
  main.main()
      /home/you/theworld/cmd/worldd/main.go:44 +0x7b7

Previous write at 0x00c0000201f8 by goroutine 8:
  theworld/internal/sim.(*World).Move()
      /home/you/theworld/internal/sim/world.go:82 +0x290
  theworld/internal/sim.(*World).driftWalkers()
      /home/you/theworld/internal/sim/tick.go:75 +0x1ac

The trace continues down through Step and Run to the goroutine main started. Both fixes are one line, and choosing between them is a design decision. Put the receive back and the report is ordered, at the price of only being able to look once the world has stopped. Or give Run a channel it sends snapshots on, so what main reads is a copy the loop chose to publish. A player asking what the valley looks like cannot be told to wait for shutdown, so a live view needs the second answer.