Low-Level Synchronization: sync.Mutex, sync.RWMutex, Starvation Mode & Non-Reentrancy
ก้าวข้ามการส่งสารผ่าน Channel สู่การปกป้อง In-Memory State ความเร็วสูง: เจาะลึกโครงสร้างระดับ 8 ไบต์ของ `sync.Mutex`, การทำงานของ Normal Mode (สลับกัน Spin บน CPU) เทียบกับ Starvation Mode (ส่งมอบตรงข้ามคิวตัดหาง Latency), ทำไม Go ถึงปฏิเสธ Reentrant Locks โดยสิ้นเชิง, และอันตรายระดับระบบล่มจากการคัดลอก Mutex ผ่าน Value
Section 0: The "Why" & Core Vocabulary
ในโลก JVM / Spring Boot เมื่อเราต้องการปกป้องการแย่งกันเขียนข้อมูลตัวเลขบัญชี (Data Race บน Account Balance) เรามักใช้คีย์เวิร์ด synchronized หรือคลาส java.util.concurrent.locks.ReentrantLock:
The JVM Object Monitor & ReentrantLock Trap We Are Overcoming
1. Massive Memory Overhead: ใน Java ทุก Object จะมี Object Header ขนาด 12–16 ไบต์ (Mark Word + Klass Word) เพื่อเก็บสถานะ Monitor Lock และหากใช้ ReentrantLock ตัวคลาสจะต้องจอง Object เพิ่มอีก 32–48 ไบต์บน Heap สำหรับ AQS (AbstractQueuedSynchronizer) State และ Thread Reference
2. False Sense of Safety from Re-entrancy: ReentrantLock อนุญาตให้ Thread เดิมเรียก lock() ซ้ำซ้อนได้หลายครั้ง ซึ่งดูเหมือนสะดวก แต่มักซ่อนบั๊กเชิงสถาปัตยกรรม (Inconsistent Intermediate State) เพราะนักพัฒนามักเผลอเรียกเมธอดอื่นที่แอบปลดหรือเปลี่ยนสถานะของ Data โดเมนกลางคัน
3. Heavyweight OS Futex Swapping: เมื่อเกิด Contention สูงใน JVM ระบบจะยกระดับเป็น Fat Lock (Inflated Monitor) ซึ่งบีบให้ OS ทำการ Block Thread ผ่าน Kernel Syscall ส่งผลให้ Latency พุ่งสูงถึง 1,000–3,000 นาโนวินาที
The Go Solution: sync.Mutex ใน Go เป็นเพียง Struct ขนาดจิ๋วเพียง 8 ไบต์ (ประกอบด้วย state int32 และ sema uint32) ไม่ต้องการ Heap Object แยก และไม่ใช้ Re-entrancy เพื่อป้องกันการซ่อนสถานะที่ไม่สมบูรณ์ พร้อมอัลกอริทึมสองสปีด: Active CPU Spinning ควบคู่กับ Runtime Starvation Prevention
| คำศัพท์สถาปัตยกรรม (Go) | นิยามและพฤติกรรมระดับ Runtime / Hardware | แนวคิดเทียบเท่าใน Kotlin / JVM |
|---|---|---|
| sync.Mutex | Mutual Exclusion Lock ขนาด 8 ไบต์ ป้องกันไม่ให้ Goroutine มากกว่า 1 ตัวเข้าถึง Critical Section ในเวลาเดียวกัน | ReentrantLock / synchronized block |
| Normal Mode | โหมดตั้งต้น: Goroutine ที่เพิ่งตื่นหรือมาใหม่จะพยายาม Spin บน CPU แย่งชิง Lock กับคิวเดิม ให้ Throughput รวมสูงสุด | JVM Non-fair Lock (Active Spin ก่อนเข้าคิว) |
| Starvation Mode | โหมดป้องกันความอดอยาก: หาก Goroutine รอคอย Lock นานเกิน 1ms ตัว Mutex จะสลับโหมด บังคับส่งมอบ Lock ให้หัวแถว FIFO ทันทีโดยห้าม Spin | JVM Fair Lock (แต่ Go สลับโหมดอัตโนมัติแบบ Hybrid) |
| Non-Reentrant | คุณสมบัติที่ไม่อนุญาตให้ Goroutine เดิมเรียก Lock() ซ้ำเป็นครั้งที่ 2 หากเรียกซ้ำจะเกิด Deadlock ถาวรทันที |
ไม่มีใน JVM (JVM Locks อนุญาตให้ Re-enter ได้โดยนับ Hold Count) |
| sync.RWMutex | Reader/Writer Mutual Exclusion Lock อนุญาตให้มี Reader อ่านข้อมูลพร้อมกันได้นับพันตัว แต่หากมี Writer เข้ามาจะกันสิทธิ์แบบ Exclusive | ReentrantReadWriteLock / StampedLock |
| Runtime Semaphore (sema) | ตัวแปร uint32 ที่ทำหน้าที่เป็น Sleep/Wakeup Token ใน Go Runtime ควบคุมผ่าน runtime_SemacquireMutex |
LockSupport.park() / unpark() ใน AQS |
Lock Acquisition Latency & Memory Overhead (1,000,000 Operations under Contention)
Section 1: Go Syntax & Command Blueprint
ในภาษา Go การใช้งาน sync.Mutex และ sync.RWMutex มักใช้วิธีวางคู่กับตัวแปรที่ต้องการปกป้องภายใน Struct เดียวกัน และควบคุมขอบเขตด้วยคำสั่ง defer mu.Unlock():
package main
import (
"errors"
"fmt"
"sync"
)
var (
ErrInsufficientFunds = errors.New("insufficient funds")
ErrNegativeAmount = errors.New("amount must be positive")
)
// BankAccount: รวบรวมตัวแปร Mutex ไว้ติดกับฟิลด์ข้อมูลที่ต้องการปกป้อง
type BankAccount struct {
mu sync.RWMutex // 24 Bytes: Reader/Writer Lock
account string
balance int64 // จำนวนเงินในหน่วยสตางค์ (Fixed-point Cents)
}
// NewBankAccount: Constructor คืนค่า Pointer เสมอ (ห้ามคืน Value เพื่อป้องกัน Mutex Copy!)
func NewBankAccount(acc string, initial int64) *BankAccount {
return &BankAccount{
account: acc,
balance: initial,
}
}
// Balance: การอ่านข้อมูล ใช้ Read Lock (RLock / RUnlock)
// อนุญาตให้ Goroutines อื่นอ่านพร้อมกันได้หลายร้อยตัวโดยไม่บล็อกกันเอง
func (a *BankAccount) Balance() int64 {
a.mu.RLock()
defer a.mu.RUnlock()
return a.balance
}
// Deposit: การเขียนข้อมูล ใช้ Exclusive Lock (Lock / Unlock)
// จะบล็อกทั้ง Reader และ Writer ตัวอื่นทั้งหมดจนกว่าจะทำงานเสร็จ
func (a *BankAccount) Deposit(amount int64) error {
if amount <= 0 {
return ErrNegativeAmount
}
a.mu.Lock()
defer a.mu.Unlock() // กฎเหล็ก: ปลดล็อกทันทีเมื่อฟังก์ชันคืนค่า แม้จะเกิด panic ก็ตาม!
a.balance += amount
return nil
}
// Withdraw: ตรวจสอบและตัดยอดเงินแบบ Atomic ภายใน Critical Section
func (a *BankAccount) Withdraw(amount int64) error {
if amount <= 0 {
return ErrNegativeAmount
}
a.mu.Lock()
defer a.mu.Unlock()
if a.balance < amount {
return ErrInsufficientFunds
}
a.balance -= amount
return nil
}
กฎเหล็ก 4 ประการในการใช้งาน Mutex ใน Go:
- Strictly Non-Reentrant: Mutex ใน Go ห้ามเรียก
Lock()ซ้ำใน Goroutine เดียวกันเด็ดขาด หากฟังก์ชัน A ถือ Lock อยู่ แล้วไปเรียกฟังก์ชัน B ที่พยายามเรียกLock()ตัวเดียวกัน โปรแกรมจะเกิด Deadlock ถาวร ทันที! - Never Copy a Mutex: Struct ที่มี Mutex อยู่ข้างใน ห้ามส่งผ่านแบบ Value เด็ดขาด (ห้าม pass-by-value ในพารามิเตอร์ หรือกำหนดค่า
b := a) เพราะการ Copy จะสร้าง Mutex ตัวใหม่ที่มีสถานะล็อกค้างเดิมติดไปด้วย ทำให้เกิด Deadlock - Unlock from Any Goroutine: ใน Go คุณสามารถเรียก
mu.Unlock()จาก Goroutine อื่นที่ไม่ใช่ตัวที่เรียกLock()ได้ (มีประโยชน์ในการทำ Signaling หรือ Handshake เฉพาะทาง) แม้ว่าจะไม่แนะนำในงานทั่วไป - Use defer Unlock() as Default: ยกเว้นกรณีที่ต้องการเค้นความเร็วใน Hot Path ระดับนาโนวินาที การใช้
defer mu.Unlock()ถือเป็น Best Practice เพื่อรับประกันว่า Lock จะถูกปลดเสมอแม้ฟังก์ชันจะออกจากลูปด้วย Early Return หรือ Panic
Section 2: Memory Architecture & The `sync.Mutex` Anatomy
เมื่อคุณผ่าซอร์สโค้ดของ Go Runtime ในไฟล์ src/sync/mutex.go คุณจะพบว่า sync.Mutex มีขนาดเพียง 8 ไบต์ เท่านั้นบนระบบ 64-bit:
type Mutex struct {
state int32 // 4 Bytes: Bitmask บันทึกสถานะ Lock, Woken, Starving และจำนวน Waiters
sema uint32 // 4 Bytes: Semaphore Token สำหรับให้ Runtime Park/Ready Goroutines
}
// src/sync/rwmutex.go (โครงสร้างระดับไบต์ของ sync.RWMutex = 24 Bytes)
type RWMutex struct {
w Mutex // 8 Bytes: Mutex หลักสำหรับควบคุม Writers
writerSem uint32 // 4 Bytes: Semaphore สำหรับ Writer ที่รอ Reader ออกไปหมด
readerSem uint32 // 4 Bytes: Semaphore สำหรับ Reader ที่รอ Writer ปลดล็อก
readerCount atomic.Int32 // 4 Bytes: จำนวน Readers ที่กำลังอ่านอยู่ (บวกค่าลบเมื่อ Writer รอ)
readerWait atomic.Int32 // 4 Bytes: จำนวน Readers ที่ Writer กำลังรอให้ออกไป
}
Dissecting the 32-bit `state` Field in `sync.Mutex`
ความอัจฉริยะของวิศวกรผู้สร้าง Go คือการอัดข้อมูลทั้งหมดของ Lock ลงในจำนวนเต็ม 32 บิตเพียงตัวเดียวผ่าน Bitmask:
Section 3: Visual Workflow & Normal vs. Starvation Mode
ปัญหาใหญ่ของระบบ Lock แบบดั้งเดิมในคอมพิวเตอร์คือ "The Tail Latency Problem": Goroutine เก่าที่ถูกส่งไปหลับในคิว มักถูก Goroutine ใหม่ที่เพิ่งวิ่งมาถึงบน CPU Core แย่ง Lock ไปกินเสมอ (เนื่องจากตัวใหม่กำลังรันอยู่บน CPU แคชจึงร้อนและได้เปรียบกว่า) Go จึงสร้างระบบสองสปีดขึ้นมา:
การทำงานของ `sync.RWMutex` เมื่อมี Writer รอดำเนินการ:
ในระบบฐานข้อมูลหรือแคช หากมีคำสั่งอ่าน (Readers) วิ่งเข้ามาตลอดเวลา คำสั่งเขียน (Writer) อาจเกิดปัญหา Writer Starvation ไม่ได้เขียนเลยตลอดกาล
sync.RWMutex ของ Go แก้ปัญหานี้อย่างแยบยล: เมื่อ Writer เรียก Lock() ตัว Runtime จะนำค่าคงที่ rwmutexMaxReaders (ประมาณ 1,073,741,824) ไปลบออกจาก readerCount ทำให้ค่าติดลบมหาศาล! ส่งผลให้ Readers ตัวใหม่ๆ ที่พยายามเรียก RLock() ตรวจพบค่าติดลบและถูกสั่งหลับรอทันที ปล่อยให้ Readers เก่าที่ค้างอยู่ประมวลผลจนหมด และส่งสิทธิ์ให้ Writer ทำงานอย่างรวดเร็ว
Section 4: Edge Cases, Hazards & Defensive Rules
Hazard #1: The Fatal Re-entrancy Self-Deadlock
ใน Java คุณสามารถเขียนเมธอด synchronized A() { B(); } โดยที่ B() ก็มี synchronized ได้อย่างปลอดภัย
แต่ใน Go การทำแบบนี้จะทำให้โปรแกรม Deadlock ทันที:
func (a *Account) A() { a.mu.Lock(); a.B(); a.mu.Unlock() }
func (a *Account) B() { a.mu.Lock(); ... } // บล็อกรอตัวเองตลอดกาล!
กฎเหล็ก: แยกฟังก์ชันออกเป็น 2 ชั้น: เมธอดสาธารณะ A() ทำหน้าที่ Lock/Unlock และเรียกเมธอดส่วนตัว bUnprotected() ที่ไม่ทำ Lock
Hazard #2: The Mutex Copy-by-Value Disaster
หากคุณส่ง Struct ที่มี Mutex เข้าไปในฟังก์ชันแบบ Pass-by-Value:
func UpdateBalance(acc BankAccount) { acc.mu.Lock() ... }
Go จะทำการคัดลอก Memory 8 ไบต์ของ Mutex ไปเป็นตัวใหม่! ทำให้การ Lock เกิดขึ้นบน Mutex ตัวสำเนา ไม่ได้ปกป้องข้อมูลใน Struct ตัวจริงเลยแม้แต่น้อย เกิด Silent Data Race ทันที!
กฎเหล็ก: ส่งผ่าน Struct ที่มี Mutex ด้วย Pointer (*BankAccount) เสมอ และรันเครื่องมือ go vet เพื่อตรวจจับ copylocks
The Senior Architect Idiom: Public Protected vs Private Unprotected
วิธีจัดการ Non-Reentrancy ในระบบธนาคารระดับสากล คือการแยกฟังก์ชันที่มี Lock ออกจากตรรกะภายใน:
Section 5: Knowledge Verification Quiz
16 Comprehensive Questions — Mutex Internals, Starvation Mode & RWMutex
Section 6: Hands-on Engineering Labs
ฝึกฝนการเขียน Concurrency Synchronization ปกป้อง Critical Section และตัดปัญหา Deadlock ตามมาตรฐานวิศวกรรมการเงิน
Lab 1: Thread-Safe Banking Ledger with sync.Mutex
โจทย์: เขียนเมธอด Deposit(amount int64) error และ Withdraw(amount int64) error ให้กับ Struct SafeAccount โดยใช้ a.mu.Lock() และ defer a.mu.Unlock() อย่างถูกต้อง และคืนค่า error หากยอดเงินติดลบหรือไม่พอ
Lab 2: Read-Heavy Cache with sync.RWMutex
โจทย์: เขียนเมธอด Get(key string) (int, bool) โดยใช้ c.mu.RLock() และเมธอด Set(key string, val int) โดยใช้ c.mu.Lock() บน Struct StatsCache
Lab 3: Refactoring Re-entrancy via Unprotected Internals
โจทย์: หลีกเลี่ยง Non-Reentrant Deadlock โดยแยกฟังก์ชันภายใน depositUnprotected(amount int64) ที่ไม่มี Lock และให้เมธอดสาธารณะ BatchDeposit(amounts []int64) เรียกใช้งานหลังจากถือ a.mu.Lock() เพียงครั้งเดียว