Synchronization

Updated

September 13, 2026

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 1: Mutex around shared state

Save as mutex_tables.go. Several windows seat names into one map. The map is the shared state. The mutex is the rule.

// mutex_tables.go
package main

import (
    "fmt"
    "sync"
)

func main() {
    var mu sync.Mutex
    tables := map[int]string{}
    var wg sync.WaitGroup
    seats := []struct {
        n    int
        name string
    }{
        {12, "Amina"},
        {3, "Bo"},
        {7, "Chen"},
    }
    for _, s := range seats {
        wg.Go(func() {
            mu.Lock()
            tables[s.n] = s.name
            mu.Unlock()
        })
    }
    wg.Wait()
    mu.Lock()
    fmt.Println("table 3:", tables[3])
    fmt.Println("table 7:", tables[7])
    fmt.Println("table 12:", tables[12])
    mu.Unlock()
}

Run:

go run mutex_tables.go

Output:

table 3: Bo
table 7: Chen
table 12: Amina

The mutex makes each write whole. It does not decide business rules. If “table 12 already seated” should error, check under the same lock.

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.go

Output:

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.go

Output (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.go

Output:

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.go

Possible output (any value up to 1000; 1000 is luck, not proof):

1000

The race detector is the check that matters:

go run -race race.go

You 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 -race

The 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, Unlock with defer if 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.
  • Once for one-time setup. Not for request logic.
  • atomic for a counter or a flag. Not for a map.
  • Run go test -race in 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

  1. In mutex_tables.go, delete both mu.Lock() / mu.Unlock() pairs inside the goroutines. Run go run -race mutex_tables.go. Put the locks back.
  2. In own_ticket.go, after out <- t, add t.Note = "changed in kitchen" in kitchen. You will not see it on the printer — the copy already left. Then change the channel type to chan *Ticket and send &t. Now the late write is a share. Run -race if you write from both sides.
  3. In atomic_count.go, replace atomic.Int64 with a plain int and n++. Compare go run and go run -race.
  4. Add defer mu.Unlock() immediately after mu.Lock() in Case 1’s loop body, and delete the manual Unlock. Confirm the program still prints.