บทเรียน 24 · Day 6 · Concurrency

Data Race, Mutex และ Race Detector

~16 นาที · ต้องมี: บทเรียน 21, บทเรียน 08 (defer)

บั๊กที่คุณเจอมาทั้งหมดจนถึงบทที่แล้ว มีลักษณะร่วมกันอย่างหนึ่ง: มันพังเหมือนเดิมทุกครั้ง รันสิบครั้งก็ผิดสิบครั้งแบบเดียวกัน
บทนี้คือบั๊กคนละสายพันธุ์ — รันสิบครั้งผิดสิบแบบ และบางครั้งก็ถูก ซึ่งแย่กว่ามาก เพราะมันผ่านการเทสของคุณไปได้

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 atgoroutine นั้นเกิดจากบรรทัดไหน — ชี้ไปที่ go ของคุณ
exit status 66ออกด้วยรหัสไม่เป็นศูนย์ → CI (ระบบที่รันเทสให้เองทุกครั้งที่ push โค้ด) ฟ้องได้
✅ ใส่ไว้ใน CI ตั้งแต่วันแรก

เพราะ -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 ด้วยอะไร
3Lock ให้แคบ แต่ครบอย่าครอบ network/DB call ไว้ใน lock — คนอื่นรอฟรี
4อย่า copy sync primitive (ของที่จำสถานะข้างในไว้ — copy แล้วสถานะเพี้ยน · ไม่ได้มีแค่ใน package sync)Mutex · RWMutex · WaitGroup — go vet จับให้
5defer 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

🛠️ ภารกิจ — ทำ Cache ให้ปลอดภัยจริง

ต่อยอดจากภารกิจบทเรียน 20 (Cache[K, V] ที่คุณเขียนไว้) — ท้าทาย 2 ของบทนั้นถามว่าทำไมมันยังใช้หลาย goroutine ไม่ได้ วันนี้มาแก้กัน

  1. พิสูจน์ก่อนว่าพัง: เขียนเทสที่ยิง 100 goroutine Set คนละ key พร้อมกัน แล้วเช็ค Len() == 100
    รัน go test ธรรมดา — น่าจะได้ fatal error: concurrent map writes เพราะข้างใน Cache เป็น map (หัวข้อ 2) ไม่ใช่ int
    ถ้าบังเอิญผ่าน ให้รันซ้ำอีก 5 ครั้ง แล้วจดว่าผ่านกี่ครั้ง พังกี่ครั้ง
    จากนั้นรัน go test -race — คราวนี้ได้ WARNING: DATA RACE ที่ชี้บรรทัดชนกันตรง ๆ ต่างกันตรงไหน?
  2. เพิ่ม mu sync.RWMutex เข้าไปใน struct
  3. Set และ Delete ใช้ Lock/Unlock · Get และ Len ใช้ RLock/RUnlock
    ใช้ defer ทุกอัน
  4. รัน go test -race ./... อีกครั้ง — ต้องเงียบสนิท
  5. ท้าทาย 1: เปลี่ยน receiver ของ Set จาก *Cache เป็น Cache (ไม่มีดาว) แล้วรัน go vet ./... ก่อน รันเทส — vet ว่าอะไร?
  6. ท้าทาย 2 — deadlock ตัวเอง: เขียน method GetOrSet(k, v) ที่ข้างในเรียก c.Get(k) แล้วถ้าไม่เจอก็เรียก c.Set(k, v) โดยที่ตัว GetOrSet เองก็ Lock() ไว้ด้วย → รันแล้วเกิดอะไร?
    นี่คือบั๊กที่เจอบ่อยมากในโค้ดจริง
  7. ท้าทาย 3: วัดความต่าง — ทำ cache สองเวอร์ชัน (Mutex vs RWMutex) แล้วยิง 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 ชั้นว่า "งานนี้ไม่ต้องทำต่อแล้ว"

อ้างอิง