Skip to content

Intermediate · Concurrency: goroutines, channels and context · topic 29 of 46 · 35 min

Mutex, Once, atomic and the race detector

Explanation

A data race happens when two goroutines touch the same variable at the same time and at least one writes. The result is unpredictable. sync.Mutex lets only one goroutine at a time into a critical section; RWMutex allows many readers; sync.Once runs set-up exactly once; sync/atomic gives fast safe counters. go test -race finds races for you.

A race in five lines

go
count := 0
var wg sync.WaitGroup
for range 1000 {
	wg.Add(1)
	go func() {
		defer wg.Done()
		count++ // read, add, write: three steps, not one
	}()
}
wg.Wait()
fmt.Println(count) // often less than 1000, and different each run
Two goroutines can read 41 at the same time, both write 42, and one increment is lost.

A data race is when two goroutines access the same memory at the same time, at least one of them writes, and nothing orders the accesses. Races cause wrong totals, corrupted maps, and crashes that happen once a week in production and never on your laptop.

The race detector

bash
go run -race main.go
go test -race ./...
# ==================
# WARNING: DATA RACE
# Read at 0x... by goroutine 8: ...
# Previous write at 0x... by goroutine 7: ...
The race detector watches memory accesses while the program runs and reports races with stack traces.
Tip: Run your tests with -race in CI. It only finds races in code that actually runs during the test, and it makes programs slower and use more memory, so it is for testing, not for production builds.

sync.Mutex: one at a time

A mutex (mutual exclusion lock) protects a critical section: code that only one goroutine may run at a time. Lock waits until the mutex is free and takes it; Unlock releases it. The zero value of a Mutex is ready to use.

go
type Counter struct {
	mu sync.Mutex
	n  int
}

func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

func (c *Counter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.n
}
Keep the mutex next to the data it protects, and use pointer receivers so the mutex is never copied.
Careful: Never copy a struct that contains a Mutex after first use (value receivers do copy!). go vet's copylocks check warns about this. Also: forgetting to unlock deadlocks the next caller, so always pair Lock with defer Unlock.

RWMutex: many readers, one writer

go
type PriceList struct {
	mu     sync.RWMutex
	prices map[string]int
}

func (p *PriceList) Get(item string) (int, bool) {
	p.mu.RLock()            // many readers at once
	defer p.mu.RUnlock()
	v, ok := p.prices[item]
	return v, ok
}

func (p *PriceList) Set(item string, v int) {
	p.mu.Lock()             // writers are exclusive
	defer p.mu.Unlock()
	p.prices[item] = v
}
Use RWMutex when reads are far more common than writes. Otherwise a plain Mutex is simpler and often just as fast.

sync.Once: run set-up exactly once

go
var (
	once   sync.Once
	config map[string]string
)

func Config() map[string]string {
	once.Do(func() {
		fmt.Println("loading config...")
		config = map[string]string{"region": "ap-south-1"}
	})
	return config
}
However many goroutines call Config at the same time, the loading function runs once. Go 1.21 also added sync.OnceFunc and sync.OnceValue helpers.

sync/atomic: fast counters

go
var hits atomic.Int64   // Go 1.19+ typed atomics

func handler() {
	hits.Add(1)
}

func report() {
	fmt.Println("hits:", hits.Load())
}
Atomic operations are single, indivisible CPU steps. Good for counters and flags; for anything more complex, use a mutex.

Channels or mutex?

Use channels whenUse a mutex when
Passing ownership of data from one goroutine to anotherSeveral goroutines read and update shared state (a cache, a counter, a map)
Building pipelines and worker poolsThe critical section is small and simple
Signalling events (done, cancel)You would otherwise need a channel just to protect a variable

Both are correct tools. Pick the one that makes the code simplest to read. sync.Map exists for special cases (keys written once and read many times, or goroutines working on separate keys), but a plain map plus a mutex is the usual choice.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Mutex, Once, atomic and the race detector · 1/7

Races and sync

When goroutines share data, you must protect it. Otherwise you get data races.

Prefer a video by a teacher? Find free videos on “golang mutex rwmutex sync once atomic race detector tutorial” on YouTube ↗

In real life: A rate-limited OTP counter

An OTP service allows at most 3 OTP requests per phone number per window. Requests arrive on many goroutines at once (net/http runs each in its own goroutine), so the map of counts is protected by a mutex. Without it, the map could be corrupted or Go could crash with "concurrent map writes".

go
package main

import (
	"fmt"
	"sync"
)

type OTPLimiter struct {
	mu     sync.Mutex
	counts map[string]int
	limit  int
}

func (l *OTPLimiter) Allow(phone string) bool {
	l.mu.Lock()
	defer l.mu.Unlock()
	if l.counts[phone] >= l.limit {
		return false
	}
	l.counts[phone]++
	return true
}

func main() {
	l := &OTPLimiter{counts: map[string]int{}, limit: 3}
	var wg sync.WaitGroup
	var allowed sync.Map // just to show the results safely
	for i := range 10 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			allowed.Store(i, l.Allow("98XXXXXX10"))
		}()
	}
	wg.Wait()
	n := 0
	allowed.Range(func(_, v any) bool {
		if v.(bool) {
			n++
		}
		return true
	})
	fmt.Println("allowed:", n) // always 3
}
Run it with go run -race: no race is reported, and exactly 3 requests are allowed every time.

Practice (0/4 done)

Task 1 · Question

1000 goroutines each run count++ on a shared int with no lock. What is printed afterwards?

Task 2 · Question

Which line is the bug? 1: type Stats struct { mu sync.Mutex; n int } 2: func (s Stats) Inc() { 3: s.mu.Lock() 4: defer s.mu.Unlock() 5: s.n++ 6: }

Task 3 · Question

Which tool reports "WARNING: DATA RACE"?

Task 4 · Practice

Write a SafeBalance type with a mutex-protected int64 balance and methods Deposit, Withdraw (returns an error if not enough) and Balance. Start 100 goroutines that each deposit 10 and check the final balance is 1000. Run it with -race.

Quick quiz

Question 1

1.What is a data race?

Question 2

2.When is RWMutex a good choice?

Question 3

3.What does sync.Once guarantee?

Question 4

4.Is the zero value of sync.Mutex ready to use?

Question 5

5.Which is best for a simple hit counter shared by many goroutines?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

What is a data race?

Answer: Unsynchronised concurrent access to the same memory where at least one access is a write

That is the precise definition used by the race detector.

When is RWMutex a good choice?

Answer: When reads greatly outnumber writes

Readers can hold the lock together; writers get exclusive access.

What does sync.Once guarantee?

Answer: A function runs at most once, even if called from many goroutines

Once.Do runs its function exactly once.

Is the zero value of sync.Mutex ready to use?

Answer: Yes

var mu sync.Mutex is an unlocked, usable mutex.

Which is best for a simple hit counter shared by many goroutines?

Answer: atomic.Int64

atomic.Int64 gives safe, fast Add and Load.

What is a mutex for?Related

Answer: Allowing only one thread at a time into a section that touches shared data

Mutual exclusion prevents simultaneous access.

From Concurrency 1: threads, mutexes and data races

What does a Mutex guarantee?Related

Answer: Only one thread accesses the data at a time

Mutual exclusion is its purpose.

From Shared state: Arc, Mutex, Send and Sync

Which flag finds data races in concurrent code?Related

Answer: -race

-race builds with the race detector enabled.

From Testing with go test

A goroutine panics without recover. What happens?Related

Answer: The whole program crashes

An unrecovered panic in any goroutine terminates the process.

From Concurrency interview questions and coding rounds

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. 1 more topic in “Concurrency: goroutines, channels and context” before its exam: context: timeouts and cancellation.
  2. Module 6 exam: Concurrency · 15 questions · 25 min, without help. 5 topics of this module still to finish first.
  3. Then the next module, Standard library essentials. 46 topics left in the course before the final exam.

← Previous: select: waiting on several channels