ข้ามไปยังเนื้อหา

Sync Primitives

Channels เหมาะสำหรับการถ่ายโอน ownership ของข้อมูลระหว่าง goroutines แต่บางครั้งคุณแค่ต้องการป้องกัน shared value ที่ goroutines หลายตัวอ่านและเขียน — cache, counter, connection pool สำหรับกรณีเหล่านี้ package sync มี primitives ระดับต่ำกว่า

sync.Mutex คือ mutual exclusion lock goroutine เดียวเท่านั้นที่ถือ lock ได้ในแต่ละเวลา ที่เหลือบล็อกบน Lock() จนกว่าผู้ถือเรียก Unlock()

var mu sync.Mutex
var count int
mu.Lock()
count++
mu.Unlock()

ใช้ defer mu.Unlock() ทันทีหลัง mu.Lock() เสมอเพื่อให้ lock ถูก release แม้ function จะ panic หรือ return ก่อนเวลา

sync.Mutex (และอะไรก็ตามที่ embed มันไว้) ห้าม copy หลังจากถูกใช้ครั้งแรกเด็ดขาด — copy จะมี lock state เป็นอิสระของตัวเอง ทำให้โค้ดที่คิดว่ากำลัง lock ตัวต้นฉบับ จริง ๆ แล้วกำลัง lock lock ที่ไม่มีใครมองอีกตัวหนึ่ง เรื่องนี้เกิดง่ายกว่าที่คิด เพราะ Go copy ตอน assignment, ตอน range, และทุกครั้งที่เรียก function แบบส่งค่า:

type Counter struct {
mu sync.Mutex
n int
}
func main() {
counters := []Counter{{}, {}, {}}
for _, c := range counters { // c is a copy of each Counter, including its mutex
c.mu.Lock()
c.n++
c.mu.Unlock()
}
}

go vet จับตรงนี้ได้พอดีบน Go 1.26.5:

main.go:12:9: range var c copies lock: command-line-arguments.Counter contains sync.Mutex

วิธีแก้เสมอคือ range ด้วย index หรือ pointer แทน — for i := range counters { counters[i].mu.Lock() ... } — เพื่อให้ทุกรอบ lock mutex ตัวเดียวกันจริง ไม่ใช่ copy ใหม่ของมัน บท Methods สอนกับดักเดียวกันนี้สำหรับ value-receiver method บน struct ที่มี mutex; นี่คือบัคตัวเดียวกันในรูปแบบของ loop

Go memory model นิยามชัดเจนว่าเมื่อไหร่ write ใน goroutine หนึ่งจะการันตีว่ามองเห็นได้โดย read ใน goroutine อีกตัว — และที่สำคัญคือ การอ่านหรือเขียน variable ธรรมดาที่ไม่ได้เชื่อมกับ goroutine อื่นด้วย mutex, channel, หรือ atomic operation ไม่มีการรับประกันแบบนั้นเลย แม้ว่า write จะ “เห็นได้ชัด” ว่าเกิดก่อนตาม wall-clock time ก็ตาม compiler และ CPU ต่างมีสิทธิ์ reorder หรือ cache memory access ธรรมดาได้ตราบใดที่ goroutine เดียวที่ออกคำสั่งนั้นยังเห็นผลลัพธ์ที่สอดคล้องกัน ไม่มีอะไรบังคับให้ผลลัพธ์นั้นมองเห็นได้โดย goroutine อื่นตาม schedule ใด ๆ เลย

นี่คือเหตุผลที่ race detector ถือว่า boolean flag ธรรมดาที่ใช้ส่งสัญญาณเป็นบัค แม้โปรแกรม “ดูเหมือนจะทำงานได้” ตอนทดสอบแบบไม่เป็นทางการ:

done := false
go func() {
time.Sleep(10 * time.Millisecond)
done = true // plain write, no synchronization
}()
for !done { // plain read, no synchronization
}
fmt.Println("done")
WARNING: DATA RACE
Write at 0x00c00019c03f by goroutine 7:
main.main.func1()
/tmp/boolflag/main.go:12 +0x38
Previous read at 0x00c00019c03f by main goroutine:
main.main()
/tmp/boolflag/main.go:14 +0xa0
...
Found 1 data race(s)

ทั้ง read และ write compile ผ่าน และบนเครื่องส่วนใหญ่ ส่วนใหญ่ของเวลา ดูเหมือนจะทำงานได้ — นี่แหละคืออันตราย sync.Mutex, channel operation, หรือ type ใน sync/atomic ล้วนสร้าง happens-before edge จริง ส่วน bool, int, หรือ pointer ธรรมดาไม่เคยสร้างเลย ไม่ว่าจะดูเหมือนอย่างไรในโค้ด

sync.RWMutex คือ variant ที่ optimize สำหรับกรณีที่อ่านบ่อย goroutines หลายตัวสามารถถือ read lock พร้อมกันได้ด้วย RLock/RUnlock Write lock (Lock/Unlock) เป็น exclusive — รอให้ reader ทั้งหมดเสร็จก่อน แล้วบล็อก reader ใหม่จนกว่า writer เสร็จ

ใช้เมื่ออ่านบ่อยกว่าเขียนมาก (เช่น in-memory cache)

flowchart LR
  Unlocked --> ReadLocked["read-locked (N readers)"]
  Unlocked --> WriteLocked["write-locked (1 writer)"]
  ReadLocked -->|"another RLock()"| ReadLocked
  ReadLocked -->|"last RUnlock(), no writer waiting"| Unlocked
  WriteLocked -->|Unlock| Unlocked
  ReadLocked -.->|"Lock() called: writer queues and blocks"| WriterWaiting["writer waiting"]
  WriterWaiting -.->|"all current readers RUnlock"| WriteLocked
  WriterWaiting -.->|"new RLock() while writer waits: blocks too"| WriterWaiting
RWMutex ให้ priority กับ writer ที่รออยู่: เมื่อ Lock() เข้าคิวแล้ว แม้แต่ RLock() ใหม่ก็ block จนกว่า writer จะได้รัน

RWMutex ของ Go ให้ priority กับ writer ที่รออยู่: เมื่อ goroutine ตัวหนึ่งเรียก Lock() แล้ว ทุกการเรียก RLock() ใหม่ — แม้แต่จาก goroutine ที่ถือ read lock อยู่แล้วที่อื่น — จะ block จนกว่า writer นั้นจะได้ lock และปล่อยมันแล้ว นี่ทำให้การ read-lock แบบ recursive จาก goroutine เดียวกันอันตรายจริง ไม่ใช่แค่เสียเวลาเปล่า:

var mu sync.RWMutex
mu.RLock()
fmt.Println("main: acquired first RLock")
go func() {
time.Sleep(50 * time.Millisecond)
mu.Lock() // queues behind main's held RLock, and blocks new readers
defer mu.Unlock()
}()
time.Sleep(100 * time.Millisecond) // let the writer start waiting
mu.RLock() // blocks forever -- a writer is already queued
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.RWMutex.RLock]:
sync.(*RWMutex).RLock(0xc000120018)
/tmp/rwrecursive/main.go:25 +0x50
main.main()
/tmp/rwrecursive/main.go:25 +0x154
goroutine 19 [sync.RWMutex.Lock]:
sync.(*RWMutex).Lock(0xc000120018)
/tmp/rwrecursive/main.go:18 +0x78
main.main.func1()
/tmp/rwrecursive/main.go:18 +0x8c

goroutine ที่ถือ RLock ตัวแรกกำลังรอ RLock ตัวที่สองของตัวเองให้สำเร็จ; writer ที่มันเข้าคิวรออยู่ไปต่อไม่ได้จนกว่า RLock ตัวแรกจะถูกปล่อย; และไม่มีอะไรปล่อยมันเลย อย่าเรียก RLock ซ้ำเป็นครั้งที่สองบน RWMutex เดียวกันจาก goroutine ที่อาจถืออันหนึ่งอยู่แล้ว — รวมถึงทางอ้อมผ่าน function call ที่ลึกลงไปหลายชั้นด้วย

sync.Once รัน function ได้ครั้งเดียว ไม่ว่าจะมี goroutines กี่ตัวเรียก Do พร้อมกัน use case มาตรฐานคือ lazy, thread-safe initialization:

var once sync.Once
var instance *Config
func getConfig() *Config {
once.Do(func() {
instance = loadConfig()
})
return instance
}

pattern once.Do ด้านบนต้องการ package-level variable เพื่อเก็บผลลัพธ์และ closure เพื่อ capture มัน ตั้งแต่ Go 1.21 sync.OnceValue รวมทั้งสองอย่างไว้ในการเรียกครั้งเดียว: มันรับ function แล้ว return function ใหม่ที่รัน function ต้นฉบับแค่ครั้งเดียวและ cache ค่า return ของมัน ปลอดภัยสำหรับผู้เรียกพร้อมกันกี่คนก็ได้:

var loadConfig = sync.OnceValue(func() string {
fmt.Println("loading config...") // printed exactly once, however many callers there are
return "config-data"
})

sync.OnceFunc คือแบบเดียวกันสำหรับ function ที่ไม่มี return value และ sync.OnceValues จัดการ function ที่ return สองค่า (ปกติคือ value กับ error) ทั้งสามยัง propagate panic ด้วย: ถ้า function ที่ห่อไว้ panic ตอนที่ถูกเรียกจริงครั้งเดียว การเรียก function ที่ return มาในครั้งถัด ๆ ไปทุกครั้งจะ panic ด้วยค่าเดียวกันนั้น แทนที่จะ return zero value แบบเงียบ ๆ

Package sync/atomic มี lock-free operations บน integer และ pointer types โดยใช้ CPU hardware instructions สำหรับ counter หรือ flag ง่าย ๆ atomics เร็วกว่า mutex เพราะไม่มี lock contention

ตั้งแต่ Go 1.19 ให้ใช้ typed API เป็นหลัก: var hits atomic.Int64 แล้วเรียก hits.Add(1), hits.Load(), hits.Store(n), และ hits.CompareAndSwap(old, new) เป็น method ตรง ๆ ไม่ต้องใช้ & เลย ตระกูลเดียวกันนี้ยังมี atomic.Bool และ atomic.Pointer[T] สำหรับ flag และ pointer swap แบบ lock-free รูปแบบ function เก่าอย่าง atomic.AddInt64(&counter, 1) ยังใช้ได้และเป็นสิ่งที่เจอในโค้ดเก่า แต่ต้องการให้ int64/uint64 ที่ทำงานด้วย align 8 byte เอง ซึ่งเป็นกับดักจริงบน 32-bit platform ที่ field ใน struct อาจ misalign ได้ — typed API ตัดปัญหา alignment นี้ไปเลย นี่คือเหตุผลหลักที่ควรใช้ในโค้ดใหม่

WaitGroup คือ primitive ที่คุณหยิบใช้บ่อยที่สุด และตั้งแต่ Go 1.25 มีทางลัด: wg.Go(f) ทำ Add(1) เริ่ม f ใน goroutine ใหม่ และเรียก Done() เมื่อ f return

var wg sync.WaitGroup
for _, url := range urls {
wg.Go(func() { fetch(url) }) // per-iteration url since Go 1.22, no capture dance
}
wg.Wait()

ในโค้ดใหม่ให้ใช้ wg.Go — วาง Add ผิดที่ไม่ได้เลย — และปล่อยให้ go fix เขียนสามบรรทัดแบบเก่าในโค้ดเดิมให้ใหม่: modernizer ตัวนี้ชื่อ waitgroup ใน Go 1.26 แล้วเปลี่ยนชื่อเป็น waitgroupgo ใน Go 1.27 (เพื่อไม่ให้ชนกับ analyzer waitgroup ของ go vet เอง ซึ่งตรวจการใช้ WaitGroup ผิด ไม่ใช่เขียนโค้ดใหม่ให้)

ใช้ channel เมื่อ…ใช้ mutex เมื่อ…
ถ่ายโอน ownership ของข้อมูลป้องกัน cache หรือ struct ที่แชร์กัน
ประสาน lifecycle ของ goroutineจัดการ counter หรือ flag ง่าย ๆ
Pipeline และ fan-out patternข้อมูลที่อ่านบ่อยด้วย sync.RWMutex

sync.Map เป็น map เฉพาะทางที่ปลอดภัยสำหรับใช้งานพร้อมกันโดยไม่ต้องใช้ Mutex แยก — แต่เอกสารของ standard library ระบุชัดว่าโค้ดส่วนใหญ่ควรใช้ map ธรรมดาพร้อม lock ของตัวเองต่อไป เพื่อ type safety ที่ดีกว่าและ invariant ที่ดูแลง่ายกว่า sync.Map คุ้มค่าใช้จริง ๆ ในสองรูปแบบ workload เท่านั้น: (1) key ถูกเขียนครั้งเดียวแล้วอ่านหลายครั้ง เช่น cache ที่โตขึ้นเรื่อย ๆ หรือ (2) goroutine หลายตัวอ่าน เขียน และเขียนทับ key ที่แยกกันไม่ทับซ้อน จึงแทบไม่แย่งกันเลย นอกเหนือจากสอง pattern นี้ map ที่ป้องกันด้วย sync.RWMutex มักจะเข้าใจง่ายกว่าและเร็วพอ ๆ กัน

var cache sync.Map
cache.Store("key", 42)
v, ok := cache.Load("key")
cache.Range(func(k, v any) bool {
// called for each entry; return false to stop early
return true
})

เหมือน mutex sync.Map ห้าม copy หลังจากถูกใช้ครั้งแรกเช่นกัน

pattern sync.WaitGroup + shared-error-variable สำหรับรัน goroutine แล้วเก็บ error ตัวแรกพบบ่อยพอที่ golang.org/x/sync/errgroup จะห่อมันไว้ให้: g.Go(func() error { ... }) เริ่ม goroutine และ g.Wait() return error ตัวแรกที่ไม่ใช่ nil ที่ตัวไหนตัวหนึ่งคืนมา (ยกเลิก group Context ด้วย ถ้าคุณสร้างด้วย errgroup.WithContext เพื่อให้ goroutine พี่น้องหยุดได้เร็วเช่นกัน) ตั้งแต่ errgroup ของ Go 1.20 SetLimit(n) จำกัดว่า goroutine เหล่านั้นรันพร้อมกันได้กี่ตัว — ปัญหาเดียวกับ “unbounded spawning” ในบท Goroutines แต่แก้แบบ declarative แทนที่จะเขียน semaphore channel เอง:

var g errgroup.Group
g.SetLimit(2) // at most 2 goroutines run at once
for _, url := range urls {
url := url
g.Go(func() error {
return fetch(url) // returns an error; SetLimit still applies
})
}
if err := g.Wait(); err != nil {
log.Fatal(err)
}

errgroup ไม่ได้อยู่ใน standard library — มันอยู่ใน module golang.org/x/sync ต้อง go get golang.org/x/sync/errgroup และรันใน Playground ของหน้านี้ที่ทำงานในเบราว์เซอร์ไม่ได้ แต่มันเป็นตัวเลือก idiomatic ในบริการ Go จริงเมื่อโจทย์อยู่ในรูป “งานขนานแบบมีขอบเขต error ตัวแรกชนะ”

Lock ควรถูกถือไว้ในช่วงที่สั้นที่สุดที่ยังทำให้ข้อมูลที่ป้องกันสอดคล้องกัน — ห้ามถือข้ามการเรียก I/O, channel send หรือ receive, หรือ operation ใด ๆ ที่อาจ block นาน การถือ mutex ข้ามการทำงานที่ช้าจะเปลี่ยน goroutine อื่นทุกตัวที่ต้องการ lock เดียวกันให้กลายเป็นคิวรอต่อจาก call ที่ช้าตัวนั้น แม้ว่าพวกมันจะไม่ได้แตะ I/O เดียวกันเลย:

// Bad: the lock is held across a network call
mu.Lock()
defer mu.Unlock()
resp, err := http.Get(url) // every other locker waits for the network here too
cache[url] = resp
// Better: only the map access needs the lock
resp, err := http.Get(url)
mu.Lock()
cache[url] = resp
mu.Unlock()

เหตุผลเดียวกันนี้ใช้กับการถือ lock ขณะ send บน channel: ถ้า receiver ช้าหรือ channel เต็ม lock จะถูกถือไว้นานเท่ากับที่ send นั้น block พอดี ทั้งที่การ send ไม่เกี่ยวอะไรกับสิ่งที่ lock ป้องกันอยู่เลย

package main
import (
"fmt"
"sync"
"sync/atomic"
)
type SafeCounter struct {
mu sync.Mutex
v int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.v++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.v
}
func main() {
// sync.Mutex: 100 goroutines each increment once -> 100
var wg sync.WaitGroup
c := &SafeCounter{}
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Inc()
}()
}
wg.Wait()
fmt.Println("counter:", c.Value()) // 100
// sync.Once: function runs exactly once despite 3 calls
var once sync.Once
for i := 0; i < 3; i++ {
once.Do(func() {
fmt.Println("only once")
})
}
// sync/atomic: typed API, 50 goroutines each add 1 -> 50
var hits atomic.Int64
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
hits.Add(1)
}()
}
wg.Wait()
fmt.Println("atomic count:", hits.Load()) // 50
}

Snippet ที่สองด้านล่างครอบคลุม sync.OnceValue และ sync.Map จากก่อนหน้านี้ในบทนี้

package main
import (
"fmt"
"sort"
"sync"
)
// loadConfig computes its value at most once, no matter how many goroutines
// call it concurrently -- sync.OnceValue handles the locking internally.
var loadConfig = sync.OnceValue(func() string {
fmt.Println("loading config...") // printed exactly once
return "config-data"
})
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = loadConfig()
}()
}
wg.Wait()
fmt.Println(loadConfig())
// sync.Map: safe for concurrent use without a separate Mutex, best suited
// to the write-once-read-many or disjoint-keys access patterns.
var cache sync.Map
var wg2 sync.WaitGroup
for i := 0; i < 5; i++ {
wg2.Add(1)
go func(i int) {
defer wg2.Done()
cache.Store(fmt.Sprintf("key%d", i), i*i)
}(i)
}
wg2.Wait()
var entries []string
cache.Range(func(k, v any) bool {
entries = append(entries, fmt.Sprintf("%s=%v", k, v))
return true
})
sort.Strings(entries) // Range order is unspecified, so sort for a stable print
fmt.Println(entries)
}
สิ่งที่ได้ประโยชน์ต้นทุน
sync.Mutexง่าย, เร็วสำหรับ simple shared stategoroutine ที่ lock ต้อง unlock เสมอ
sync.RWMutexread parallel ได้RLock overhead > Mutex lock เมื่อ write บ่อย
sync.Onceinit once thread-safeไม่สามารถ reset ได้
sync/atomicเร็วที่สุดสำหรับ simple counterจำกัดแค่ primitive types
channelcomposable, saferoverhead มากกว่า mutex
  • mutex ช้ากว่า channel เสมอ — mutex เร็วกว่า channel สำหรับ protect simple counter
  • sync.Map แทน map+mutex ได้เสมอ — sync.Map เหมาะกับ read-heavy, append-only ไม่ใช่ general map
  • typed atomic.Int64 กับ atomic.AddInt64 แบบเก่าเหมือนกันทุกประการ — รูป function เก่าต้องการให้ตัวแปรที่ใช้ align 8 byte เอง (พังได้จริงบน 32-bit platform) ส่วน typed API (atomic.Int64, atomic.Bool, atomic.Pointer[T]) จัดการ alignment ให้อัตโนมัติ จึงควรใช้ตัวหลังในโค้ดใหม่เสมอ
  • defer mu.Unlock() ช้ากว่า explicit unlock — defer มี overhead น้อยมาก ใช้เพื่อ safety เสมอ

💡 ตัวอย่างจากของจริง

Prometheus client_golang ใช้ atomic.AddUint64 เพิ่มค่า counter metric แบบ lock-free ใน prometheus/counter.go — hot path ที่ถูกเรียกทุกครั้งที่ metric เปลี่ยนค่า จึงหลีกเลี่ยง lock contention ด้วย atomic แทน mutex

เมื่อโค้ดที่ AI สร้างมาใช้ lock หรือ sync primitive อื่น ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:

  • struct ที่มี sync.Mutex, sync.RWMutex, หรือ sync.WaitGroup ถูก copy หรือเปล่า — ด้วย range, ด้วย assignment, ด้วยการส่งหรือ return แบบส่งค่า, หรือด้วย value-receiver method?
  • goroutine เรียก RLock เป็นครั้งที่สองขณะที่อาจถืออันหนึ่งอยู่แล้วไหม — ตรง ๆ หรือผ่าน function call ที่ลึกลงไปหลายชั้น? นั่น deadlock ทันทีที่มี goroutine ไหนกำลังรอ Lock
  • มี bool, int, หรือ pointer ธรรมดาถูกใช้เป็นสัญญาณระหว่าง goroutine แทนที่จะเป็น channel, mutex, หรือ type ใน sync/atomic ไหม? มัน compile ผ่าน มัก “ทำงานได้” ตอนทดสอบ แต่ไม่มี happens-before guarantee เลย
  • lock ถูกถือไว้ข้าม network call, channel operation, หรือ call อื่นที่อาจ block ไหม — แทนที่จะถูกปล่อยก่อน call นั้นแล้วเอากลับมาถือเฉพาะตอนเข้าถึง shared state?
  • sync.Map จะเหมาะกว่า map ที่ป้องกันด้วย Mutex ตรงนี้ไหม หรือมันถูกหยิบมาใช้ตามความเคยชินบน workload ที่ไม่ใช่ write-once-read-many หรือ disjoint-keys?

🐛 หา bug ในโค้ดนี้

type Counter struct {
mu sync.Mutex
n int
}
func main() {
counters := []Counter{{}, {}, {}}
for _, c := range counters {
c.mu.Lock()
c.n++
c.mu.Unlock()
}
}
เฉลย

range copy แต่ละ element เข้า c ทุกรอบ — รวมถึง sync.Mutex ของมันด้วย — ดังนั้นทุกคู่ Lock/Unlock ใน loop ทำงานบน copy ที่ใช้แล้วทิ้งของ mutex และ c.n++ ไม่เคยแตะ Counter ตัวจริงใน slice เลย go vet จับได้บน Go 1.26.5:

main.go:12:9: range var c copies lock: command-line-arguments.Counter contains sync.Mutex

วิธีแก้คือ index แทนการ copy: for i := range counters { counters[i].mu.Lock(); counters[i].n++; counters[i].mu.Unlock() } หรือ range บน *Counter ถ้า slice เก็บ pointer อยู่แล้วตั้งแต่ต้น

ความแตกต่างระหว่าง sync.Mutex และ sync.RWMutex คืออะไร?
function ที่ส่งให้ sync.Once.Do จะรันกี่ครั้ง แม้ว่า 100 goroutines จะเรียก Do?
ควรใช้ sync/atomic แทน sync.Mutex สำหรับ shared integer counter เมื่อไหร่?
go test -race ทำอะไร?
go vet รายงานอะไร และเกิดอะไรขึ้นกับ counters จริง ๆ?
type Counter struct {
    mu sync.Mutex
    n  int
}

func main() {
    counters := []Counter{{}, {}, {}}
    for _, c := range counters {
        c.mu.Lock()
        c.n++
        c.mu.Unlock()
    }
}
goroutine ที่ถือ RLock บน sync.RWMutex อยู่แล้วเรียก RLock() อีกครั้ง ขณะที่ goroutine อีกตัวกำลัง block อยู่ใน Lock() เกิดอะไรขึ้น?