← Week 4 Day 20

Phase 2: sync.Mutex & sync.RWMutex Runtime Mechanics

Quiz: 0/160 pts
Phase 2: Concurrency & Runtime Mastery

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:

// ตัวอย่าง Java ReentrantLock ในระบบบัญชีธนาคาร
private final ReentrantLock lock = new ReentrantLock();
public void debit(long amount) {
    lock.lock();
    try { this.balance -= amount; }
    finally { lock.unlock(); }
}

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():

// Banking Thread-Safe Ledger Account with sync.RWMutex
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:

  1. Strictly Non-Reentrant: Mutex ใน Go ห้ามเรียก Lock() ซ้ำใน Goroutine เดียวกันเด็ดขาด หากฟังก์ชัน A ถือ Lock อยู่ แล้วไปเรียกฟังก์ชัน B ที่พยายามเรียก Lock() ตัวเดียวกัน โปรแกรมจะเกิด Deadlock ถาวร ทันที!
  2. Never Copy a Mutex: Struct ที่มี Mutex อยู่ข้างใน ห้ามส่งผ่านแบบ Value เด็ดขาด (ห้าม pass-by-value ในพารามิเตอร์ หรือกำหนดค่า b := a) เพราะการ Copy จะสร้าง Mutex ตัวใหม่ที่มีสถานะล็อกค้างเดิมติดไปด้วย ทำให้เกิด Deadlock
  3. Unlock from Any Goroutine: ใน Go คุณสามารถเรียก mu.Unlock() จาก Goroutine อื่นที่ไม่ใช่ตัวที่เรียก Lock() ได้ (มีประโยชน์ในการทำ Signaling หรือ Handshake เฉพาะทาง) แม้ว่าจะไม่แนะนำในงานทั่วไป
  4. 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:

// src/sync/mutex.go (โครงสร้างระดับไบต์ของ sync.Mutex)
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:

Bit: 31 ---------------------------------------- 3 | 2 | 1 | 0 Field: waitersCount (29 Bits) | starving | woken | locked | (1 Bit) | (1 Bit) | (1 Bit) - Bit 0 (mutexLocked = 1): มี Goroutine ใดกำลังถือสิทธิ์ Lock นี้อยู่หรือไม่ (1 = Locked, 0 = Free) - Bit 1 (mutexWoken = 2): มี Goroutine ที่กำลังถูกปลุกตื่นขึ้นมาแย่งชิง Lock หรือไม่ (ป้องกันปลุกซ้ำ) - Bit 2 (mutexStarving = 4): Mutex กำลังทำงานในโหมด Starvation Mode หรือไม่ (1 = Starving, 0 = Normal) - Bits 3..31 (waitersCount): จำนวน Goroutines ทั้งหมดที่กำลังหลับรอคอยคิวใน Semaphore (รองรับได้ถึง 536 ล้านตัว!)

Section 3: Visual Workflow & Normal vs. Starvation Mode

ปัญหาใหญ่ของระบบ Lock แบบดั้งเดิมในคอมพิวเตอร์คือ "The Tail Latency Problem": Goroutine เก่าที่ถูกส่งไปหลับในคิว มักถูก Goroutine ใหม่ที่เพิ่งวิ่งมาถึงบน CPU Core แย่ง Lock ไปกินเสมอ (เนื่องจากตัวใหม่กำลังรันอยู่บน CPU แคชจึงร้อนและได้เปรียบกว่า) Go จึงสร้างระบบสองสปีดขึ้นมา:

[MODE 1: NORMAL MODE (Throughput-Oriented)] Incoming G (Running on CPU Core) --------+ |--- แย่งชิง CAS (mutexLocked) Woken G (Waking up from Semaphore) ------+ --> Incoming G มักชนะเพราะกำลังรันอยู่บน CPU ไม่ต้องเสียเวลา Restore Registers --> Woken G แพ้ -> ถูกส่งกลับไปต่อหัวคิว Semaphore ใหม่อีกรอบ! --> ข้อดี: ได้ Throughput รวมสูงสุด เพราะ CPU ไม่ต้องหยุดสลับ Context | v (หาก Woken G รอคอย Lock ตัวเดิมนานเกิน 1 มิลลิวินาที!) [MODE 2: STARVATION MODE (Latency & Fairness-Oriented)] --> state ถูกเปิดบิต mutexStarving = 1 --> ปิดการ Spin ของ Goroutine ใหม่ทุกตัวโดยสิ้นเชิง! --> Goroutine ที่เพิ่งวิ่งมาถึง จะถูกบังคับห้ามแตะต้อง Lock และถูกส่งไปต่อท้ายคิว Semaphore ทันที --> เมื่อผู้ถือ Lock สั่ง Unlock() สิทธิ์การครอบครองจะถูก "ส่งมอบตรง (Direct Handover)" ไปยัง Goroutine ตัวแรกของคิว Semaphore ทันที! --> หลุดจาก Starvation Mode เมื่อ: 1. เป็น Waiter ตัวสุดท้ายในคิวแล้ว หรือ 2. Waiter ตัวที่ได้สิทธิ์ รอคอย Lock นี้น้อยกว่า 1ms

การทำงานของ `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 ออกจากตรรกะภายใน:

// เมธอดสาธารณะ: ควบคุมขอบเขต Lock สำหรับ Caller ภายนอก func (a *BankAccount) Transfer(to *BankAccount, amount int64) error { // กำหนด Lock Order ตาม Account ID เสมอ เพื่อป้องกัน Deadlock ข้ามบัญชี! first, second := a, to if a.account > to.account { first, second = to, a } first.mu.Lock() defer first.mu.Unlock() second.mu.Lock() defer second.mu.Unlock() if err := a.withdrawInternal(amount); err != nil { return err } to.depositInternal(amount) return nil } // เมธอดส่วนตัว (Internal/Unprotected): ทำงานบนสมมติฐานว่า Caller ถือ Lock ไว้เรียบร้อยแล้ว func (a *BankAccount) withdrawInternal(amount int64) error { if a.balance < amount { return ErrInsufficientFunds } a.balance -= amount return nil }

Section 5: Knowledge Verification Quiz

16 Comprehensive Questions — Mutex Internals, Starvation Mode & RWMutex

160 Points Total

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() เพียงครั้งเดียว