The sync Package
Channels are great for passing data between goroutines, but sometimes many goroutines simply need to read and update one shared thing: a page-visit counter, a cache, a map of logged-in users. For that, the sync package gives you locks, and the sync/atomic package gives you counters that are safe without locks.
In this lesson you will fix the data race from Goroutines & WaitGroup with a Mutex, allow many readers with RWMutex, run setup code exactly once with sync.OnceValue, use atomic counters, and learn how to choose between a mutex and a channel.
sync.Mutex: one goroutine at a time
A mutex (short for mutual exclusion) is a lock. Lock() waits until no other goroutine holds the lock and then takes it; Unlock() releases it. Code between them, the critical section, runs in only one goroutine at a time.
The usual style puts the mutex inside the struct it protects, next to the data, and locks it in every method:
package main
import (
"fmt"
"sync"
)
type VisitCounter struct {
mu sync.Mutex
counts map[string]int
}
func (c *VisitCounter) Add(page string) {
c.mu.Lock()
defer c.mu.Unlock()
c.counts[page]++
}
func (c *VisitCounter) Get(page string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.counts[page]
}
func main() {
c := &VisitCounter{counts: make(map[string]int)}
var wg sync.WaitGroup
for i := range 1000 {
wg.Go(func() {
if i%4 == 0 {
c.Add("/offers")
} else {
c.Add("/home")
}
})
}
wg.Wait()
fmt.Println(c.Get("/home"), c.Get("/offers"))
}750 250
Every fourth visitor goes to /offers, so 250 of the 1,000 visits land there and 750 on /home, on every run. Without the mutex, concurrent writes to the map could lose updates or crash with "concurrent map writes".
defer c.mu.Unlock() right after Lock() guarantees the lock is released however the method returns, even if it panics. The zero value of a sync.Mutex is an unlocked mutex, so no setup is needed. Keep the field unexported (mu) so code outside the package cannot lock it by mistake.
sync.RWMutex: many readers, one writer
If data is read far more often than it is written, such as a price list that changes once a day, an RWMutex lets many readers in together. Readers call RLock/RUnlock; a writer calls Lock/Unlock and waits until all readers leave.
package main
import (
"fmt"
"slices"
"sync"
)
type PriceList struct {
mu sync.RWMutex
prices map[string]int
}
func (p *PriceList) Get(item string) (int, bool) {
p.mu.RLock()
defer p.mu.RUnlock()
price, ok := p.prices[item]
return price, ok
}
func (p *PriceList) Set(item string, price int) {
p.mu.Lock()
defer p.mu.Unlock()
p.prices[item] = price
}
func main() {
list := &PriceList{prices: map[string]int{"atta 5kg": 260}}
var wg sync.WaitGroup
found := make([]bool, 50)
for i := range 50 {
wg.Go(func() {
_, found[i] = list.Get("atta 5kg")
})
}
wg.Wait()
list.Set("atta 5kg", 275)
price, _ := list.Get("atta 5kg")
fmt.Println("all readers found it:", !slices.Contains(found, false))
fmt.Println("new price:", price)
}all readers found it: true new price: 275
Fifty readers share the read lock. Each writes only its own slot in found, so that slice needs no lock. An RWMutex has more overhead than a plain Mutex, so use it only when reads clearly outnumber writes.
Running something once
Loading a configuration file or opening a database pool should happen once, the first time it is needed, even if many goroutines ask at the same moment. sync.OnceValue (Go 1.21) wraps a function so it runs only on the first call; later calls return the saved result:
package main
import (
"fmt"
"sync"
)
var loadConfig = sync.OnceValue(func() map[string]string {
fmt.Println("loading config (runs once)")
return map[string]string{"city": "Jaipur", "currency": "INR"}
})
func main() {
var wg sync.WaitGroup
for range 3 {
wg.Go(func() {
_ = loadConfig()
})
}
wg.Wait()
fmt.Println(loadConfig()["currency"])
}loading config (runs once) INR
Four calls, one load. sync.OnceValues does the same for functions that return a value and an error, and sync.OnceFunc for functions with no result. Older code uses sync.Once with its Do method, which still works.
Atomic counters
For a single number, a lock is more than you need. The sync/atomic package has types whose operations happen in one indivisible step:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var downloads atomic.Int64
var wg sync.WaitGroup
for range 500 {
wg.Go(func() {
downloads.Add(1)
})
}
wg.Wait()
fmt.Println("downloads:", downloads.Load())
}downloads: 500
The types atomic.Int64, atomic.Int32, atomic.Bool and atomic.Pointer[T] (Go 1.19) have methods Add, Load, Store and CompareAndSwap. Use atomics only for simple counters and flags; as soon as two values must change together, use a mutex.
Mutex or channel?
Both are correct tools. A simple guide:
| Situation | Prefer |
|---|---|
| Protecting a shared map, cache or counter inside a type | Mutex / RWMutex / atomic |
| Passing ownership of data from one goroutine to another | Channel |
| Distributing work to a pool of workers | Channel |
| Signalling "done" or "stop" to many goroutines | Closing a channel, or context |
Choose whichever makes the code simplest to read. The package also has sync.Map, a concurrent map tuned for special cases (keys written once and read many times); for most programs a plain map with a mutex is clearer.
Common mistakes
- Forgetting to unlock. A missing
Unlockon one return path freezes every other goroutine. Usedefer. - Locking twice in the same goroutine. Go mutexes are not re-entrant: calling
Lockagain while holding it deadlocks. - Copying a struct that contains a mutex. Use pointer receivers and pass pointers.
- Holding a lock during slow work. Do network calls and file reads outside the critical section.
Key points
sync.Mutexlets one goroutine at a time into a critical section; pairLockwithdefer Unlock.sync.RWMutexallows many readers or one writer, for read-heavy data.sync.OnceValue,OnceValuesandOnceFunc(Go 1.21) run initialisation exactly once.atomic.Int64and friends give lock-free counters withAddandLoad.- Use mutexes to guard state and channels to pass data and signals.
Exercise
A railway reservation counter in Lucknow has 100 seats. Write a Train type with a mutex, a seats field and a method Book() bool that reduces the seats by one and returns true, or returns false when none are left. Start 120 goroutines that each try to book one seat, count successful bookings with an atomic.Int64, and print the bookings and the seats left.
Show solution
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type Train struct {
mu sync.Mutex
seats int
}
func (t *Train) Book() bool {
t.mu.Lock()
defer t.mu.Unlock()
if t.seats == 0 {
return false
}
t.seats--
return true
}
func main() {
train := &Train{seats: 100}
var booked atomic.Int64
var wg sync.WaitGroup
for range 120 {
wg.Go(func() {
if train.Book() {
booked.Add(1)
}
})
}
wg.Wait()
fmt.Println("Booked:", booked.Load(), "Seats left:", train.seats)
}Booked: 100 Seats left: 0
The check and the decrement happen inside one critical section, so two passengers can never get the last seat. Reading train.seats directly is safe here only because every goroutine has finished after Wait.
Quick Quiz
1. What does defer mu.Unlock() right after mu.Lock() guarantee?
Deferred calls run on every return path, including panics.
2. When is sync.RWMutex a good choice?
Many readers can hold the read lock together; writers get exclusive access.
3. What does sync.OnceValue(f) return?
OnceValue wraps f so it runs only once, however many goroutines call it.
4. What happens if a method with a value receiver locks a mutex field?
The receiver is a copy, including the mutex; go vet reports "passes lock by value".
Learn Go with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
