Synchronization
Synchronization
Two goroutines that both touch the same variable are sharing memory. The boring question is not “how do I make this faster?” It is who owns this value right now? A mutex says “we share, but only one at a time.” A channel says “I am giving this to you.” Prefer the second when you can. Use the first when a shared structure is the whole point.
Mental model
A mutex (sync.Mutex) is a lock. Lock then Unlock. The code between them is the critical section. Maps, slices, and ordinary integers are not safe for concurrent write. The mutex does not mark the data. You have to remember to take it.
A channel moves ownership. After ch <- ticket, the sender should not touch ticket again. After ticket := <-ch, the receiver is the owner. No lock, because there is no share.
sync.Once runs a function at most once, even if many goroutines call Do. Use it for process-wide setup (load the menu file), not for “call this sometimes.”
atomic operations (atomic.Int64) are for simple counters and flags. They are not a general replacement for a mutex around a map.
A data race is two goroutines accessing the same memory, at least one writing, with no happens-before. The race detector (go test -race / go run -race) finds many of them. A race is a bug even if the printed number looks right today.
Worked examples
Case 2: Channel as ownership handoff
Save as own_ticket.go. The kitchen fills a ticket and sends it. The printer is the only goroutine that edits the note after the send.
// own_ticket.go
package main
import "fmt"
type Ticket struct {
ID int
Note string
}
func kitchen(out chan<- Ticket) {
t := Ticket{ID: 7, Note: "no onions"}
out <- t
}
func main() {
ch := make(chan Ticket)
go kitchen(ch)
t := <-ch
t.Note = t.Note + ", extra napkins"
fmt.Printf("ticket %d: %s\n", t.ID, t.Note)
}Run:
go run own_ticket.goOutput:
ticket 7: no onions, extra napkins
No mutex. The value crossed the channel. If kitchen kept using t after the send, that would be a share again — send a copy and stop touching it.
Case 3: sync.Once
Save as once_menu.go. Three windows ask for the menu. The file is “loaded” once.
// once_menu.go
package main
import (
"fmt"
"sync"
)
func main() {
var once sync.Once
load := func() {
fmt.Println("loaded menu")
}
var wg sync.WaitGroup
for range 3 {
wg.Go(func() {
once.Do(load)
fmt.Println("menu ready")
})
}
wg.Wait()
}Run:
go run once_menu.goOutput (the three menu ready lines may appear in any order; loaded menu prints once, before the Do calls return):
loaded menu
menu ready
menu ready
menu ready
Do waits if another goroutine is already inside load. Callers never see a half-loaded menu.
Case 4: Atomic counter
Save as atomic_count.go. A thousand increments, no mutex. atomic.Int64 is the whole state.
// atomic_count.go
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var n atomic.Int64
var wg sync.WaitGroup
for range 1000 {
wg.Go(func() {
n.Add(1)
})
}
wg.Wait()
fmt.Println(n.Load())
}Run:
go run atomic_count.goOutput:
1000
If the “counter” grows a second field (last ticket id, last name), you are back to a mutex. Atomics do not compose into a critical section.
The trap
Save as race.go. Two goroutines increment a plain int. That is a data race.
// race.go
package main
import (
"fmt"
"sync"
)
func main() {
var n int
var wg sync.WaitGroup
for range 1000 {
wg.Go(func() {
n++
})
}
wg.Wait()
fmt.Println(n)
}Run without the detector — the number may look fine or not:
go run race.goPossible output (any value up to 1000; 1000 is luck, not proof):
1000
The race detector is the check that matters:
go run -race race.goYou should see WARNING: DATA RACE (file names and goroutine ids vary) and a non-zero exit. Treat that as a failed build.
The same check in a module (go.mod with module desk, plus the test file):
// race_test.go
package desk
import (
"sync"
"testing"
)
func TestTicketCountRace(t *testing.T) {
var n int
var wg sync.WaitGroup
for range 100 {
wg.Go(func() {
n++
})
}
wg.Wait()
if n != 100 {
t.Fatalf("got %d", n)
}
}Run:
go test -raceThe count assertion may pass or fail. The detector still prints WARNING: DATA RACE and fails the command when it sees the race. Fix: a mutex around n++, or atomic.Int64 as in Case 4. For a one-file check, prefer go run -race race.go.
The boring rule
- Mutex: shared structure, short critical section,
Unlockwithdeferif the section has more than one return. - Channel: hand off a value. Do not use both a mutex and a channel on the same data without a drawing.
Oncefor one-time setup. Not for request logic.atomicfor a counter or a flag. Not for a map.- Run
go test -racein CI. A silent race is still a race. - Do not share a slice header or a map with append/assign from two goroutines.
Try this
- In
mutex_tables.go, delete bothmu.Lock()/mu.Unlock()pairs inside the goroutines. Rungo run -race mutex_tables.go. Put the locks back. - In
own_ticket.go, afterout <- t, addt.Note = "changed in kitchen"inkitchen. You will not see it on the printer — the copy already left. Then change the channel type tochan *Ticketand send&t. Now the late write is a share. Run-raceif you write from both sides. - In
atomic_count.go, replaceatomic.Int64with a plainintandn++. Comparego runandgo run -race. - Add
defer mu.Unlock()immediately aftermu.Lock()in Case 1’s loop body, and delete the manualUnlock. Confirm the program still prints.