Synchronization in Go
By Syncfusion®, Inc. · Last updated
Coordinate goroutines safely: why synchronization is needed, sync.WaitGroup, sync.Mutex and RWMutex, sync/atomic, and how to detect and prevent race conditions.
What Is Synchronization?
Synchronization = making goroutines work together safely: waiting for each other, and taking turns with shared data.
Shared data = a variable that more than one goroutine reads or changes.
Simple lesson: synchronization keeps goroutines in step, so shared data stays correct.
A Real-World Example
Think of the class attendance register on the teacher's desk.
- Two monitors both want to mark students present.
- If both write in the same box at the same moment, the marks get messy.
- So the rule is: only the monitor holding the pen may write.
- The other monitor waits for the pen.
Now the mapping:
- The register = shared data.
- The pen = a mutex (a lock).
- Waiting for the pen =
Lock()blocking until it is free. - Handing the pen back =
Unlock().
You have met two ways to coordinate goroutines:
sync.WaitGroupin Goroutines- channels in Channels
This lesson adds mutexes and atomic operations for shared data.
What You Will Learn
In this lesson, you will learn:
- Why synchronization is needed, and how to coordinate goroutines.
sync.WaitGroupand itsAdd(),Done()andWait().sync.Mutexandsync.RWMutexwithLock()andUnlock().- Atomic operations with
sync/atomic. - What race conditions are, how to detect them, and how to prevent them.
Synchronization Fundamentals
Why Synchronization Is Needed
count++ looks like one step, but it is really three:
- read the current value
- add one
- write the new value back
If two goroutines do this at the same time, one update can be lost.
Step through it. Then switch on the mutex.
Compare the two tabs:
- No mutex: both read 0, both write 1. The final count is 1, not 2.
- With mutex: B waits until A is done. The final count is 2.
Coordinating Goroutines
There are two different needs:
- Wait for goroutines to finish →
sync.WaitGroupor channels. - Take turns with shared data →
sync.Mutex,sync.RWMutexorsync/atomic.
Simple lesson: use WaitGroups to wait, and locks or atomics to share data safely.
Wait Groups
sync.WaitGroup
WaitGroup = a counter of goroutines still working.
Add(), Done() and Wait()
| Call | Who calls it | What it does |
|---|---|---|
wg.Add(n) | the starter, before go | counter + n |
wg.Done() | each goroutine, at the end | counter - 1 |
wg.Wait() | the waiter | blocks until the counter is 0 |
Wait for every house to report
package main
import (
"fmt"
"sync"
)
func main() {
houses := []string{"Red", "Blue", "Green", "Yellow"}
points := make([]int, len(houses))
var wg sync.WaitGroup
wg.Add(len(houses))
for i, h := range houses {
go func() {
defer wg.Done()
points[i] = len(h) * 10
}()
}
wg.Wait()
fmt.Println(points)
}
[30 40 50 60]
wg.Add(len(houses))adds all four at once.defer wg.Done()runs even if the goroutine returns early.
Simple lesson: Add before starting, Done when finished, Wait to block until all are done.
Mutexes
sync.Mutex
Mutex (mutual exclusion) = a lock that lets only one goroutine at a time into a piece of code.
Critical section = the code between Lock() and Unlock().
Lock() and Unlock()
Press Play to watch the lock.
A mutex protects the counter
A safe counter type
package main
import (
"fmt"
"sync"
)
type VoteBox struct {
mu sync.Mutex
votes map[string]int
}
func (b *VoteBox) Add(name string) {
b.mu.Lock()
defer b.mu.Unlock()
b.votes[name]++
}
func (b *VoteBox) Count(name string) int {
b.mu.Lock()
defer b.mu.Unlock()
return b.votes[name]
}
func main() {
box := &VoteBox{votes: make(map[string]int)}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if i%3 == 0 {
box.Add("Ravi")
} else {
box.Add("Asha")
}
}()
}
wg.Wait()
fmt.Println("Asha:", box.Count("Asha"), "Ravi:", box.Count("Ravi"))
}
Asha: 66 Ravi: 34
- The mutex sits next to the data it protects, inside the struct.
defer b.mu.Unlock()makes sure the lock is always released.- Maps are not safe for many goroutines writing at once, so the lock is required. See Maps.
- A struct with a mutex must be passed by pointer. A copy would have its own, separate lock.
sync.RWMutex
RWMutex = a lock with two modes:
RLock()/RUnlock()= many readers at the same time.Lock()/Unlock()= one writer, and no readers while it writes.
Many readers, one writer
package main
import (
"fmt"
"sync"
)
type Timetable struct {
mu sync.RWMutex
periods map[string]string
}
func (t *Timetable) Get(day string) string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.periods[day]
}
func (t *Timetable) Set(day, subject string) {
t.mu.Lock()
defer t.mu.Unlock()
t.periods[day] = subject
}
func main() {
tt := &Timetable{periods: map[string]string{"Mon": "Maths"}}
tt.Set("Tue", "Science")
var wg sync.WaitGroup
for _, d := range []string{"Mon", "Tue", "Mon"} {
wg.Add(1)
go func() {
defer wg.Done()
_ = tt.Get(d)
}()
}
wg.Wait()
fmt.Println(tt.Get("Mon"), tt.Get("Tue"))
}
Maths Science
- Use
RWMutexwhen reads are much more common than writes.
Mutex vs RWMutex
Simple lesson: a mutex lets one goroutine at a time touch shared data, and an RWMutex lets many readers share.
Atomic Operations
sync/atomic
Atomic operation = a small change, like adding 1, that happens as one unbreakable step.
- No goroutine can see it half done.
- Faster than a mutex for simple counters.
An atomic counter
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var visitors atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 500; i++ {
wg.Add(1)
go func() {
defer wg.Done()
visitors.Add(1)
}()
}
wg.Wait()
fmt.Println("Library visitors:", visitors.Load())
}
Library visitors: 500
atomic.Int64has methods:Add,Load,Store,CompareAndSwap.- Older code uses functions instead:
atomic.AddInt64(&n, 1). - Use atomics only for single numbers or flags. For anything bigger, use a mutex.
Simple lesson: use sync/atomic for a simple shared counter, and a mutex for anything more.
Race Conditions
What Are Race Conditions?
Data race = two goroutines use the same variable at the same time, and at least one of them writes.
Race condition = a bug where the result depends on which goroutine happens to run first.
An unsafe counter (do not copy this)
package main
import (
"fmt"
"sync"
)
func main() {
count := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++
}()
}
wg.Wait()
fmt.Println(count)
}
- On a real computer with many cores, this often prints a number below 1000, and a different one each run.
- In this lab, goroutines take turns on one thread, so you may see 1000. The code is still wrong.
Detection
Go has a built-in race detector. Run your program or tests with -race:
go run -race main.go
go test -race ./...
==================
WARNING: DATA RACE
Read at 0x00c000012118 by goroutine 8:
main.main.func1()
/school/main.go:14 +0x...
Previous write at 0x00c000012118 by goroutine 7:
...
==================
- It shows where the two goroutines touched the same memory.
- It needs the
gotool on your computer, so it cannot run in this lab. - Run your tests with
-raceoften.
Prevention
- Do not share: give each goroutine its own data, then combine results. See Goroutines.
- Send instead of share: pass values through channels. See Channels.
- Lock: protect shared data with a
sync.Mutex. - Atomic: use
sync/atomicfor single counters. - Check: run with
-race.
Common lock mistakes:
- Forgetting
Unlock()freezes every other goroutine. Usedefer mu.Unlock(). - Locking the same mutex twice in one goroutine waits forever.
- Copying a struct that holds a mutex makes a second, unrelated lock.
Easy way to remember
- Wait for work →
WaitGroup - Hand data over → channels
- Share data →
Mutex(RWMutexfor many readers) - Share one number →
atomic - Check →
go run -race
Simple lesson: a data race happens when goroutines share data without rules, and locks, channels or atomics prevent it.
TL;DR
- Synchronization = goroutines waiting for each other and taking turns.
- WaitGroup =
Add(n)beforego,defer Done(),Wait(). Mutex =
Lock()...defer Unlock(). One goroutine at a time.- Critical section = the code between them.
- RWMutex =
RLockfor many readers,Lockfor one writer. - Atomic =
atomic.Int64:Add,Load. For single numbers. Data race = shared variable, at the same time, at least one write.
- Detect =
go run -race,go test -race. - Prevent = do not share, use channels, lock, or atomics.
- Detect =
Common mistakes
| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
count++ from many goroutines | ++ is one step | It is read, add, write | Lost updates. |
Forgetting Unlock() | The lock frees itself | Unlock, ideally with defer | Other goroutines wait forever. |
| Copying a struct with a mutex | The lock is shared | The copy has its own lock | No protection at all. |
| Locking around a slow network call | Locks are cheap | Keep critical sections short | Everything slows down. |
wg.Add inside the goroutine | It counts in time | Add before go | Wait may return too early. |
| "It printed 1000, so it is safe" | One correct run proves it | Races show up only sometimes | Bugs in production. Use -race. |
Check Yourself
Exercises
What to Remember
- Shared data needs rules when goroutines change it.
WaitGroupwaits.Mutextakes turns.RWMutexlets readers share.atomicchanges one number safely.- Always
defer mu.Unlock(), and keep critical sections short. - A data race may hide on one run and show on the next. Use
go run -race.
Next: Reflection. You will learn how a Go program can look at its own values and types while it runs.