Recommended resources
The freely available resources this lesson will direct you to.
Hello. In the previous lesson, you used Go’s race detector to find two goroutines incrementing the same counter without synchronization. The test waited for both goroutines to finish, yet their increments could still conflict. This lesson fixes that conflict with a sync.Mutex, then checks both the result and the detector output. Allow about 35–40 minutes, including the code and test runs.
Put the entire update inside a critical section
The counter problem is not just that two goroutines might write at once. Each count++ must read the old value, calculate a new one, and store it. If both goroutines read the same old value, one increment can be lost.

A mutex makes a region of code mutually exclusive: a goroutine calls Lock before entering that region and Unlock after leaving it. Another goroutine attempting to lock the same mutex must wait. For the counter, the region must contain the whole read-modify-write operation, not just its read or its write.
Watch the short explanation and Go implementation in James Cutajar’s Go Lang’s Mutex for Thread Synchronization. Its account-balance example has the same read-modify-write problem as the counter.
Go Lang's Mutex for Thread Synchronization
Watch Go Lang's Mutex for Thread Synchronization by James Cutajar for the intuition behind protecting one shared value and for a concrete sync.Mutex implementation.
Watch mutex behavior for why a second updater waits, then the Go fix for the placement of Lock and Unlock. Focus on the code surrounding each update. The video's repeated final values are a useful demonstration, but you will also verify the fix with -race; a correct-looking value alone is not enough.
There is a second rule that is easy to miss: every access to the protected state must follow the same locking rule. Locking writes while leaving a concurrent read unprotected still permits a race. The Tour of Go shows this with a map-backed counter, whose update and lookup methods acquire its mutex.
Read the Tour of Go’s sync.Mutex page for the connection between mutual exclusion and a small Go type that owns its protected state.
On the Concurrency > “sync.Mutex” page (step 9/11), read the motivation. Then find the SafeCounter code below the explanation. Study its two methods, from the struct declaration through the end of Value, stopping before main. Notice that Inc locks around the map update and Value locks around the map read. For your own test, keep the completion signals from the previous lesson rather than using the example program’s sleep to wait for goroutines.
Repair the counter test
In the disposable module from the previous lesson, replace counter_test.go with the following. It keeps the two concurrent writers and adds a reader so the test also exercises the rule for reading shared state.
package raceprobe
import (
"sync"
"testing"
)
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Inc() {
c.mu.Lock()
c.value++
c.mu.Unlock()
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
func TestConcurrentCount(t *testing.T) {
var counter Counter
start := make(chan struct{})
done := make(chan struct{}, 3)
for worker := 0; worker < 2; worker++ {
go func() {
<-start
for i := 0; i < 10000; i++ {
counter.Inc()
}
done <- struct{}{}
}()
}
go func() {
<-start
for i := 0; i < 10000; i++ {
_ = counter.Value()
}
done <- struct{}{}
}()
close(start)
<-done
<-done
<-done
const want = 20000
if got := counter.Value(); got != want {
t.Fatalf("counter = %d; want %d", got, want)
}
}
The Counter keeps its mutex beside the value it protects. Its methods use pointer receivers so calls operate on that same counter and mutex; do not copy a Counter after it has been used. A mutex’s zero value is ready to use, so var counter Counter needs no separate initialization.
Inc holds the lock across value++. Value takes that same lock, including when the reader goroutine runs alongside the writers. Its defer releases the lock when the method returns. The explicit Unlock in Inc makes the short update’s boundary particularly visible.
The start and done channels serve a different purpose. They start the workers together and let the test know when all three have finished. They do not replace the mutex: while the workers are running, it is the mutex that coordinates their accesses to value.
Check the fix—and its boundary
Run the focused test under the race detector, then run it repeatedly:
go test -race -run '^TestConcurrentCount$' -count=1 -v
go test -race -run '^TestConcurrentCount$' -count=10
You should see passing tests, no WARNING: DATA RACE, and a final value of 20000 on every run. The assertion checks the counter’s behavior; -race checks for conflicting accesses exercised during those runs. Neither an expected value nor a clean detector run, on its own, proves every path through a larger program is correct. If this code belongs to a larger package, go test -race ./... extends the check to its other exercised tests.
Why insist that Inc hold the lock for the whole increment? Consider an implementation that locks to read value, unlocks, and later locks again to store old + 1. Its individual memory accesses could be protected from data races, yet two goroutines could still read the same old value and overwrite one another’s increments. Race-free accesses are necessary here, but the critical section must also preserve the operation’s intended meaning.
Keep race-detector runs separate from performance measurements: as in the previous lesson, detector instrumentation changes execution cost. First establish a correct synchronization rule; later modules will measure what it costs under concurrent load.
Takeaways
A mutex protects shared mutable state only when every access uses the same mutex and each operation holds it for its full critical section. In this counter, both Inc and Value lock; the test checks the expected total, while go test -race checks the concurrent paths it executes for data races.
The completion channels made this test straightforward, but coordinating completion becomes more involved with more goroutines. Next, you’ll use sync.WaitGroup to manage that lifecycle safely.