Sync Primitives
เมื่อ Channels ไม่เพียงพอ
หัวข้อที่มีชื่อว่า “เมื่อ Channels ไม่เพียงพอ”Channels เหมาะสำหรับการถ่ายโอน ownership ของข้อมูลระหว่าง goroutines แต่บางครั้งคุณแค่ต้องการป้องกัน shared value ที่ goroutines หลายตัวอ่านและเขียน — cache, counter, connection pool สำหรับกรณีเหล่านี้ package sync มี primitives ระดับต่ำกว่า
sync.Mutex
หัวข้อที่มีชื่อว่า “sync.Mutex”sync.Mutex คือ mutual exclusion lock goroutine เดียวเท่านั้นที่ถือ lock ได้ในแต่ละเวลา ที่เหลือบล็อกบน Lock() จนกว่าผู้ถือเรียก Unlock()
var mu sync.Mutexvar count int
mu.Lock()count++mu.Unlock()ใช้ defer mu.Unlock() ทันทีหลัง mu.Lock() เสมอเพื่อให้ lock ถูก release แม้ function จะ panic หรือ return ก่อนเวลา
Copy Mutex
หัวข้อที่มีชื่อว่า “Copy Mutex”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
Memory Model และ Happens-before
หัวข้อที่มีชื่อว่า “Memory Model และ Happens-before”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 := falsego func() { time.Sleep(10 * time.Millisecond) done = true // plain write, no synchronization}()for !done { // plain read, no synchronization}fmt.Println("done")WARNING: DATA RACEWrite 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
หัวข้อที่มีชื่อว่า “sync.RWMutex”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
Recursive-RLock Deadlock
หัวข้อที่มีชื่อว่า “Recursive-RLock Deadlock”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 waitingmu.RLock() // blocks forever -- a writer is already queuedfatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.RWMutex.RLock]:sync.(*RWMutex).RLock(0xc000120018) /tmp/rwrecursive/main.go:25 +0x50main.main() /tmp/rwrecursive/main.go:25 +0x154
goroutine 19 [sync.RWMutex.Lock]:sync.(*RWMutex).Lock(0xc000120018) /tmp/rwrecursive/main.go:18 +0x78main.main.func1() /tmp/rwrecursive/main.go:18 +0x8cgoroutine ที่ถือ RLock ตัวแรกกำลังรอ RLock ตัวที่สองของตัวเองให้สำเร็จ; writer ที่มันเข้าคิวรออยู่ไปต่อไม่ได้จนกว่า RLock ตัวแรกจะถูกปล่อย; และไม่มีอะไรปล่อยมันเลย อย่าเรียก RLock ซ้ำเป็นครั้งที่สองบน RWMutex เดียวกันจาก goroutine ที่อาจถืออันหนึ่งอยู่แล้ว — รวมถึงทางอ้อมผ่าน function call ที่ลึกลงไปหลายชั้นด้วย
sync.Once
หัวข้อที่มีชื่อว่า “sync.Once”sync.Once รัน function ได้ครั้งเดียว ไม่ว่าจะมี goroutines กี่ตัวเรียก Do พร้อมกัน use case มาตรฐานคือ lazy, thread-safe initialization:
var once sync.Oncevar instance *Config
func getConfig() *Config { once.Do(func() { instance = loadConfig() }) return instance}sync.OnceValue และเพื่อน ๆ
หัวข้อที่มีชื่อว่า “sync.OnceValue และเพื่อน ๆ”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 แบบเงียบ ๆ
sync/atomic
หัวข้อที่มีชื่อว่า “sync/atomic”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 นี้ไปเลย นี่คือเหตุผลหลักที่ควรใช้ในโค้ดใหม่
sync.WaitGroup.Go (Go 1.25+)
หัวข้อที่มีชื่อว่า “sync.WaitGroup.Go (Go 1.25+)”WaitGroup คือ primitive ที่คุณหยิบใช้บ่อยที่สุด และตั้งแต่ Go 1.25 มีทางลัด: wg.Go(f) ทำ Add(1) เริ่ม f ใน goroutine ใหม่ และเรียก Done() เมื่อ f return
var wg sync.WaitGroupfor _, 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 ผิด ไม่ใช่เขียนโค้ดใหม่ให้)
Channels vs mutexes — คู่มือใช้งานจริง
หัวข้อที่มีชื่อว่า “Channels vs mutexes — คู่มือใช้งานจริง”| ใช้ channel เมื่อ… | ใช้ mutex เมื่อ… |
|---|---|
| ถ่ายโอน ownership ของข้อมูล | ป้องกัน cache หรือ struct ที่แชร์กัน |
| ประสาน lifecycle ของ goroutine | จัดการ counter หรือ flag ง่าย ๆ |
| Pipeline และ fan-out pattern | ข้อมูลที่อ่านบ่อยด้วย sync.RWMutex |
sync.Map
หัวข้อที่มีชื่อว่า “sync.Map”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.Mapcache.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 หลังจากถูกใช้ครั้งแรกเช่นกัน
errgroup.SetLimit
หัวข้อที่มีชื่อว่า “errgroup.SetLimit”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.Groupg.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 Scope
หัวข้อที่มีชื่อว่า “Lock Scope”Lock ควรถูกถือไว้ในช่วงที่สั้นที่สุดที่ยังทำให้ข้อมูลที่ป้องกันสอดคล้องกัน — ห้ามถือข้ามการเรียก I/O, channel send หรือ receive, หรือ operation ใด ๆ ที่อาจ block นาน การถือ mutex ข้ามการทำงานที่ช้าจะเปลี่ยน goroutine อื่นทุกตัวที่ต้องการ lock เดียวกันให้กลายเป็นคิวรอต่อจาก call ที่ช้าตัวนั้น แม้ว่าพวกมันจะไม่ได้แตะ I/O เดียวกันเลย:
// Bad: the lock is held across a network callmu.Lock()defer mu.Unlock()resp, err := http.Get(url) // every other locker waits for the network here toocache[url] = resp
// Better: only the map access needs the lockresp, err := http.Get(url)mu.Lock()cache[url] = respmu.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 state | goroutine ที่ lock ต้อง unlock เสมอ |
| sync.RWMutex | read parallel ได้ | RLock overhead > Mutex lock เมื่อ write บ่อย |
| sync.Once | init once thread-safe | ไม่สามารถ reset ได้ |
| sync/atomic | เร็วที่สุดสำหรับ simple counter | จำกัดแค่ primitive types |
| channel | composable, safer | overhead มากกว่า 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 เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อโค้ดที่ 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 อยู่แล้วตั้งแต่ต้น