1 · การทดลองที่ต้องเห็นด้วยตาตัวเอง
var counter int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
fmt.Println("counter =", counter)
WaitGroup ถูกต้องทุกอย่าง รอครบ 1000 ตัวจริง ๆ
— พิมพ์อะไรออกมา?
ผมรัน 5 ครั้งติดกัน:
counter = 988
counter = 947
counter = 956
counter = 963
counter = 918
ไม่เคยได้ 1000 และไม่เคยได้ตัวเลขเดิมซ้ำ
ไม่มี panic ไม่มี error ไม่มี warning — โปรแกรม "ทำงานสำเร็จ" ทุกครั้ง
แค่คำตอบผิด
ลองคิดดูว่าถ้าตัวแปรนี้คือยอดเงินคงเหลือของลูกค้า บั๊กแบบนี้ผ่าน code review ผ่านเทส ขึ้น production แล้วไปโผล่เป็น "ยอดไม่ตรง" ตอนปิดบัญชีสิ้นเดือน
2 · ทำไม counter++ ถึงไม่ปลอดภัย
counter++ ดูเหมือนคำสั่งเดียว แต่จริง ๆ CPU ทำ 3 ขั้น:
counter++ = 1) อ่านค่าจาก memory 2) บวก 1 3) เขียนกลับ
เวลา →
goroutine A: อ่าน(5) ──── +1 ──── เขียน(6)
goroutine B: อ่าน(5) ──── +1 ──── เขียน(6)
▲ ▲
│ │
อ่านทันก่อน A เขียน ทับค่าที่ A เพิ่งเขียน
ผลลัพธ์: บวกไปสองครั้ง แต่ค่าขึ้นแค่ 1 ← หายไป 1
ยิ่งมี goroutine เยอะ ยิ่งซ้อนกันบ่อย ยิ่งหายเยอะ — และเพราะจังหวะการสลับขึ้นกับ Go runtime scheduler (ตัวจัดคิวของ Go เอง ที่เลือกว่าตอนไหน goroutine ตัวไหนจะได้รัน — มันทำงานซ้อนอยู่บนตัวจัดคิวของ OS อีกที โดย OS จัดคิวแค่ระดับ thread ส่วน Go เอา goroutine เป็นแสนตัวมาสลับกันใช้ thread ไม่กี่ตัวเอง) จำนวนที่หายจึงต่างกันทุกครั้ง
ร้านหนึ่งแขวนกระดาษนับของไว้แผ่นเดียว ตอนนี้เขียนว่า 5
· พนักงาน A เดินมาเห็นเลข 5 จำไว้ในหัว แล้วเดินไปหยิบปากกา
· ระหว่างนั้นพนักงาน B เดินมา เห็นเลข 5 เหมือนกัน (เพราะ A ยังไม่ได้เขียน)
· A กลับมาเขียน 6 · แล้ว B ก็กลับมาเขียนทับเป็น 6
ของเข้ามาสองชิ้น แต่กระดาษขึ้นแค่ชิ้นเดียว — หายไปหนึ่ง
สังเกตว่าไม่มีใครนับผิดเลย ทั้งคู่ทำถูกทุกขั้นตอน ปัญหาอยู่ที่ "อ่าน" กับ "เขียน" ไม่ได้เกิดติดกันเป็นจังหวะเดียว จึงมีช่องว่างตรงกลางให้อีกคนแทรกเข้ามาได้
ที่ไม่เหมือนกัน: สองข้อ —
(1) คนเดินไปหยิบปากกาใช้เวลาเป็นวินาที เราเลยเห็นชัดว่าชนกันง่าย
แต่ CPU ทำ 3 ขั้นนี้จบในเสี้ยวไมโครวินาที ส่วนใหญ่จึงไม่ชน —
ชนแค่บางครั้ง และไม่มีใครมาบอกว่าชนตอนไหน
นี่คือเหตุผลที่บั๊กแบบนี้หลุดขึ้น production ได้ทั้งที่โค้ดดูปกติดี
(2) กระดาษมีแผ่นเดียว ใครมองก็เห็นเลขเดียวกันเสมอ แต่ในเครื่องจริงไม่ใช่ —
แต่ละ core มีสำเนาของมันเอง และ compiler ก็สลับลำดับคำสั่งได้ ·
ไม่มีอะไรรับประกันว่าค่าที่อีกฝั่งเพิ่งเขียน จะโผล่มาให้เราเห็นตอนไหน
Go จึงไม่ได้บอกว่าโปรแกรมที่มี data race แค่ "ผลเพี้ยน" แต่บอกว่า
ไม่การันตีผลลัพธ์อะไรให้เลย — อย่าคิดว่าเคสแย่สุดจบแค่ "ค่าหายไปหนึ่ง"
(ทางแก้ที่ปิดช่องนี้จริง ๆ คือ mutex · channel · sync/atomic ซึ่งจะเจอต่อจากนี้)
⚠️ map พังหนักกว่านั้นอีก — ผมลองให้ 100 goroutine เขียน map เดียวกัน รัน 3 ครั้ง ได้เหมือนกันทั้งสามครั้ง:
fatal error: concurrent map writes
Go runtime ดักไว้ให้เองสำหรับ map และฆ่าโปรแกรมทิ้งทันที
(ไม่ใช่ panic ที่ recover ได้ด้วย)
ข่าวดีคือพังเสียงดัง — ต่างจาก int ที่หายเงียบ ๆ
สิ่งที่เพิ่งเข้าใจไปมีชื่อเรียกของมันอยู่ — ใช้คำให้ตรง แล้วจะถาม senior หรือถาม AI ได้ตรงจุดกว่ามาก
- data race
- “สองคนแตะของชิ้นเดียวกันพร้อมกัน โดยที่อย่างน้อยหนึ่งคนกำลังเขียน และไม่มีใครจัดคิวให้” · เวลาเจอตัวเลขเพี้ยนแบบไม่ซ้ำเดิม ให้พูดว่า “ตรงนี้น่าจะมี data race ที่ตัวแปร counter” จะสื่อกว่าพูดว่า “ผลลัพธ์มันมั่ว”
- race condition
- คนละเรื่องกับ data race แต่ชอบมาด้วยกัน — หมายถึงผลลัพธ์เปลี่ยนไปตามจังหวะว่าใครมาก่อนมาหลัง ·
เกิดได้แม้ล็อกครบทุกจุด เช่นล็อกเพื่อเช็กว่ายอดเงินพอไหม แล้วปลดล็อก
ค่อยล็อกใหม่เพื่อหักเงิน (เรียกว่า check-then-act) — สองคนเช็กผ่านพร้อมกันได้
ยอดเลยติดลบ ทั้งที่ทุกบรรทัดมี lock คุมอยู่ ·
เคสแบบนี้
-raceเงียบสนิท เพราะไม่มี data race เลยสักจุด - atomic
- “จบในจังหวะเดียว แทรกกลางไม่ได้” ·
counter++ไม่ใช่ atomic เพราะแยกเป็น 3 ขั้น · ประโยคที่ใช้ได้จริง: “การบวกตรงนี้ atomic รึเปล่า” - critical section
- ช่วงโค้ดที่แตะของกลาง และต้องมีคนเข้าได้ทีละคนเท่านั้น ·
ในตัวอย่างนี้คือบรรทัด
counter++บรรทัดเดียว - flaky test
- เทสที่รันครั้งเดิม โค้ดเดิม แต่ผ่านบ้างไม่ผ่านบ้าง ·
data race เป็นหนึ่งในผู้ต้องสงสัยอันดับต้น ๆ แต่ไม่ใช่รายเดียว —
time.Sleep/timeout ที่ตั้งสั้นไป · เทสที่ใช้ state ร่วมกันแล้วสลับลำดับรัน · การพึ่ง service ข้างนอก ก็ทำให้ flaky ได้พอกัน · วิธีตัด data race ออกจากรายชื่อเร็วที่สุดคือรันgo test -race -count=10 ./...ดูก่อน อย่าเพิ่งด่วนสรุปว่า “รันใหม่ก็หายแล้ว”
3 · sync.Mutex — เข้าได้ทีละคน
var (
counter int
mu sync.Mutex
)
func increment() {
mu.Lock()
defer mu.Unlock()
counter++ // ตรงนี้มีได้แค่ goroutine เดียวเท่านั้น
}
Mutex ย่อมาจาก MUTual EXclusion — กันไม่ให้เข้าพร้อมกัน เหมือนกุญแจห้องน้ำ: ใครถือกุญแจอยู่ คนอื่นรอ
ผมเอาโค้ดจากหัวข้อ 1 มาใส่ mu.Lock() แล้วรันใหม่:
counter = 1000 // ทุกครั้ง ไม่มีพลาด
defer mu.Unlock() — ต้องเป็นบรรทัดถัดจาก Lock() เสมอ
ถ้าโค้ดตรงกลาง return ก่อน หรือ panic แล้วไม่ได้ Unlock
→ ทุก goroutine ที่รอกุญแจจะค้างตลอดกาล
ซึ่งแย่กว่าบั๊กเดิมอีก
เหตุผลเดียวกับ defer wg.Done() ในบทเรียน 21
4 · sync.RWMutex — คนอ่านเข้าพร้อมกันได้
Mutex กันทุกคน แม้แต่คนที่แค่ อ่าน —
ซึ่งเกินจำเป็น เพราะคนอ่านหลายคนพร้อมกันไม่ได้ทำให้ข้อมูลเสีย
สิ่งที่ต้องกันคือ "อ่านขณะมีคนเขียน" และ "เขียนพร้อมกัน"
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // R = Read — หลายคนพร้อมกันได้
defer c.mu.RUnlock()
value, ok := c.data[key]
return value, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock() // ไม่มี R = Write — คนเดียวเท่านั้น
defer c.mu.Unlock()
c.data[key] = value
}
ป้ายเมนูแขวนอยู่หน้าร้านหนึ่งป้าย
· ลูกค้ายืนอ่านพร้อมกันสิบคนก็ได้ ไม่มีใครทำให้ป้ายเสีย = RLock()
· แต่ตอนพนักงานจะปีนขึ้นไปเปลี่ยนราคา ต้องให้ทุกคนถอยออกก่อน
และขึ้นไปเปลี่ยนได้ทีละคน = Lock()
Mutex ธรรมดาให้เข้าทีละคนหมด ไม่ว่าจะมาอ่านหรือมาเขียน ·
RWMutex เร็วกว่าในงานที่อ่านเยอะ เพราะมันไม่ไล่คนอ่านออกโดยไม่จำเป็น
ที่ไม่เหมือนกัน: สามข้อ —
(1) ลูกค้าที่เผลออ่านป้ายตอนพนักงานกำลังแปะราคาใหม่ อย่างมากก็แค่เห็นราคาเก่า
แต่ในโปรแกรม การอ่านขณะมีคนเขียนไม่ได้จบแค่ “ได้ค่าเก่า” —
ถ้าของกลางเป็น map โปรแกรมตายทั้งตัวได้เลย
(2) หน้าร้านจริงมีคิวตามมารยาท ใครมาก่อนได้ก่อน แต่ Go
ไม่รับประกันลำดับว่าใครจะได้ lock ก่อนหลัง — อย่าเขียนโค้ดที่พึ่งลำดับนั้น
(3) หน้าร้านจริง คนอ่านทยอยเดินเข้ามาได้เรื่อย ๆ จนพนักงานไม่มีจังหวะขึ้นไปเปลี่ยนป้ายเลย
แต่ Go ไม่ปล่อยให้เป็นแบบนั้น — พอมีคนขอ Lock() ต่อคิวแล้ว
คนที่มาขอ RLock() ทีหลังจะถูกให้รอด้วย เพื่อไม่ให้คนเขียนอดตาย ·
แปลว่า RLock() ไม่ใช่ของฟรีที่ใส่ตรงไหนก็ไม่มีวันบล็อก
(ตารางข้างล่างที่เขียนว่า “ได้ กี่คนก็ได้” จึงหมายถึงตอนที่ยังไม่มีคนเขียนต่อคิวอยู่)
RLock() (อ่าน) | Lock() (เขียน) | |
|---|---|---|
| คนอ่านคนอื่นเข้าได้ไหม | ✅ ได้ กี่คนก็ได้ | ❌ ไม่ได้ |
| คนเขียนเข้าได้ไหม | ❌ ไม่ได้ | ❌ ไม่ได้ |
ผมทำ cache สองตัว เหมือนกันทุกอย่างต่างแค่ Mutex กับ
RWMutex แล้วให้ 2000 goroutine อ่านพร้อมกัน รัน 3 ครั้ง:
sync.Mutex : 12.4ms · 13.8ms · 7.1ms
sync.RWMutex : 0.8ms · 0.7ms · 0.4ms
เร็วขึ้น 16–20 เท่า
ใช้ RWMutex เมื่ออ่านบ่อยกว่าเขียนมาก ๆ
(cache, config, ตารางค้นหา) — ซึ่งเป็นกรณีส่วนใหญ่ใน backend
แต่ถ้าอ่านกับเขียนพอ ๆ กัน Mutex ธรรมดาเรียบง่ายกว่าและไม่ช้ากว่าเท่าไหร่
- mutex / mutual exclusion
- “กุญแจที่ทำให้เข้าได้ทีละคน” · อ่านออกเสียงว่า มิว-เท็กซ์ · เวลาคุยงานให้พูดว่า “ตัวแปรนี้มี mutex คุมอยู่รึเปล่า”
- thread-safe / goroutine-safe
- “ของชิ้นนี้ให้หลายคนเรียกพร้อมกันได้ ไม่พัง” · เป็นคำถามแรกที่ควรถามก่อนหยิบ library ของคนอื่นมาใช้ใน goroutine: “type นี้ thread-safe ไหม” — ถ้า doc ไม่เขียนไว้ ให้ถือว่าไม่
- lock contention
- “อาการที่คนแย่งกุญแจกันจนต้องรอคิว” · เป็นคำที่ใช้อธิบายว่า ทำไมใส่ lock แล้วโปรแกรมช้าลง · ตัวเลข 12.4ms ข้างบนคือหน้าตาของ contention
- deadlock
- นิยามแท้คือ “รอกันเป็นวงจนไม่มีใครไปต่อได้” —
A ถือกุญแจ 1 อยู่แล้วรอกุญแจ 2 · B ถือกุญแจ 2 อยู่แล้วรอกุญแจ 1 ·
ส่วนการลืม
Unlock()แล้วคนอื่นรอตลอดกาล เป็นคนละสาเหตุ (เรียก lock leak) แต่ให้อาการค้างเหมือนกัน
Go ฟ้องด้วยข้อความall goroutines are asleep - deadlock!ได้เฉพาะตอนที่ goroutine ทุกตัวในโปรแกรมหลับพร้อมกันเท่านั้น — ถ้าเป็นเซิร์ฟเวอร์ที่ยังมี goroutine อื่นทำงานอยู่ จะไม่มีข้อความอะไรโผล่มาเลย มีแค่ request นั้นค้างเงียบ ๆ · “ไม่เห็นข้อความ” จึงไม่ได้แปลว่าไม่มี deadlock (เจอในภารกิจท้ายบท) - reentrant
- “เจ้าของกุญแจขอกุญแจดอกเดิมซ้ำได้ไหม” ·
sync.Mutexของ Go ไม่ reentrant ขอซ้ำเมื่อไหร่คือค้างทันที · เป็นคำที่ทำให้ senior เข้าใจปัญหาคุณได้ในประโยคเดียว: “ผมล็อกซ้อนตัวเองเพราะ mutex มันไม่ reentrant”
5 · Race Detector — เครื่องมือที่ต้องติดตัว
ปัญหาใหญ่ของ data race คือมันไม่พังทุกครั้ง
— เทสผ่าน 100 ครั้งไม่ได้แปลว่าไม่มี
Go จึงมีเครื่องมือที่ตรวจจับการเข้าถึงที่ไม่ปลอดภัย ไม่ใช่ผลลัพธ์ที่ผิด:
go run -race main.go
go test -race ./...
go build -race
ผมรันโค้ดจากหัวข้อ 1 ด้วย -race ได้แบบนี้:
==================
WARNING: DATA RACE
Read at 0x00c00009c038 by goroutine 12:
main.main.func1()
/path/to/main.go:15 +0x68
Previous write at 0x00c00009c038 by goroutine 14:
main.main.func1()
/path/to/main.go:15 +0x78
Goroutine 12 (running) created at:
main.main()
/path/to/main.go:13 +0x6c
==================
counter = 934
Found 2 data race(s)
exit status 66
| บรรทัด | อ่านว่าอะไร |
|---|---|
Read at 0x... by goroutine 12 | ใครอ่าน · อ่านที่ไหนในโค้ด |
Previous write ... by goroutine 14 | ใครเขียนทับก่อนหน้านั้น |
created at | goroutine นั้นเกิดจากบรรทัดไหน — ชี้ไปที่ go ของคุณ |
exit status 66 | ออกด้วยรหัสไม่เป็นศูนย์ → CI (ระบบที่รันเทสให้เองทุกครั้งที่ push โค้ด) ฟ้องได้ |
เพราะ -race ทำให้ exit code เป็น 66 เมื่อเจอ race
pipeline จะแดงเองโดยไม่ต้องมีใครมานั่งอ่าน log
ค่าใช้จ่าย: ช้าลง ~2–20 เท่า และกิน memory มากขึ้น →
ใช้ตอนเทส ไม่ใช่ตอน build ขึ้น production
ต่อยอดจาก Day 5: go test -race ./... คือคำสั่งเดียวที่ควรจำ
⚠️ race detector ไม่ได้เห็นทุกอย่าง —
มันตรวจเฉพาะสิ่งที่เกิดขึ้นจริงในรอบที่รัน
ถ้าเส้นทางนั้นไม่ถูกเรียกก็ไม่เจอ
"รัน -race แล้วเงียบ" ≠ "ไม่มี race"
— เหมือน coverage 100% ที่ไม่ได้แปลว่าไม่มีบั๊ก
(บทเรียน 14)
แต่ถ้ามันฟ้อง แปลว่ามีจริง 100% ไม่เคยฟ้องผิด
ร้านติดกล้องไว้ตรงแคชเชียร์จุดเดียว
· ถ้าเปิดเทปแล้วเห็นภาพคนหยิบของ — จบเลย เกิดขึ้นจริงแน่นอน เถียงไม่ได้
· แต่ถ้าเปิดเทปแล้วไม่เจออะไร แปลว่า “ไม่มีเหตุในมุมกล้อง
ในช่วงที่กล้องถ่ายไว้” เท่านั้น ไม่ได้แปลว่าทั้งร้านปลอดภัย
-race ให้คำตอบแบบเดียวกันเป๊ะ:
ฟ้อง = มีจริง 100% · เงียบ = แค่รอบนี้ ในเส้นทางที่โค้ดวิ่งผ่านจริง ยังไม่เจอ
เทสของคุณจึงเป็น “มุมกล้อง” — เส้นทางไหนที่เทสไม่เคยเรียก มันก็ไม่มีทางเห็น
ที่ไม่เหมือนกัน: กล้องเปิดทิ้งไว้ตลอดได้เพราะแทบไม่มีต้นทุน
แต่การเปิด -race ทำให้โปรแกรมช้าลง 2–20 เท่าและกิน memory มากขึ้น
จึงเปิดตอนเทส ไม่เปิดตอน build ขึ้น production
- race detector
- เครื่องมือตรวจ data race ที่ติดมากับ Go · เปิดด้วยธง
-race· ประโยคที่ใช้ได้จริงตอนส่งงาน: “รันgo test -race ./...แล้วเงียบครับ” - instrumentation
- “การแอบใส่โค้ดตรวจสอบเข้าไปในโปรแกรมตอน build” · เป็นเหตุผลที่
-raceทำให้ช้าลง — ไม่ใช่เพราะโค้ดคุณช้า แต่เพราะมีตัวจดบันทึกเพิ่มเข้าไปทุกจุดที่แตะหน่วยความจำ - false positive / false negative
- “ฟ้องผิด” กับ “มีแต่ไม่ฟ้อง” · race detector ไม่มี false positive แต่มี false negative ได้ — ตรงกับเรื่องมุมกล้องพอดี
- exit code / exit status
- ตัวเลขที่โปรแกรมทิ้งไว้ตอนจบ · 0 = สำเร็จ · ไม่ใช่ 0 = มีปัญหา ·
CI ตัดสินผ่าน/ไม่ผ่านจากเลขนี้เป็นหลัก — ข้อความจะสวยแค่ไหน ถ้า exit code เป็น
0มันก็ถือว่าผ่าน · เลข66จึงทำให้ pipeline แดงเองได้
6 · เก้าข้อที่ทำให้ concurrency ปลอดภัย
| # | กฎ | ทำไม |
|---|---|---|
| 1 | ไม่ share ก็ไม่ต้อง lock | ทางที่ปลอดภัยที่สุดคือไม่มีของกลาง — ส่งผ่าน channel แทน |
| 2 | ของกลางต้องมีเจ้าของ | ตอบให้ได้ว่า ใครเขียนได้ · ใครอ่านได้ · sync ด้วยอะไร |
| 3 | Lock ให้แคบ แต่ครบ | อย่าครอบ network/DB call ไว้ใน lock — คนอื่นรอฟรี |
| 4 | อย่า copy sync primitive (ของที่จำสถานะข้างในไว้ — copy แล้วสถานะเพี้ยน · ไม่ได้มีแค่ใน package sync) | Mutex · RWMutex · WaitGroup — go vet จับให้ |
| 5 | defer Unlock() เสมอ | return กลางทางหรือ panic แล้วยังปลดล็อก |
| 6 | ทุก goroutine ต้องตอบได้ว่าจบเมื่อไหร่ | ตอบไม่ได้ = leak (บทเรียน 23) |
| 7 | คิดเรื่องยกเลิกตั้งแต่ออกแบบ | ใส่ context ทีหลังยากกว่ามาก (บทเรียน 25) |
| 8 | รัน race detector เป็นนิสัย | go test -race ./... |
| 9 | "ทำ concurrent ได้" ≠ "ควรทำ" | เร็วขึ้น 5% แต่ debug ยากขึ้น 10 เท่า = ไม่คุ้ม |
ขยายกฎข้อ 3 — Lock ให้แคบ
❌ ล็อกคลุมงานช้า
mu.Lock()
doNetworkRequest() // 200ms
doDatabaseQuery() // 50ms
calculate()
updateCounter()
mu.Unlock()
ทุกคนรอ 250ms ทั้งที่ของกลางถูกแตะแค่บรรทัดสุดท้าย
✅ ล็อกเท่าที่จำเป็น
result := doSlowWork() // นอก lock
mu.Lock()
cache[key] = result // แตะของกลางแค่นี้
mu.Unlock()
งานหนักทำพร้อมกันได้ · lock ถือแค่เสี้ยววินาที
ขยายกฎข้อ 4 — อย่า copy
// ❌ value receiver = ได้ Mutex สำเนา = ไม่ได้ล็อกอะไรเลย
func (c Cache) Set(k, v string) { ... }
// ✅ pointer receiver
func (c *Cache) Set(k, v string) { ... }
go vet ฟ้องว่า
passes lock by value: sync.Mutex contains sync.noCopy
— เป็นเหตุผลที่ struct ที่มี mutex ต้องใช้ pointer receiver เสมอ
(ต่อจาก บทเรียน 07)
ทดสอบความเข้าใจ
1. 1000 goroutine ทำ counter++ โดยมี WaitGroup ถูกต้อง ได้ผลอะไร?
ผมรัน 5 ครั้งได้ 988 · 947 · 956 · 963 · 918
WaitGroup รับประกันแค่ว่า "ทุกตัวทำงานจบแล้ว"
ไม่ได้รับประกันว่าทำงานถูกต้อง
counter++ คือ 3 ขั้น (อ่าน–บวก–เขียน) ที่แทรกกันได้
→ มีคนเขียนทับ → ค่าหาย · ไม่ panic เพราะไม่มีอะไรผิดกฎภาษา
2. go run -race เจอ race แล้วจะเกิดอะไร?
โปรแกรมทำงานต่อจนจบ แต่พิมพ์ WARNING: DATA RACE
บอกบรรทัดที่อ่าน บรรทัดที่เขียน และบรรทัดที่ go เกิด
ปิดท้ายด้วย Found N data race(s) และ
exit status 66 — ซึ่งทำให้ CI แดงเองอัตโนมัติ
3. cache ที่อ่าน 1000 ครั้งต่อเขียน 1 ครั้ง ควรใช้อะไร?
RWMutex ให้คนอ่านเข้าพร้อมกันกี่คนก็ได้ กันเฉพาะตอนเขียน
ผมวัดจริงกับ 2000 goroutine ที่อ่านพร้อมกัน:
Mutex 12.4ms · RWMutex 0.8ms — เร็วขึ้น 16 เท่า
ตัวเลือกที่ 3 ผิด: อ่านขณะมีคนเขียนอยู่ก็ยังเป็น race
(และ map จะได้ fatal error: concurrent map writes)
4. ทำไม struct ที่มี sync.Mutex ต้องใช้ pointer receiver?
Go ส่งค่าแบบ pass-by-value (บทเรียน 07) → value receiver ได้สำเนาของ Mutex
→ Lock() ล็อกสำเนา → goroutine อื่นเข้าได้อยู่ดี
เท่ากับไม่ได้ล็อกอะไรเลย
go vet ฟ้อง:
passes lock by value: sync.Mutex contains sync.noCopy
กฎเดียวกันกับ WaitGroup ในบทเรียน 21
ต่อยอดจากภารกิจบทเรียน 20 (Cache[K, V] ที่คุณเขียนไว้)
— ท้าทาย 2 ของบทนั้นถามว่าทำไมมันยังใช้หลาย goroutine ไม่ได้ วันนี้มาแก้กัน
- พิสูจน์ก่อนว่าพัง: เขียนเทสที่ยิง 100 goroutine
Setคนละ key พร้อมกัน แล้วเช็คLen() == 100
รันgo testธรรมดา — น่าจะได้fatal error: concurrent map writesเพราะข้างในCacheเป็น map (หัวข้อ 2) ไม่ใช่int
ถ้าบังเอิญผ่าน ให้รันซ้ำอีก 5 ครั้ง แล้วจดว่าผ่านกี่ครั้ง พังกี่ครั้ง
จากนั้นรันgo test -race— คราวนี้ได้WARNING: DATA RACEที่ชี้บรรทัดชนกันตรง ๆ ต่างกันตรงไหน? - เพิ่ม
mu sync.RWMutexเข้าไปใน struct SetและDeleteใช้Lock/Unlock·GetและLenใช้RLock/RUnlock
ใช้deferทุกอัน- รัน
go test -race ./...อีกครั้ง — ต้องเงียบสนิท - ท้าทาย 1: เปลี่ยน receiver ของ
Setจาก*Cacheเป็นCache(ไม่มีดาว) แล้วรันgo vet ./...ก่อน รันเทส — vet ว่าอะไร? - ท้าทาย 2 — deadlock ตัวเอง: เขียน method
GetOrSet(k, v)ที่ข้างในเรียกc.Get(k)แล้วถ้าไม่เจอก็เรียกc.Set(k, v)โดยที่ตัวGetOrSetเองก็Lock()ไว้ด้วย → รันแล้วเกิดอะไร?
นี่คือบั๊กที่เจอบ่อยมากในโค้ดจริง - ท้าทาย 3: วัดความต่าง — ทำ cache สองเวอร์ชัน
(
MutexvsRWMutex) แล้วยิง 2000 goroutine อ่าน จับเวลาทั้งสองแบบ ได้ต่างกันกี่เท่า?
โปรแกรมค้างตลอดกาล ผมรันแล้วได้:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.RWMutex.RLock]:
คำในวงเล็บชี้ตรงจุดเลย (แบบเดียวกับที่ฝึกในบทเรียน 21) —
GetOrSet ถือ lock อยู่
แล้วเรียก Get ที่พยายามขอ lock ตัวเดิมอีกครั้ง
sync.Mutex ของ Go ไม่ใช่ reentrant —
เจ้าของ lock ขอซ้ำเองก็ยังต้องรอ และมันจะรอตัวเองไปตลอดกาล
ทางแก้ที่เป็นสำนวนมาตรฐาน:
แยก method ที่ไม่ล็อก ออกมาเป็นตัว unexported
(ชื่อขึ้นต้นด้วยตัวพิมพ์เล็ก — package อื่นเรียกไม่ได้)
แล้วให้ทั้ง Get และ GetOrSet เรียกตัวนั้น
func (c *Cache[K, V]) get(k K) (V, bool) { // ไม่ล็อก — ผู้เรียกต้องล็อกมาแล้ว
v, ok := c.data[k]
return v, ok
}
func (c *Cache[K, V]) Get(k K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
return c.get(k)
}
func (c *Cache[K, V]) GetOrSet(k K, v V) (V, bool) {
c.mu.Lock() // ล็อกครั้งเดียวคลุมทั้งอ่านและเขียน
defer c.mu.Unlock()
if old, ok := c.get(k); ok { // เรียกตัวที่ไม่ล็อก จึงไม่ขอ lock ซ้ำ
return old, true
}
c.data[k] = v
return v, false
}
กฎที่ถอดออกมาได้:
• method ตัวใหญ่ (exported) เป็นคนล็อก
• method ตัวเล็ก (unexported) ไม่ล็อกเลย และห้ามเรียกจากข้างนอก
• method ที่ล็อกแล้ว ห้ามเรียก method ที่ล็อกด้วยกันเอง
💬 ข้อ 1 คือข้อที่ผมอยากเห็นผลที่สุด —
สังเกตว่าเทสธรรมดาบอกได้แค่ "พัง" แต่ไม่บอกว่าบรรทัดไหนชนกับบรรทัดไหน
ส่วน -race ชี้ให้เห็นทั้งคู่
และถ้าของกลางเป็น int แทน map
เทสธรรมดาจะผ่านเงียบ ๆ ทั้งที่โค้ดผิด
— นั่นคือเหตุผลที่ต้องใส่ -race ทุกครั้ง
ถ้าคุณเห็นความต่างนั้นด้วยตาตัวเอง คุณจะไม่มีวันลืมใส่ -race อีกเลย
บทถัดไป: วิธีบอก goroutine ที่กระจายไป 10 ชั้นว่า "งานนี้ไม่ต้องทำต่อแล้ว"
อ้างอิง
- Data Race Detector — คู่มือทางการของ
-race - The Go Memory Model — นิยามอย่างเป็นทางการว่า data race คืออะไร · ยากกว่าที่ต้องรู้ตอนนี้
- pkg.go.dev — sync.Mutex · sync.RWMutex
- Introducing the Go Race Detector — Go Blog
- Go Code Review Comments — Synchronous Functions