Sync & Common Mistakes
Channels are Go's preferred concurrency primitive, but sometimes shared state is the simpler solution. The sync package provides mutexes, wait groups, and one-time initialization for cases where channels would be overkill. This chapter covers those tools and the mistakes that catch every Go developer eventually.
sync.Mutex
A mutex (mutual exclusion lock) protects shared data from concurrent access. Only one goroutine can hold the lock at a time.
type SafeCounter struct {
mu sync.Mutex
count map[string]int
}
func NewSafeCounter() *SafeCounter {
return &SafeCounter{
count: make(map[string]int),
}
}
func (c *SafeCounter) Increment(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.count[key]++
}
func (c *SafeCounter) Get(key string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count[key]
}
The pattern is always the same: lock, defer unlock, do work. Using defer ensures the mutex is unlocked even if the code panics.
Keep the Critical Section Small
Lock only what needs to be locked. Do not hold a mutex while doing I/O, network calls, or anything slow.
// Release the lock before slow operations like network calls
func (c *Cache) GetOrFetch(key string) (string, error) {
c.mu.Lock()
if val, ok := c.data[key]; ok {
c.mu.Unlock()
return val, nil
}
c.mu.Unlock()
val, err := fetchFromAPI(key)
if err != nil {
return "", err
}
c.mu.Lock()
c.data[key] = val
c.mu.Unlock()
return val, nil
}
sync.RWMutex
An RWMutex allows multiple concurrent readers or one exclusive writer. Use it when reads vastly outnumber writes.
type Config struct {
mu sync.RWMutex
settings map[string]string
}
func (c *Config) Get(key string) string {
c.mu.RLock() // Multiple goroutines can read simultaneously
defer c.mu.RUnlock()
return c.settings[key]
}
func (c *Config) Set(key, value string) {
c.mu.Lock() // Exclusive access for writes
defer c.mu.Unlock()
c.settings[key] = value
}
RLock allows concurrent reads. Lock blocks until all readers release their locks, then holds exclusive access. Use RWMutex when you have many readers and infrequent writers. If reads and writes are roughly equal, a plain Mutex has less overhead.
sync.WaitGroup
A WaitGroup waits for a collection of goroutines to finish. It is the simplest way to "join" goroutines.
func processAll(items []Item) {
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(it Item) {
defer wg.Done()
process(it)
}(item)
}
wg.Wait() // Blocks until all goroutines call Done
fmt.Println("all items processed")
}
Three methods:
Add(n)-- increment the counter by n (call before launching the goroutine)Done()-- decrement the counter by 1 (call when the goroutine finishes)Wait()-- block until the counter reaches 0
WaitGroup Rules
Always call Add before launching the goroutine, not inside it. Otherwise there is a race between Wait and Add.
// Wrong: race condition
for _, item := range items {
go func(it Item) {
wg.Add(1) // May execute after wg.Wait()
defer wg.Done()
process(it)
}(item)
}
wg.Wait()
// Right: Add before go
for _, item := range items {
wg.Add(1)
go func(it Item) {
defer wg.Done()
process(it)
}(item)
}
wg.Wait()
sync.Once
sync.Once guarantees that a function is executed exactly once, no matter how many goroutines call it.
type DBPool struct {
once sync.Once
pool *pgxpool.Pool
}
func (d *DBPool) Get(ctx context.Context) (*pgxpool.Pool, error) {
var err error
d.once.Do(func() {
d.pool, err = pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
})
if err != nil {
return nil, fmt.Errorf("initializing database pool: %w", err)
}
return d.pool, nil
}
The first goroutine to call Get initializes the pool. All subsequent calls return the already-initialized pool without running the initialization function again. Other goroutines that arrive while initialization is in progress block until it completes.
A common use is for lazy singleton initialization:
var (
instance *Service
once sync.Once
)
func GetService() *Service {
once.Do(func() {
instance = &Service{
client: http.DefaultClient,
cache: make(map[string]string),
}
})
return instance
}
The Race Detector
Go's race detector is one of its most valuable tools. It finds data races at runtime by instrumenting memory accesses.
$ go test -race ./...
$ go run -race main.go
$ go build -race -o myservice
When a race is detected, you get a detailed report:
WARNING: DATA RACE
Read at 0x00c0000a4010 by goroutine 7:
main.main.func2()
/home/user/main.go:18 +0x38
Previous write at 0x00c0000a4010 by goroutine 6:
main.main.func1()
/home/user/main.go:13 +0x50
Goroutine 7 (running) created at:
main.main()
/home/user/main.go:17 +0xf4
Goroutine 6 (finished) created at:
main.main()
/home/user/main.go:12 +0xc8
This tells you exactly which goroutines are involved, which lines of code contain the conflicting accesses, and where the goroutines were created.
Run your tests with -race in CI. Always. The overhead is about 5-10x slower and 5-10x more memory, which is fine for tests.
Common Mistake: Shared Variable in Loop
Before Go 1.22, the loop variable was reused across iterations. Capturing it in a closure gave every goroutine the same variable.
// Bug in Go < 1.22
func buggyLoop() {
urls := []string{"a.com", "b.com", "c.com"}
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(url) // All goroutines print "c.com"
}()
}
wg.Wait()
}
c.com
c.com
c.com
The fix (works in all Go versions): pass the variable as a function argument.
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
fmt.Println(u)
}(url)
}
Go 1.22 changed the semantics so each loop iteration gets its own variable. But passing as an argument is still good practice -- it works everywhere and makes the intent explicit.
Common Mistake: Copying a Mutex
A sync.Mutex must not be copied after first use. Passing a struct containing a mutex by value copies the mutex, and the copy is independent of the original -- defeating the purpose.
type Cache struct {
mu sync.Mutex
data map[string]string
}
// Bug: c is a copy. The mutex in c is independent of the original.
func broken(c Cache) {
c.mu.Lock()
defer c.mu.Unlock()
// This lock does not protect the original Cache
}
// Correct: use a pointer
func correct(c *Cache) {
c.mu.Lock()
defer c.mu.Unlock()
c.data["key"] = "value"
}
go vet catches this mistake:
$ go vet ./...
./main.go:15:14: broken passes lock by value: main.Cache contains sync.Mutex
Common Pitfalls
- Using sync primitives when channels would be clearer. If you are coordinating independent tasks, channels and
selectare usually more readable than mutexes and condition variables. - Holding a mutex during I/O. This serializes all concurrent access behind the slowest I/O operation. Lock, read/write shared state, unlock. Do everything else outside the lock.
- Recursive locking. Go's
sync.Mutexis not reentrant. CallingLockfrom a goroutine that already holds the lock deadlocks. Restructure your code so that locked helper functions do not re-lock. - Forgetting to call wg.Done. A missed
Donecall meansWaitblocks forever. Always usedefer wg.Done(). - Not running the race detector in CI. Data races may not manifest in local testing. Run
go test -race ./...in every CI build.
Key Takeaways
sync.Mutexprotects shared state. Lock, defer unlock, do work. Keep critical sections small.sync.RWMutexallows concurrent reads withRLock. Use it when reads vastly outnumber writes.sync.WaitGroupwaits for goroutines to finish. CallAddbefore launching,Donewhen finished,Waitto block.sync.Onceexecutes a function exactly once, safely across goroutines. Use it for lazy initialization.- Run
go test -racein CI. The race detector catches data races that are invisible during normal testing. - Never copy a mutex. Pass structs containing mutexes by pointer.
- The most common concurrency bugs: goroutine leaks, closing channels from the wrong side, shared loop variables (pre-1.22), forgetting to wait, and holding locks during slow operations.