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.
// 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.
// 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.
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.
$ 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.
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.
-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
- Say what
go func() { ... }()starts and what it promises: work begins, nothing is waited for, and every goroutine dies the instantmainreturns, 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
Runand 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
mainreported, andwc -lagainst 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.
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.