Enough Go to read these builds
The first build in this track, drawn as the walls it hits in order. Every wall on it is crossed with the handful of Go words this lesson teaches.
Before you start. This track builds seven real systems from an empty file, all in the Go language: a memory store like Redis, a message log like Kafka, a web server, a database, a job scheduler, a small Kubernetes (the program that keeps a fleet of other programs running) and a key-value store spread over several machines. No Go is assumed: if you know that a program is steps run top to bottom, and that a function is a named set of steps you hand inputs to and get answers back from, that is enough.
September 2007: a 45-minute build, and three people at a whiteboard
In 2007, one major Google program, written in the C++ language, took 45 minutes to build, and that was on a build system spread over many machines. Rob Pike told the story in a 2012 talk, “Go at Google”, and he was careful to call what came next a myth: “The origin myth for Go states that it was during one of those 45 minute builds that Go was conceived.”
The date is not a myth. On 21 September 2007, Robert Griesemer, Rob Pike and Ken Thompson started sketching the goals for a new language on a whiteboard at Google. They wanted a language that built in seconds, that read plainly to a programmer who had never seen the file before, and that was made for machines serving many people at once over a network. Go became a public open-source project on 10 November 2009.
That last goal is why these builds are in Go. A server is a kitchen. Orders come in from many tables at once, cooks work them, and something shared sits on the counter that everyone reads and writes. You will carry that kitchen through this whole lesson, and each piece of it turns out to be one Go word. There are five, and after them you can read any file in these builds.
What if the stove has no gas? The error that comes back with the plate
In many languages, a failure makes the program jump somewhere else to complain. Go does not. A Go function that can fail hands back two things: the answer, and a note saying whether anything went wrong. A cook hands you a plate and a slip. If the slip is blank, eat. If it says “no gas”, do not eat the plate.
Here is the first thing the Redis build does, in its first stage (redis/01-socket/main.go, lines 23 and 24):
ln, err := net.Listen("tcp", "127.0.0.1:6379")
if err != nil {
Read it aloud: “ask for an internet line ("tcp") to listen on, and call what comes back ln and err. If err is not empty, stop.” In Go, empty is spelled nil. The note is called an error, and it is an ordinary value, like a number. You look at it on the next line.
The seven builds check if err != nil 92 times (counted with a text search, 2026-09-28). Read it as “plate and slip, check the slip” and all 92 read themselves.
Who closes the fridge? defer, the note stuck by the door
A cook who opens the fridge must close it, whether the order went well, went badly, or was abandoned halfway. Put “close the fridge” at the bottom of the steps and a cook who leaves early skips it.
Go’s answer is one word. Write defer, then the cleanup step, right after you open the thing. It is a note stuck by the door: whenever you leave this function, by any way out, do this first. The Redis build’s handle function opens with it (redis/01-socket/main.go, line 38):
defer conn.Close()
The visitor’s connection now closes however handle ends: a goodbye, garbage, or a dropped line. Defer keeps the cleanup beside the mess it cleans. The builds use it for two things: closing a connection, and handing back a lock you will meet at the end of this lesson.
Twelve orders, one cook: the goroutine
Now the kitchen gets busy. The first Redis stage serves one visitor at a time, on purpose. While Alice is connected, Bob waits at the door, even if Alice is just thinking.
The fix in the Redis build’s fourth stage is one word (redis/04-eventloop/main.go, line 44):
go handle(conn) // <- the one word that changes everything
Put go in front of a function call and that function starts running alongside everything else, and the line after it runs straight away without waiting. The kitchen just hired a cook for Bob. Each of these running-alongside functions is a goroutine. The Redis build and the web server build both start one goroutine per visitor.
Here the kitchen stops matching. A cook is a person you pay. A goroutine is so cheap that the lesson’s own program starts 100,000 of them in 78 milliseconds, measured with go run ./03-goroutines on a laptop on 2026-09-28. Go runs all of them on a handful of real processor cores (the parts of the chip that actually run steps), switching between them whenever one is waiting.
The rail between the counter and the cooks: the channel
How do orders reach the cooks? A kitchen answers with a ticket rail: a metal bar with a fixed number of hooks. The waiter clips a ticket on. A free cook pulls one off. If every hook is full, the waiter stands there holding the ticket until a hook frees up. If the rail is empty, a free cook waits for a ticket.
flowchart LR W["waiter with 12 tickets"] -->|"clips one on, waits if all 4 hooks are full"| R["rail: 4 hooks"] R -->|"a free cook pulls one off"| C1["cook 1"] R --> C2["cook 2"] R --> C3["cook 3"] C1 -->|"takes the key, adds one, gives the key back"| T["tally sheet on the counter"] C2 --> T C3 --> T
Go has that rail built in, and it is called a channel. make(chan Ticket, 4) builds a rail with 4 hooks. rail <- t clips ticket t on, and waits if the rail is full. <-rail takes one off, and waits if it is empty. The numbers here (12 tickets, 4 hooks, 3 cooks) are teaching values, small enough to follow by eye.
The web server build uses a channel for exactly the “waits when full” half (http/06-concurrent/main.go, line 23):
var sem = make(chan struct{}, 100) // at most 100 connections in flight
A rail of 100 hooks with blank tickets (struct{} is Go’s empty value: a ticket with nothing written on it). Before starting a cook for a new visitor, the server clips one on. After the hundredth, the next visitor waits at the door until a cook finishes and pulls one off.
Two cooks, one tally sheet: the mutex
Cooks share things. In the Redis build, every goroutine reads and writes one shared table of names and values. In the kitchen, every cook adds a mark to one tally sheet on the counter.
Adding a mark is three steps: read the number, add one, write it back. Two cooks can both read 41, both write 42, and one plate goes uncounted. The lesson’s fourth program does exactly that with no protection. Four cooks each add 100,000 to one counter. Measured with go run ./04-race on 2026-09-28, it printed expected 400000, got 195493. More than half the marks vanished, and the program said nothing was wrong.
The fix is a key hanging by the sheet. A cook takes the key, marks the sheet, and hangs the key back. A second cook who wants to write waits for the key. Go calls the key a mutex, from “mutual exclusion”, and the steps are mu.Lock() and mu.Unlock(). The Redis build’s expiry stage puts it together with the word from earlier (redis/05-expiry/main.go, lines 85 and 86):
mu.Lock()
defer mu.Unlock()
Take the key, and stick a note by the door saying “hang the key back on your way out”.
Go also ships a tool that catches a forgotten key for you. Add -race to the run command and Go watches every read and write while the program runs. When two goroutines touch the same thing with no key between them, it prints both of their lines and fails.
You now know
- A Go function that can fail returns an error value alongside its answer, and the caller checks
if err != nilon the next line. Nothing jumps past the caller. deferruns a cleanup step whenever the function returns, by any path. The builds use it to close connections and release locks.- Writing
gobefore a function call starts a goroutine: it runs alongside the rest of the program. Goroutines are cheap enough to start one per visitor. - A channel passes values between goroutines. Sending waits when it is full and receiving waits when it is empty, so a channel with 100 slots works as a limit.
- Two goroutines adding to one shared number at the same moment can lose updates, with no warning. A mutex is a lock that lets one goroutine write at a time, and running with
go run -racereports any shared value left unlocked.
Stop here if you want. What follows is the rest of the vocabulary (maps, structs, slices, and how to run and test a stage), with every program run and its output pasted, for when you want to read a build line by line.
Second read: four small programs, and where each word lives in the builds
Every number below printed by a program was measured on 2026-09-28 on an Apple Silicon Mac with go1.27.1. The 12 tickets, 4 hooks and 3 cooks are teaching values; the timings and the lost-update count change on every run and on every machine.
Errors and defer
cd teaching/code/from-scratch/enough-go
go run ./01-errors
Run it yourself: full source file
- Install Go from
go.dev/dl. Check it withgo version. - Make the folder:
mkdir -p teaching/code/from-scratch/enough-go. Save the file below at the path shown above it, including any subfolders. - Inside that folder, run
go mod init lessononce. Go needs a module name to build. - Run the commands in the block above from the folder where you ran mkdir.
teaching/code/from-scratch/enough-go/01-errors/main.gopackage main
// 01 — errors are values, and defer.
//
// Run: go run ./01-errors
//
// A Go function that can fail hands back TWO things: the answer, and an error.
// The caller looks at the error before touching the answer. Nothing is thrown;
// nothing jumps past you. Every build in this track opens with the same shape:
//
// ln, err := net.Listen("tcp", "127.0.0.1:6379")
// if err != nil { ... }
//
// defer says "run this when the function returns, however it returns".
// The builds use it to close a connection and to release a lock.
import (
"fmt"
"os"
"strconv"
)
// parsePort turns text like "6379" into a number, or says why it cannot.
func parsePort(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("port %q is not a number", s)
}
if n < 1 || n > 65535 {
return 0, fmt.Errorf("port %d is out of range", n)
}
return n, nil // nil = "no error"
}
// serve opens, maybe leaves early, and always closes.
func serve(name string) {
fmt.Println("open ", name)
defer fmt.Println("close ", name) // runs on EVERY way out of serve
if name == "bob" {
fmt.Println(" bob sent garbage, leaving early")
return
}
fmt.Println(" answered", name)
}
func main() {
for _, s := range []string{"6379", "sixty", "99999"} {
port, err := parsePort(s)
if err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println("port:", port)
}
_, err := os.ReadFile("no-such-file.txt")
fmt.Println("error:", err)
serve("alice")
serve("bob")
}
port: 6379
error: port "sixty" is not a number
error: port 99999 is out of range
error: open no-such-file.txt: no such file or directory
open alice
answered alice
close alice
open bob
bob sent garbage, leaving early
close bob
parsePort returns (int, error): a number and a slip. It builds its own error with fmt.Errorf, and returns nil in the error slot when all went well. The fourth line is an error the standard library made, from os.ReadFile. The last six lines are defer: Bob leaves early through a return, and close bob still prints.
The three shapes data takes: map, struct, slice
A map finds a value by its name. map[string]int{"dal": 3} is a map from text to numbers. The Redis build’s whole database is one line (redis/03-store/main.go, line 25): var store = map[string]string{}. Looking a name up comes in the same two-value shape as errors: v, ok := store[key] gives the value and whether it was there at all (redis/04-eventloop/main.go, line 69). Walking a map with for k, v := range m visits the entries in a random order; the Redis expiry stage leans on that as a free random sample (redis/05-expiry/main.go, line 89).
A struct bundles named fields into one thing, and a method is a function that belongs to one, written with the struct in brackets before the name. The database build opens with both (database/01-append/main.go, lines 21 and 28): type DB struct{ f *os.File }, then func (db *DB) set(key, val string). The * means the method works on the one real DB, not a copy, so it can change it. Without the *, Go would hand the method its own duplicate of the DB, and any change it made would vanish when it returned.
A slice is a list that grows. append(list, x) returns the list with x on the end. The B-tree stage splits a node with it (database/04-btree/main.go, line 77): z.keys = append(z.keys, y.keys[t:]...). y.keys[t:] is “everything from position t on”, and ... passes each item rather than the list as one thing.
cd teaching/code/from-scratch/enough-go
go run ./02-shapes
Run it yourself: full source file
- Install Go from
go.dev/dl. Check it withgo version. - Make the folder:
mkdir -p teaching/code/from-scratch/enough-go. Save the file below at the path shown above it, including any subfolders. - Inside that folder, run
go mod init lessononce. Go needs a module name to build. - Run the commands in the block above from the folder where you ran mkdir.
teaching/code/from-scratch/enough-go/02-shapes/main.gopackage main
// 02 — the three shapes data takes in these builds: map, struct, slice.
//
// Run: go run ./02-shapes
// Test: go test ./02-shapes
//
// A map finds a value by its name (Redis's whole database is one).
// A struct bundles named fields; a method is a function that belongs to it
// (the database build's DB, the B-tree's node). A slice is a list that grows
// with append (the B-tree's keys, the Kafka log's segments).
import (
"errors"
"fmt"
)
// Ticket is one order: which table, which dish, how many.
type Ticket struct {
Table int
Dish string
Qty int
}
// Kitchen holds what is left of each dish, and every ticket it has cooked.
type Kitchen struct {
stock map[string]int // dish -> portions left
cooked []Ticket // grows by one per ticket
}
var ErrSoldOut = errors.New("sold out")
// Cook is a method: it belongs to *Kitchen and can change it.
func (k *Kitchen) Cook(t Ticket) error {
left, ok := k.stock[t.Dish] // ok is false if the dish is not on the menu
if !ok {
return fmt.Errorf("table %d: no such dish %q", t.Table, t.Dish)
}
if left < t.Qty {
return fmt.Errorf("table %d: %s: %w", t.Table, t.Dish, ErrSoldOut)
}
k.stock[t.Dish] = left - t.Qty
k.cooked = append(k.cooked, t)
return nil
}
func main() {
k := &Kitchen{stock: map[string]int{"dal": 3, "rice": 5}}
tickets := []Ticket{
{Table: 1, Dish: "dal", Qty: 2},
{Table: 2, Dish: "rice", Qty: 1},
{Table: 3, Dish: "dal", Qty: 2},
{Table: 4, Dish: "pizza", Qty: 1},
}
for _, t := range tickets {
if err := k.Cook(t); err != nil {
fmt.Println("error:", err)
continue
}
fmt.Printf("cooked table %d: %d x %s\n", t.Table, t.Qty, t.Dish)
}
fmt.Println("cooked", len(k.cooked), "of", len(tickets), "tickets")
fmt.Println("dal left:", k.stock["dal"], " rice left:", k.stock["rice"])
}
cooked table 1: 2 x dal
cooked table 2: 1 x rice
error: table 3: dal: sold out
error: table 4: no such dish "pizza"
cooked 2 of 4 tickets
dal left: 1 rice left: 4
The kitchen’s stock is a map, a ticket is a struct, Cook is a method returning an error, and cooked is a slice that grows by one per plate. Table 3 fails the stock check; table 4 fails the ok check because “pizza” was never in the map.
go run and go test
go run ./02-shapes compiles the folder and runs its main function. Every stage in all seven builds is run that way, and none of the builds ships a test file yet. go test is how you check your own change to one: it runs every function named TestSomething in the folder’s _test.go files, and a test fails by calling t.Errorf.
cd teaching/code/from-scratch/enough-go
go test -v ./02-shapes
Run it yourself: all 2 source files
- Install Go from
go.dev/dl. Check it withgo version. - Make the folder:
mkdir -p teaching/code/from-scratch/enough-go. Save each of the 2 files below at the path shown above it, including any subfolders. - Inside that folder, run
go mod init lessononce. Go needs a module name to build. - Run the commands in the block above from the folder where you ran mkdir.
teaching/code/from-scratch/enough-go/02-shapes/kitchen_test.gopackage main
// go test ./02-shapes runs every func TestXxx in this folder.
// A test is ordinary Go that calls t.Errorf when the answer is wrong.
import (
"errors"
"testing"
)
func TestCookTakesFromStock(t *testing.T) {
k := &Kitchen{stock: map[string]int{"dal": 3}}
if err := k.Cook(Ticket{Table: 1, Dish: "dal", Qty: 2}); err != nil {
t.Fatalf("first ticket: unexpected error %v", err)
}
if got := k.stock["dal"]; got != 1 {
t.Errorf("dal left = %d, want 1", got)
}
if got := len(k.cooked); got != 1 {
t.Errorf("cooked = %d tickets, want 1", got)
}
}
func TestCookRefusesWhenSoldOut(t *testing.T) {
k := &Kitchen{stock: map[string]int{"dal": 1}}
err := k.Cook(Ticket{Table: 3, Dish: "dal", Qty: 2})
if !errors.Is(err, ErrSoldOut) {
t.Fatalf("err = %v, want ErrSoldOut", err)
}
if got := k.stock["dal"]; got != 1 {
t.Errorf("a refused ticket changed the stock: dal left = %d, want 1", got)
}
}
teaching/code/from-scratch/enough-go/02-shapes/main.gopackage main
// 02 — the three shapes data takes in these builds: map, struct, slice.
//
// Run: go run ./02-shapes
// Test: go test ./02-shapes
//
// A map finds a value by its name (Redis's whole database is one).
// A struct bundles named fields; a method is a function that belongs to it
// (the database build's DB, the B-tree's node). A slice is a list that grows
// with append (the B-tree's keys, the Kafka log's segments).
import (
"errors"
"fmt"
)
// Ticket is one order: which table, which dish, how many.
type Ticket struct {
Table int
Dish string
Qty int
}
// Kitchen holds what is left of each dish, and every ticket it has cooked.
type Kitchen struct {
stock map[string]int // dish -> portions left
cooked []Ticket // grows by one per ticket
}
var ErrSoldOut = errors.New("sold out")
// Cook is a method: it belongs to *Kitchen and can change it.
func (k *Kitchen) Cook(t Ticket) error {
left, ok := k.stock[t.Dish] // ok is false if the dish is not on the menu
if !ok {
return fmt.Errorf("table %d: no such dish %q", t.Table, t.Dish)
}
if left < t.Qty {
return fmt.Errorf("table %d: %s: %w", t.Table, t.Dish, ErrSoldOut)
}
k.stock[t.Dish] = left - t.Qty
k.cooked = append(k.cooked, t)
return nil
}
func main() {
k := &Kitchen{stock: map[string]int{"dal": 3, "rice": 5}}
tickets := []Ticket{
{Table: 1, Dish: "dal", Qty: 2},
{Table: 2, Dish: "rice", Qty: 1},
{Table: 3, Dish: "dal", Qty: 2},
{Table: 4, Dish: "pizza", Qty: 1},
}
for _, t := range tickets {
if err := k.Cook(t); err != nil {
fmt.Println("error:", err)
continue
}
fmt.Printf("cooked table %d: %d x %s\n", t.Table, t.Qty, t.Dish)
}
fmt.Println("cooked", len(k.cooked), "of", len(tickets), "tickets")
fmt.Println("dal left:", k.stock["dal"], " rice left:", k.stock["rice"])
}
=== RUN TestCookTakesFromStock
--- PASS: TestCookTakesFromStock (0.00s)
=== RUN TestCookRefusesWhenSoldOut
--- PASS: TestCookRefusesWhenSoldOut (0.00s)
PASS
ok enoughgo/02-shapes 0.083s
The second test is the useful one: a refused ticket must not touch the stock. errors.Is(err, ErrSoldOut) asks “is this, or does it wrap, the sold-out error”, which is why Cook builds its message with %w rather than %v.
Goroutines, a channel and a mutex together
cd teaching/code/from-scratch/enough-go
go run ./03-goroutines
Run it yourself: full source file
- Install Go from
go.dev/dl. Check it withgo version. - Make the folder:
mkdir -p teaching/code/from-scratch/enough-go. Save the file below at the path shown above it, including any subfolders. - Inside that folder, run
go mod init lessononce. Go needs a module name to build. - Run the commands in the block above from the folder where you ran mkdir.
teaching/code/from-scratch/enough-go/03-goroutines/main.gopackage main
// 03 — goroutines, a channel between them, and a mutex around shared data.
//
// Run: go run ./03-goroutines
//
// `go f()` starts f running alongside everything else: a goroutine.
// A channel is a rail with a fixed number of hooks: send puts a ticket on it
// (and waits if every hook is full), receive takes one off (and waits if the
// rail is empty). A mutex is a key: only the goroutine holding it may touch
// the shared tally. The Redis build runs one goroutine per client and guards
// its map with a mutex; the HTTP build uses a channel of 100 hooks as a limit.
import (
"fmt"
"sort"
"sync"
"time"
)
type Ticket struct {
Table int
Dish string
}
func main() {
rail := make(chan Ticket, 4) // a rail with 4 hooks
done := make(chan int) // each cook reports here when the rail is empty
var mu sync.Mutex
served := map[string]int{} // shared by every cook: guard it
for c := 1; c <= 3; c++ {
go func() { // three cooks, running at once
n := 0
for t := range rail { // take tickets until the rail is closed and empty
mu.Lock()
served[t.Dish]++
mu.Unlock()
n++
}
done <- n
}()
}
dishes := []string{"dal", "rice", "roti"}
for i := 0; i < 12; i++ {
rail <- Ticket{Table: i + 1, Dish: dishes[i%3]} // waits if 4 are already waiting
}
close(rail) // no more tickets tonight
total := 0
for c := 1; c <= 3; c++ {
total += <-done
}
names := make([]string, 0, len(served))
for d := range served {
names = append(names, d) // map order is random; sort for a stable print
}
sort.Strings(names)
for _, d := range names {
fmt.Printf("%-5s %d\n", d, served[d])
}
fmt.Println("tickets cooked:", total)
// Where the kitchen stops matching: a cook is a person; a goroutine is cheap.
start := time.Now()
wake := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 100_000; i++ {
wg.Add(1)
go func() { defer wg.Done(); <-wake }()
}
fmt.Printf("100000 goroutines started in %v\n", time.Since(start).Round(time.Millisecond))
close(wake)
wg.Wait()
}
dal 4
rice 4
roti 4
tickets cooked: 12
100000 goroutines started in 78ms
Three cooks pull 12 tickets off a 4-hook rail. close(rail) tells the cooks no more tickets are coming, so each cook’s for t := range rail loop ends once the rail is empty, and each reports its count on a second channel, done. The last line is the kitchen’s limit: three more runs printed 83, 68 and 93 ms. The last part uses sync.WaitGroup, a counter the program waits on until every goroutine has called Done; the builds do not need it, because a server’s goroutines never all finish.
The race, caught
cd teaching/code/from-scratch/enough-go
go run ./04-race
Run it yourself: full source file
- Install Go from
go.dev/dl. Check it withgo version. - Make the folder:
mkdir -p teaching/code/from-scratch/enough-go. Save the file below at the path shown above it, including any subfolders. - Inside that folder, run
go mod init lessononce. Go needs a module name to build. - Run the commands in the block above from the folder where you ran mkdir.
teaching/code/from-scratch/enough-go/04-race/main.gopackage main
// 04 — the same tally with the mutex removed: a data race.
//
// Run: go run ./04-race
// Race: go run -race -trimpath ./04-race
//
// Four cooks each add 100,000 to one shared counter, with no lock. "count++"
// is three steps (read, add one, write back), and two cooks can read the
// same old value, so one of their additions is lost. -race watches every
// memory access while the program runs and reports the two goroutines that
// touched the counter with nothing ordering them.
import (
"fmt"
"sync"
)
func main() {
count := 0
var wg sync.WaitGroup
for c := 0; c < 4; c++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 100_000; i++ {
count++ // no mu.Lock() here: the bug
}
}()
}
wg.Wait()
fmt.Println("expected 400000, got", count)
}
expected 400000, got 195493
204,507 additions were lost (400,000 minus 195,493, about 51%). Now the same program under the race detector. -trimpath only shortens the file paths in the report:
cd teaching/code/from-scratch/enough-go
go run -race -trimpath ./04-race
Run it yourself: full source file
- Install Go from
go.dev/dl. Check it withgo version. - Make the folder:
mkdir -p teaching/code/from-scratch/enough-go. Save the file below at the path shown above it, including any subfolders. - Inside that folder, run
go mod init lessononce. Go needs a module name to build. - Run the commands in the block above from the folder where you ran mkdir.
teaching/code/from-scratch/enough-go/04-race/main.gopackage main
// 04 — the same tally with the mutex removed: a data race.
//
// Run: go run ./04-race
// Race: go run -race -trimpath ./04-race
//
// Four cooks each add 100,000 to one shared counter, with no lock. "count++"
// is three steps (read, add one, write back), and two cooks can read the
// same old value, so one of their additions is lost. -race watches every
// memory access while the program runs and reports the two goroutines that
// touched the counter with nothing ordering them.
import (
"fmt"
"sync"
)
func main() {
count := 0
var wg sync.WaitGroup
for c := 0; c < 4; c++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 100_000; i++ {
count++ // no mu.Lock() here: the bug
}
}()
}
wg.Wait()
fmt.Println("expected 400000, got", count)
}
==================
WARNING: DATA RACE
Read at 0x00c000092038 by goroutine 8:
main.main.func1()
enoughgo/04-race/main.go:27 +0x88
Previous write at 0x00c000092038 by goroutine 7:
main.main.func1()
enoughgo/04-race/main.go:27 +0x98
Goroutine 8 (running) created at:
main.main()
enoughgo/04-race/main.go:24 +0x6c
Goroutine 7 (running) created at:
main.main()
enoughgo/04-race/main.go:24 +0x6c
==================
==================
WARNING: DATA RACE
Write at 0x00c000092038 by goroutine 6:
main.main.func1()
enoughgo/04-race/main.go:27 +0x98
Previous write at 0x00c000092038 by goroutine 7:
main.main.func1()
enoughgo/04-race/main.go:27 +0x98
Goroutine 6 (running) created at:
main.main()
enoughgo/04-race/main.go:24 +0x6c
Goroutine 7 (running) created at:
main.main()
enoughgo/04-race/main.go:24 +0x6c
==================
expected 400000, got 342083
Found 2 data race(s)
exit status 66
Read each report from the top: what was touched (Read at), where (line 27, count++), which other goroutine touched it before with nothing in between, and where both were started (line 24, the go func()). Exit status 66 is the race detector’s default failure code, so a test run with -race fails loudly. The count came out different again (342,083) because the detector slows every access and changes the timing; it finds the race without the count needing to be wrong.
Where each word lives in the builds
One row per word: the word, the real line, and the file it is in.
| Word | Real line | File |
|---|---|---|
| error | ln, err := net.Listen("tcp", "127.0.0.1:6379") | redis/01-socket/main.go, line 23 |
| defer | defer conn.Close() | redis/01-socket/main.go, line 38 |
| map | var store = map[string]string{} | redis/03-store/main.go, line 25 |
| struct and method | func (db *DB) set(key, val string) | database/01-append/main.go, line 28 |
| slice | z.keys = append(z.keys, y.keys[t:]...) | database/04-btree/main.go, line 77 |
| goroutine | go handle(conn) | redis/04-eventloop/main.go, line 44 |
| goroutine, in the background | go activeExpiryLoop() | redis/05-expiry/main.go, line 44 |
| channel | sem <- struct{}{} | http/06-concurrent/main.go, line 60 |
| mutex | mu sync.Mutex | redis/06-eviction/main.go, line 35 |
| read-write mutex | mu sync.RWMutex | redis/04-eventloop/main.go, line 32 |
The last row is a mutex with two kinds of key: many goroutines may hold a reading key at once (mu.RLock()), but a writing key (mu.Lock()) waits until nobody holds either. The Redis build uses it because reads outnumber writes. Everything else the builds use (bufio, strings, os.OpenFile, sort, container/heap) is a standard-library package, and each build’s own lesson explains the one it reaches for.
Try it
In teaching/code/from-scratch/enough-go, run go vet ./... first. go vet reads the code without running it and flags mistakes a compiler allows; it prints nothing when all four programs are clean. Then five-minute experiments:
- In
04-race/main.go, add async.Mutexand wrapcount++inmu.Lock()andmu.Unlock(). Rerun both commands. The count comes out at exactly 400,000 and-raceprints no report. - In
03-goroutines/main.go, change the rail tomake(chan Ticket)(no hooks at all). It still cooks 12 tickets: every send now waits for a cook to be ready to take it by hand. - In
02-shapes/main.go, delete theif !okcheck inCook(and the, okit reads) and rerun. Table 4 now sayspizza: sold outinstead ofno such dish: a name missing from a map reads as 0, and 0 portions is less than 1.go teststill passes, so write a test that catches it. - In
01-errors/main.go, delete the worddeferfrom theservefunction’s close line.close aliceandclose bobnow print straight afteropen, before any work: withoutdefer, the line runs where it is written.
Go deeper
- A Tour of Go (the official interactive tour; the “Concurrency” section is this lesson’s second half)
- Effective Go (the house style every build follows)
- Go FAQ, “What is the history of the project?” (the whiteboard date and the open-source date)
- Rob Pike, “Go at Google: Language Design in the Service of Software Engineering”, 2012 (the 45-minute build)
- Data Race Detector (what
-racewatches, and its options).
Related
- Build Your Own Redis - From Scratch is the first build, and the one whose lines this lesson quotes most.
- Redis: One Thread, Many Clients is where
go handle(conn)and the read-write mutex arrive in the Redis build, and why real Redis needs no lock at all. - HTTP Server: Concurrency is the web server’s 100-hook channel, in full.
- Build Your Own Database - From Scratch is where structs and methods carry a whole build.
- Processes, Threads & the Kernel in How Computers Work is what the operating system does underneath goroutines: the real threads they are shared across.
- S1 · Build Your Own Database (for real) in Portfolio Projects is where these builds become repos of your own; its “Get the code” section covers the one piece of Go this lesson leaves out,
go mod init, and whatgo test ./...says without it.
The full From Scratch list is in CURRICULUM.
Retrieval practice
8 questions. Answer each one before you open it.
A Go function returns (v, err). What do you do before touching v, and why does Go work this way instead of throwing?
Check if err != nil on the next line, and stop or handle it if the error is not empty. The error is an ordinary value handed back beside the answer, so nothing jumps past your code: the failure is handled exactly where it happened, in plain sight.
Why does the Redis build write defer conn.Close() at the top of handle rather than conn.Close() at the bottom?
A deferred call runs whenever the function returns, by any path. handle can return early when the visitor disconnects or sends garbage; a close written at the bottom would be skipped on those paths and the connection would leak.
What does the single word go change in go handle(conn), and what does it cost compared with hiring a cook?
It starts handle running alongside the rest of the program, and the accept loop continues at once to the next visitor. A goroutine is cheap: the lesson’s program started 100,000 of them in 78 ms on a laptop, and Go runs them all on a handful of processor cores.
make(chan Ticket, 4) builds a channel with room for 4. What happens when a fifth ticket is sent before any is taken, and how does the HTTP build use that?
The send waits until a receiver takes one off. The HTTP build makes a channel of 100 empty slots, sends one before starting each visitor’s goroutine and takes it back when the visitor is done, so the 101st visitor waits: a limit on work in flight.
Four goroutines each add 100,000 to a shared counter with no lock. Why is the total short, and what fixes it?
count++ is read, add one, write back. Two goroutines can read the same old value and both write the same new one, so an addition is lost; one run printed 195,493 instead of 400,000. A mutex around the increment (mu.Lock(), mu.Unlock()) lets only one goroutine do the three steps at a time.
What does go run -race report, and why does it matter that it does not need the answer to be wrong?
It watches every memory access while the program runs and prints each pair of goroutines that touched the same data with no lock ordering them, with both lines and where each goroutine started, then exits with status 66. A race can give the right answer on one run and the wrong one on the next, so a check that waits for a wrong answer misses it.
Name the three shapes data takes in these builds and one real use of each.
A map finds a value by name (Redis’s database is map[string]string). A struct bundles named fields, with methods attached (the database build’s DB and its set). A slice is a list that grows with append (the B-tree’s keys).
What is the difference between go run ./02-shapes and go test ./02-shapes?
go run compiles the folder and runs its main function, which is how every build stage runs. go test compiles it with the folder’s _test.go files and runs every TestSomething function, passing or failing each; it is how you check a change you made.