Go: Go Synchronization Primitives
Channels are used for communication between goroutines, and the
syncpackage is used to protect shared state—Go provides a complete toolkit for concurrent programming.
When multiple goroutines need to read from or write to the same متغير, synchronization primitives are required to ensure safety. In this lesson, you'll learn all the core tools and selection strategies of Go's sync package.
1. You will learn
sync.Mutexmutexsync.RWMutexread-write mutex (reads are non-exclusive)sync.Once: Execute oncesync.Map: A concurrency-safe mapsync/atomicatomic operations- race detector (
-raceflag) sync.Pooltemporary كائن pool- Strategies for Selecting Synchronization Primitives
2. A True Story of a High-Concurrency Engineer
(1) Problem: 100 goroutines write to the counter simultaneously, causing the data to become completely corrupted
Charlie is a واجهة خلفية engineer for an e-commerce platform, and he needs to count the number of orders per second:
"100 goroutines were processing orders simultaneously, and each one incremented the counter by 1 after completing an order. On the first day after launch, the data was completely off—the dashboard showed 1,000 orders per second, but there were only 300 orders in the قاعدة بيانات. My boss asked me, 'Where did the money for those 700 orders go?'"
He opened the code:
// Bad code: concurrent write to shared variable, no lock
var counter int
func processOrder(orderID string) {
// ... process order
counter++ // Non-atomic operation! Equivalent to:
// temp = counter
// temp = temp + 1
// counter = temp <- 3 goroutines executing this step simultaneously = chaos
}
counter++ is not an atomic operation—it breaks down into three steps: read, increment by one, and write. If two goroutines simultaneously read counter=10, increment it by one, and then write it back, the result will be counter=11 instead of 12—this is a classic example of a data race.
(2) Solution in Go: Mutex
// counter.go
package main
import (
"fmt"
"sync"
)
type SafeCounter struct {
mu sync.Mutex
value int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
c.value++ // Protected by the lock; only one goroutine can execute at a time
c.mu.Unlock()
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
func main() {
var counter SafeCounter
var wg sync.WaitGroup
// 100 goroutines incrementing simultaneously
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment()
}()
}
wg.Wait()
fmt.Printf("Final value: %d (expected 100)\n", counter.Value())
}
Output:
Final value: 100 (expected 100)
(3) Returns: Locked vs. Unlocked
| Situation | Result | Data Race |
|---|---|---|
| Lock-free concurrency | 95/100/97 (random) | ✅ Yes (race condition detected) |
| Mutex Protection | 100 | ❌ None |
| atomic operation | 100 | ❌ None |
go run -race main.go can detect data races. If concurrent access to shared variables is not protected by locks, the -race option will issue a warning at runtime. It is recommended to always enable -race in CI/CD.
3. sync.Mutex Mutex
(1) Mutex Basics
package main
import (
"fmt"
"sync"
)
type Account struct {
mu sync.Mutex
balance float64
}
func (a *Account) Deposit(amount float64) {
a.mu.Lock()
a.balance += amount
a.mu.Unlock()
}
func (a *Account) Withdraw(amount float64) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.balance < amount {
return fmt.Errorf("insufficient balance: have %.2f, need %.2f", a.balance, amount)
}
a.balance -= amount
return nil
}
func (a *Account) Balance() float64 {
a.mu.Lock()
defer a.mu.Unlock()
return a.balance
}
func main() {
acc := Account{balance: 1000}
var wg sync.WaitGroup
// 10 concurrent deposits
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
acc.Deposit(100)
}()
}
wg.Wait()
fmt.Printf("Balance: %.2f\n", acc.Balance())
}
▶ Example: Defer Unlock Pattern (Recommended)
// Recommended: Lock then immediately defer Unlock
func (a *Account) SafeMethod() {
a.mu.Lock()
defer a.mu.Unlock()
// All operations are within the lock
a.balance += 100
a.balance -= 50
// Even if there is a panic in between, defer ensures Unlock
}
// Not recommended: manual Unlock
func (a *Account) UnsafeMethod() {
a.mu.Lock()
a.balance += 100
a.mu.Unlock() // If there is a return or panic in between, Unlock won't be called → deadlock
}
Lock, you must call Unlock. Always use defer a.mu.Unlock() instead of manually calling Unlock at the end—if there are multiple return paths, forgetting to call Unlock in any one of them will result in a deadlock.
4. sync.RWMutex Read-Write Lock
(1) Principles of Read-Write Locks
package main
import (
"fmt"
"sync"
"time"
)
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func NewCache() *Cache {
return &Cache{data: make(map[string]string)}
}
// Read operation: RLock (concurrent reads, non-exclusive)
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
// Write operation: Lock (exclusive, blocks all reads and writes)
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
func main() {
cache := NewCache()
var wg sync.WaitGroup
// Multiple concurrent reads (RLock is non-exclusive)
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 3; j++ {
if v, ok := cache.Get("key"); ok {
fmt.Printf("Reader %d: %s\n", id, v)
}
time.Sleep(10 * time.Millisecond)
}
}(i)
}
// One writer
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(5 * time.Millisecond)
cache.Set("key", "value")
fmt.Println("Writer: write complete")
}()
wg.Wait()
}
▶ Example: Performance Comparison of Mutex vs. RWMutex
package main
import (
"sync"
"testing"
)
type DataStore struct {
mu sync.RWMutex
value int
}
func BenchmarkMutexRead(b *testing.B) {
store := DataStore{}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
store.mu.Lock()
_ = store.value
store.mu.Unlock()
}
})
}
func BenchmarkRWMutexRead(b *testing.B) {
store := DataStore{}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
store.mu.RLock()
_ = store.value
store.mu.RUnlock()
}
})
}
// go test -bench=. -benchmem
(3) Mutex vs RWMutex
| Feature | Mutex | RWMutex |
|---|---|---|
| Read and Read | Mutual Exclusion | Non-Mutual Exclusion |
| Read and Write | Mutual Exclusion | Mutual Exclusion |
| Write and Write | Mutual Exclusion | Mutual Exclusion |
| Use Cases | Read-write ratio 1:1 | Reads far exceed writes (e.g., cache) |
| Read Performance | Slow (serialized) | Fast (concurrent reads) |
5. sync.Once: Execute once
(1) Once Basics
package main
import (
"fmt"
"sync"
)
var (
config map[سلسلة]سلسلة
configOnce sync.Once
)
func loadConfig() {
configOnce.Do(func() {
fmt.Println("Initializing config...")
config = map[سلسلة]سلسلة{
"host": "localhost",
"port": "8080",
}
})
}
func main() {
var wg sync.WaitGroup
// 100 goroutines call simultaneously, but init executes only once
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
loadConfig()
}()
}
wg.Wait()
fmt.Printf("config: %v\n", config)
}
Output:
Initializing config...
config: map[host:localhost port:8080]
(2) sync.Once vs. manual flags
| Method | Thread-safe | Code Size |
|---|---|---|
if !initialized { ... } |
❌ Data race | Small |
sync.Once |
✅ Go guarantees | Minimal |
init() دالة |
✅ Executed when the package is loaded | Minimal |
sync.Once ensures that the دالة inside Do is executed only once, even if it is called simultaneously by 1,000 goroutines. Its internal implementation uses atomic operations and mutexes, making it very efficient.
6. sync.Map: A Concurrency-Safe Map
(1) sync.Map Basics
package main
import (
"fmt"
"sync"
)
func main() {
var m sync.Map
var wg sync.WaitGroup
// Concurrent writes
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
m.Store(fmt.Sprintf("key-%d", n), n*100)
}(i)
}
wg.Wait()
// Read
m.Range(func(key, value interface{}) bool {
fmt.Printf("%s = %d\n", key, value)
return true
})
// LoadOrStore: returns if exists, stores if not
actual, loaded := m.LoadOrStore("key-0", 999)
fmt.Printf("LoadOrStore: actual=%d, loaded=%v\n", actual, loaded)
}
▶ Example: sync.Map vs. map+Mutex
package main
import (
"sync"
"testing"
)
// map + Mutex
type MutexMap struct {
mu sync.Mutex
items map[سلسلة]int
}
func (m *MutexMap) Store(key سلسلة, value int) {
m.mu.Lock()
m.items[key] = value
m.mu.Unlock()
}
func (m *MutexMap) Load(key سلسلة) (int, bool) {
m.mu.Lock()
v, ok := m.items[key]
m.mu.Unlock()
return v, ok
}
func BenchmarkMutexMap(b *testing.B) {
m := &MutexMap{items: make(map[سلسلة]int)}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
m.Store("key", 1)
m.Load("key")
}
})
}
func BenchmarkSyncMap(b *testing.B) {
var m sync.Map
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
m.Store("key", 1)
m.Load("key")
}
})
}
// go test -bench=. -benchmem
(3) Use Cases for sync.Map
| Scenario | Recommendation | Reason |
|---|---|---|
| Key written once | sync.Map | Read optimization (few writes, many reads) |
| Frequent key updates | map+Mutex | sync.Map writes slightly slower |
| Key set growing | sync.Map | Avoid locking the entire map |
| Simple scenario | map+Mutex | More intuitive |
| High-performance requirements | Specific benchmark | Determined by actual testing |
7. sync/atomic: Atomic Operations
(1) Atomic Basics
package main
import (
"fmt"
"sync/atomic"
)
func main() {
var counter int64
// Atomic increment
newVal := atomic.AddInt64(&counter, 1)
fmt.Printf("AddInt64: %d\n", newVal)
// Atomic read
val := atomic.LoadInt64(&counter)
fmt.Printf("LoadInt64: %d\n", val)
// Atomic write
atomic.StoreInt64(&counter, 100)
// CAS (Compare And Swap)
swapped := atomic.CompareAndSwapInt64(&counter, 100, 200)
fmt.Printf("CAS: swapped=%v, val=%d\n", swapped, atomic.LoadInt64(&counter))
}
▶ Example: Atomic Counters vs. Mutexes
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
type AtomicCounter struct {
value int64
}
func (c *AtomicCounter) Increment() {
atomic.AddInt64(&c.value, 1)
}
func (c *AtomicCounter) Value() int64 {
return atomic.LoadInt64(&c.value)
}
type MutexCounter struct {
mu sync.Mutex
value int
}
func (c *MutexCounter) Increment() {
c.mu.Lock()
c.value++
c.mu.Unlock()
}
func (c *MutexCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
func main() {
var wg sync.WaitGroup
n := 100000
// Atomic counter
atomicStart := time.Now()
var ac AtomicCounter
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ac.Increment()
}()
}
wg.Wait()
fmt.Printf("Atomic: %d (%v)\n", ac.Value(), time.Since(atomicStart))
// Mutex counter
muStart := time.Now()
var mc MutexCounter
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mc.Increment()
}()
}
wg.Wait()
fmt.Printf("Mutex: %d (%v)\n", mc.Value(), time.Since(muStart))
}
(3) Common atomic functions
| Operation | Function | Purpose |
|---|---|---|
| Add/Subtract | AddInt64, AddUint32 |
Counter |
| Read | LoadInt64, LoadPointer |
Safe read |
| Write | StoreInt64, StorePointer |
Safe write |
| CAS | CompareAndSwapInt64 |
Optimistic lock |
| Swap | SwapInt64 |
Atomic swap |
8. race detector
▶ Example: Data Race Detection
// race_example.go
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // Data race!
}()
}
wg.Wait()
fmt.Println(counter)
}
$ go run -race race_example.go
==================
WARNING: DATA RACE
Read at 0x00c0000... by goroutine X
main.main.func1()
race_example.go:14 +0x...
Previous write at 0x00c0000... by goroutine Y
main.main.func1()
race_example.go:14 +0x...
==================
9. sync.Pool Object Pool
▶ Example: Object Pool
package main
import (
"fmt"
"sync"
)
type User struct {
Name string
Age int
}
var userPool = sync.Pool{
New: func() interface{} {
return &User{}
},
}
func main() {
// Get from pool (avoids allocating a new object each time)
u := userPool.Get().(*User)
u.Name = "Alice"
u.Age = 28
fmt.Printf("Using: %+v\n", u)
// Put back into the pool after use
userPool.Put(u)
// Next Get reuses the previous object
u2 := userPool.Get().(*User)
fmt.Printf("Reusing: %+v\n", u2)
}
10. Complete Example: High-Concurrency Counter
// concurrent_counter.go
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
// ---------- 3 counter variants ----------
type Counter interface {
Increment()
Value() int64
}
// AtomicCounter
type AtomicCounter struct {
value int64
}
func (c *AtomicCounter) Increment() {
atomic.AddInt64(&c.value, 1)
}
func (c *AtomicCounter) Value() int64 {
return atomic.LoadInt64(&c.value)
}
// MutexCounter
type MutexCounter struct {
mu sync.Mutex
value int64
}
func (c *MutexCounter) Increment() {
c.mu.Lock()
c.value++
c.mu.Unlock()
}
func (c *MutexCounter) Value() int64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
// ChannelCounter
type ChannelCounter struct {
ch chan struct{}
value int64
done chan struct{}
}
func NewChannelCounter() *ChannelCounter {
c := &ChannelCounter{
ch: make(chan struct{}, 100),
done: make(chan struct{}),
}
go func() {
for range c.ch {
c.value++
}
close(c.done)
}()
return c
}
func (c *ChannelCounter) Increment() {
c.ch <- struct{}{}
}
func (c *ChannelCounter) Value() int64 {
return atomic.LoadInt64(&c.value)
}
func (c *ChannelCounter) Close() {
close(c.ch)
<-c.done
}
// ---------- Benchmark ----------
func benchmarkCounter(c Counter, goroutines, increments int) time.Duration {
var wg sync.WaitGroup
start := time.Now()
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < increments; i++ {
c.Increment()
}
}()
}
wg.Wait()
return time.Since(start)
}
func main() {
const (
goroutines = 100
increments = 10000
)
fmt.Printf("Benchmark: %d goroutines, each executing %d Increments\n\n", goroutines, increments)
// 1. Atomic counter
ac := &AtomicCounter{}
atomicTime := time.Now()
atomicDone := make(chan struct{})
go func() {
defer close(atomicDone)
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < increments; i++ {
ac.Increment()
}
}()
}
wg.Wait()
}()
<-atomicDone
fmt.Printf("AtomicCounter: %d (expected %d) — %v\n",
ac.Value(), goroutines*increments, time.Since(atomicTime))
// 2. Mutex counter
mc := &MutexCounter{}
mutexTime := time.Now()
mutexDone := make(chan struct{})
go func() {
defer close(mutexDone)
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < increments; i++ {
mc.Increment()
}
}()
}
wg.Wait()
}()
<-mutexDone
fmt.Printf("MutexCounter: %d (expected %d) — %v\n",
mc.Value(), goroutines*increments, time.Since(mutexTime))
// 3. Channel counter
cc := NewChannelCounter()
channelTime := time.Now()
channelDone := make(chan struct{})
go func() {
defer close(channelDone)
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < increments; i++ {
cc.Increment()
}
}()
}
wg.Wait()
cc.Close()
}()
<-channelDone
fmt.Printf("ChannelCounter: %d (expected %d) — %v\n",
cc.Value(), goroutines*increments, time.Since(channelTime))
fmt.Println("\nConclusion: Atomic > Mutex > Channel (performance from high to low)")
}
sequenceDiagram
participant G1 as Goroutine 1
participant G2 as Goroutine 2
participant G3 as Goroutine 3
participant M as Mutex
G1->>M: Lock()
Note over G1,M: G1 holds the lock
G2->>M: Lock() blocked
G3->>M: Lock() blocked
G1->>M: Unlock()
M-->>G2: wake up
M-->>G3: still blocked
G2->>M: Lock() success
Note over G2,M: G2 holds the lock
G2->>M: Unlock()
M-->>G3: wake up
G3->>M: Lock() success
*Counter).
❓ FAQ
sync.Once?sync.Once.Do(f) ensures that f is executed only once, even if 1,000 goroutines call it simultaneously. Typical uses: lazy-loading configurations, the singleton pattern, and resources that need to be initialized only once. It is implemented internally using atomic operations and a mutex.sync.Map suitable?atomic appropriate?atomic is 10 to 100 times faster than a mutex, but it can only be used with basic types such as int, uint, and pointer. For complex logic, use a mutex instead.go run -race main.go or go test -race ./.... It detects data races at runtime—if two goroutines concurrently access the same متغير and at least one of them is a write operation, a warning will be issued. It is recommended to keep it enabled for CI/CD.sync.Pool be used?sync.Mutex is a non-reentrant lock—if the same goroutine calls Lock twice in succession, it will result in a deadlock. If you need to acquire the lock again within the same goroutine, refactor the code to use sync.Mutex with recursive calls, or use defer to ensure the lock is properly released.Lock for write operations and only RLock for read operations; do not mix them.📖 Summary
- Mutex:
Lock/Unlock, suitable for a 1:1 read-write ratio - RWMutex read-write lock:
RLockfor concurrent reads,Lockfor exclusive writes - sync.Once:
Do(f)ensures that the دالة is executed only once - sync.Map: A concurrency-safe Map for read-heavy, write-light scenarios
- sync/atomic: lock-free counters, CAS optimistic locking
- race detector: The
-raceflag detects data races - sync.Pool: A pool of temporary objects that reduces the load on the garbage collector
- Selection: atomic > Mutex > RWMutex > Channel (in descending order of performance)
📝 Exercises
-
Basic Problem (Difficulty ⭐): Use a mutex to protect concurrent reads and writes to a
map[سلسلة]int. Launch 10 writer goroutines to write data and 10 reader goroutines to read data, and verify that there is no race condition (the-racetest passes). -
Advanced Problem (Difficulty ⭐⭐): Compare the performance of four types of counters (atomic, Mutex, RWMutex, and channel) by measuring the time taken and verifying the correctness of 100 goroutines performing 10,000 increment operations. Use
-raceto verify that there are no data races. -
Challenge (Difficulty ⭐⭐⭐): Implement a high-concurrency ذاكرة مخبأة with many reads and few writes. Requirements: (1) Use RWMutex for protection; (2) Support Get/Set/Delete/Range operations; (3) Support TTL expiration (
time.Now().After(expireAt)); (4) Block all read operations during write operations, but allow concurrent read operations; (5) Pass the-racetest.