← Week 5 Day 21

Phase 2: Advanced Coordination Primitives

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

Advanced Coordination Primitives: sync.WaitGroup, sync.Once & sync.Cond

ก้าวข้ามการล็อกแบบ Exclusive สู่การควบคุมจังหวะการทำงานประสานกันของ Goroutines นับพันตัว: ผ่าโครงสร้าง 64-bit Atomic State ของ `sync.WaitGroup`, การันตี Thread-Safe Lazy Initialization ด้วย Fast-Path Atomic Loading ใน `sync.Once`, และการสร้างระบบ Signaling ประสิทธิภาพสูงด้วย `sync.Cond` พร้อมหลีกเลี่ยงกับดัก Spurious Wakeups ในระบบ Core Banking Ledger

Section 0: The "Why" & Core Vocabulary

ในโลก JVM / Spring Boot เมื่อเราต้องการรอให้หลายๆ Worker เธรดทำงานเสร็จ หรือต้องการเริ่มระบบแบบ Lazy Singleton ปลอดภัย เรามักใช้คลาสใน java.util.concurrent เช่น CountDownLatch, Double-Checked Locking ร่วมกับ volatile, หรือ Object.wait() / notifyAll():

// ตัวอย่าง Java CountDownLatch และ Double-Checked Locking
CountDownLatch latch = new CountDownLatch(16);
latch.countDown(); // หักลบเคาน์เตอร์
latch.await(); // บล็อกรอจนครบ
// Double-Checked Locking Singleton
if (instance == null) { synchronized(this) { if (instance == null) instance = new Gateway(); } }

The JVM Synchronization & Signalling Trap We Are Overcoming

1. Heap-Allocated AQS Nodes & Single-Use Latch: CountDownLatch ใน Java ไม่สามารถรีเซ็ตค่าเพื่อนำกลับมาใช้ใหม่ได้ (Single-use only) หากต้องการใช้ซ้ำต้องสร้าง Instance ใหม่ลงบน Heap เสมอ และทุกการรอต้องจอง Waiter Node ใน AQS
2. Double-Checked Locking Fragility: การทำ Lazy Singleton ใน Java หากลืมคีย์เวิร์ด volatile ตัว JIT Compiler หรือ CPU Reordering จะทำให้ Thread อื่นเห็น Object ที่สร้างยังไม่เสร็จสมบูรณ์ (Partially constructed object leak)
3. Heavy Object Monitor Overhead: การใช้ synchronized (obj) { obj.wait(); } ผูกติดกับ Object Header 16 ไบต์ และบีบให้ OS ทำการ Park Thread ผ่าน Kernel Syscall ส่งผลให้ Latency การแจ้งเตือนพุ่งสูงถึง 1,000–3,000 นาโนวินาที

The Go Solution: sync.WaitGroup ใน Go ใช้ Atomic 64-bit State (Counter 32-bit + Waiter Count 32-bit) ผสานกับ Runtime Semaphore ในพื้นที่เพียง 12 ไบต์! sync.Once การันตี Zero-Race ด้วย Atomic Fast-Path โหลดค่า 0 นาโนวินาที และ sync.Cond ส่งต่อสัญญานผ่าน Intrusive notifyList โดยไม่สร้าง Garbage บน Heap

คำศัพท์สถาปัตยกรรม (Go) นิยามและพฤติกรรมระดับ Runtime / Hardware แนวคิดเทียบเท่าใน Kotlin / JVM
sync.WaitGroup ตัวประสานงานการจบงานของกลุ่ม Goroutines อาศัย 64-bit atomic state สำหรับนับงานและจำนวนผู้รอ CountDownLatch / CompletableFuture.allOf()
sync.Once โครงสร้างรับประกันการรันฟังก์ชันเพียง 1 ครั้งตลอดชีวิตของ Process ด้วย Fast-Path Atomic Load คู่กับ Mutex Slow-Path Double-Checked Locking / Kotlin lazy(LazyThreadSafetyMode.SYNCHRONIZED)
sync.Cond Condition Variable สำหรับให้ Goroutines พักรอ (Wait) และรอรับสัญญาณปลุก (Signal/Broadcast) ภายใต้ Mutex Lock Object.wait(), notify(), notifyAll() หรือ Condition ใน AQS
Spurious Wakeup ปรากฏการณ์ที่ Goroutine ตื่นขึ้นมาจาก Wait() ทั้งที่เงื่อนไขทางธุรกิจยังไม่เป็นจริง จึงต้องตรวจสอบเงื่อนไขใน for ลูปเสมอ Java Spurious Wakeups (กฎการครอบ while (!condition) wait();)
notifyList โครงสร้างคิวภายใน Go Runtime (Ticket/Wait System) ที่ใช้บริหารรายชื่อ Goroutines ที่รอรับสัญญาณใน sync.Cond Condition Object Wait Queue ใน JVM AQS

Coordination Primitives Latency & Memory Allocation (1,000,000 Operations)

Section 1: Go Syntax & Command Blueprint

ในงานสถาปัตยกรรมระบบการเงิน เรามักใช้ sync.WaitGroup, sync.Once และ sync.Cond ควบคุม Lifecycle ของ Gateway Connection และการประมวลผลธุรกรรมแบบแบตช์:

// Banking Settlement Engine using sync.WaitGroup, sync.Once, and sync.Cond
package main

import (
    "fmt"
    "sync"
    "time"
)

// PaymentGatewayManager: จัดการการเชื่อมต่อไปยัง Core Banking ด้วย sync.Once
type PaymentGatewayManager struct {
    once     sync.Once
    endpoint string
}

func (m *PaymentGatewayManager) Connect() {
    // Fast-path atomic check: ครั้งที่ 2 เป็นต้นไปจะใช้เวลาเพียง 1-2 ns โดยไม่แตะต้อง Mutex เลย!
    m.once.Do(func() {
        fmt.Println("[Init] Establishing secure TLS 1.3 socket to Core Banking...")
        m.endpoint = "tls://gateway.bank.internal:8443"
    })
}

// TransactionQueue: คิวธุรกรรมที่มีระบบส่งสัญญาณด้วย sync.Cond
type TransactionQueue struct {
    mu    sync.Mutex
    cond  *sync.Cond
    items []string
}

func NewTransactionQueue() *TransactionQueue {
    q := &TransactionQueue{}
    // สร้าง Cond โดยผูกเข้ากับ Mutex ตัวเดียวกันเสมอ
    q.cond = sync.NewCond(&q.mu)
    return q
}

// Push: เติมงานธุรกรรมเข้าคิว และส่งสัญญาณปลุก Worker 1 ตัว (Signal)
func (q *TransactionQueue) Push(txID string) {
    q.mu.Lock()
    defer q.mu.Unlock()

    q.items = append(q.items, txID)
    // Signal: ปลุก Goroutine ที่หลับรอคิวอยู่ตัวแรกสุด (FIFO)
    q.cond.Signal()
}

// Pop: ดึงงานออกจากคิว หากคิวว่าง จะหลับรอสัญญาณแบบ Zero-CPU
func (q *TransactionQueue) Pop() string {
    q.mu.Lock()
    defer q.mu.Unlock()

    // กฎเหล็ก: ต้องใช้ for loop ตรวจสอบเสมอ ห้ามใช้ if เด็ดขาด! ป้องกัน Spurious Wakeup
    for len(q.items) == 0 {
        // Wait() จะปลดล็อก mu ชั่วคราว และนำ Goroutine เข้าสู่สถานะ Park (หลับ)
        // เมื่อถูกปลุก มันจะกลับมาเข้าคิวเพื่อแย่ง Lock mu คืนก่อนรันบรรทัดถัดไป
        q.cond.Wait()
    }

    item := q.items[0]
    q.items = q.items[1:]
    return item
}

กฎเหล็ก 4 ประการในการใช้งาน Coordination Primitives:

  1. Call wg.Add() Before Spawning Goroutine: ต้องเรียก wg.Add(1) ในเธรดแม่ ก่อน บรรทัดคำสั่ง go func() เสมอ มิฉะนั้นแม่จะวิ่งไปถึง wg.Wait() และจบโปรแกรมก่อนที่ลูกจะทันได้เริ่มงาน
  2. Prevent Negative WaitGroup Counter: หากเรียก wg.Done() เกินจำนวนที่ Add() ไว้ หรือส่งค่าติดลบเข้าไปใน Add() ระบบจะเกิด Runtime Panic ทันที: panic: sync: negative WaitGroup counter
  3. sync.Cond Wait Requires Holding Lock: การเรียก c.Wait() ต้องถือ Mutex Lock เสมอ มิฉะนั้นจะเกิด Fatal Runtime Panic: sync: unlock of unlocked mutex
  4. Always Check Condition in a For-Loop: ห้ามเขียน if !ready { c.Wait() } เด็ดขาด เพราะเมื่อตื่นขึ้นมา เงื่อนไขอาจถูก Goroutine ตัวอื่นที่ตื่นก่อนหน้าแย่งชิงไปแล้ว ต้องครอบด้วย for !ready { c.Wait() } เสมอ

Section 2: Memory Architecture & Struct Anatomy

เมื่อผ่าซอร์สโค้ดของ Go Runtime ในไฟล์ src/sync/waitgroup.go, src/sync/once.go และ src/sync/cond.go เราจะพบโครงสร้างหน่วยความจำที่เรียบง่ายแต่ทรงพลังระดับฮาร์ดแวร์:

// 1. src/sync/waitgroup.go (ขนาด 12 ไบต์)
type WaitGroup struct {
    noCopy noCopy        // 0 Bytes: Marker สำหรับตรวจจับ go vet copylocks
    state  atomic.Uint64 // 8 Bytes: รวม Counter (32 บิตบน) และ Waiter Count (32 บิตล่าง)
    sema   uint32        // 4 Bytes: Semaphore สำหรับ Sleep/Wakeup ผู้รอใน Wait()
}

// 2. src/sync/once.go (ขนาด 12 ไบต์ + Mutex)
type Once struct {
    done uint32     // 4 Bytes: Atomic Flag (0 = ยังไม่รัน, 1 = รันเสร็จแล้ว)
    m    Mutex      // 8 Bytes: sync.Mutex ปกป้องช่วง Slow-Path
}

// 3. src/sync/cond.go (ขนาด 32 ไบต์)
type Cond struct {
    noCopy  noCopy      // 0 Bytes: ห้าม copy
    L       Locker      // 16 Bytes: interface iface ชี้ไปยัง Mutex/RWMutex
    notify  notifyList  // 16 Bytes: คิวควบคุมการปลุก Goroutines
    checker copyChecker // 8 Bytes: ตรวจจับการ copy struct ขณะรันไทม์
}

Dissecting the 64-bit Atomic State in `sync.WaitGroup`

ความอัจฉริยะของ Go คือการใช้ตัวแปร atomic.Uint64 เพียงตัวเดียวในการบันทึกทั้งจำนวนงานที่ต้องทำ (Counter) และจำนวน Goroutines ที่กำลังหลับรอ (Waiters):

Bit: 63 ---------------------------- 32 | 31 ----------------------------- 0 Field: counter (32 Bits) | waiter (32 Bits) (จำนวนงานที่เหลือต้องรอ wg.Done) | (จำนวน Goroutines ที่ติดใน wg.Wait) - เมื่อเรียก wg.Add(1): ทำการบวก 1 << 32 แบบ Atomic CAS - เมื่อเรียก wg.Done(): ทำการลบ 1 << 32 แบบ Atomic CAS - หาก counter กลายเป็น 0 และ waiter > 0: - สั่ง runtime_Semrelease(&wg.sema) ปลุก Goroutines ทั้งหมดที่รออยู่ใน waiter ทันที! - รีเซ็ต state ทั้งหมดกลับเป็น 0 เพื่อให้สามารถนำ WaitGroup กลับมาใช้ใหม่ได้

Section 3: Visual Workflow & Runtime Mechanics

มาดูลำดับขั้นตอนระดับฮาร์ดแวร์เมื่อ sync.Once ทำงานแบบ Fast-Path vs Slow-Path และการสลับสถานะของ Goroutine ใน sync.Cond:

[sync.Once Execution Pipeline] Caller เรียก once.Do(initFunc) | v 1. [FAST PATH: Atomic Load] atomic.LoadUint32(&o.done) == 1 ? | +--- YES ---> RETURN ทันที! (ใช้เวลาเพียง ~1 ns, Zero Mutex Contention!) | +--- NO ---> เข้าสู่ [SLOW PATH] | v o.m.Lock() (ล็อก Mutex ป้องกัน Race Condition) | v [Double-Check]: atomic.LoadUint32(&o.done) == 0 ? | +--- YES: เรียก initFunc() | atomic.StoreUint32(&o.done, 1) | +--- NO : (มีตัวอื่นรันเสร็จไปแล้วในเสี้ยววินาทีนั้น) | v o.m.Unlock() RETURN

กลไกการปลดล็อกและหลับใน `sync.Cond.Wait()`:

เมื่อ Goroutine เรียก c.Wait() ตัว Runtime จะทำงาน 3 ขั้นตอนอย่างเคร่งครัด:
1. จองบัตรคิวใน notifyList: อ่านตัวเลข Ticket ปัจจุบันและเพิ่มตัวเองเข้าไปในคิวผู้รอ
2. ปลดล็อก Mutex: เรียก c.L.Unlock() เพื่อเปิดทางให้ Goroutine อื่นสามารถเข้า Critical Section มาแก้ไขข้อมูลหรือเรียก Signal() ได้
3. หลับรอ (Park): สั่ง runtime_notifyListWait() เพื่อสละ CPU
4. ตื่นและล็อกคืน: เมื่อได้รับสัญญาณ Signal() หรือ Broadcast() Goroutine จะถูกปลุก แต่ จะต้องเรียก c.L.Lock() เพื่อแย่ง Lock กลับคืนมาก่อนเสมอ จึงจะหลุดออกจากคำสั่ง Wait() ได้

Section 4: Edge Cases, Hazards & Defensive Rules

Hazard #1: The Fatal Spurious Wakeup & "If" Trap

หากคุณเขียนการรอใน sync.Cond ด้วยคำสั่ง if:
if !queue.HasItems() { c.Wait() } item := queue.Pop() // อาจเกิด Panic: Index out of range! สาเหตุ: เมื่อมีการเรียก Broadcast() Goroutines นับร้อยจะตื่นพร้อมกัน แต่ตัวแรกที่แย่ง Lock ได้จะหยิบ Item ชิ้นเดียวไปแล้ว! เมื่อตัวที่สองได้ Lock ข้อมูลจะว่างเปล่าทันที!
กฎเหล็ก: ใช้ for !queue.HasItems() { c.Wait() } เสมอ เพื่อบังคับให้ตรวจเช็กเงื่อนไขซ้ำทุกครั้งที่ตื่น

Hazard #2: The `sync.Once` Recursive Deadlock Trap

หากฟังก์ชันที่ส่งเข้า once.Do(f) มีการเรียก once.Do(f) ซ้ำซ้อนภายในตัวเอง (Re-entrant):
var once sync.Once once.Do(func() { once.Do(...) }) // DEADLOCK ถาวรทันที! เนื่องจาก sync.Once ใช้ sync.Mutex ในการป้องกัน Slow-Path และ Mutex ของ Go เป็นแบบ Non-Reentrant ทำให้มันพยายามจะ Lock ทับตัวเอง เกิด Deadlock 100%
กฎเหล็ก: ห้ามเรียก once.Do ตัวเดียวกันซ้ำภายใน Execution Chain เด็ดขาด

The Senior Architect Idiom: `sync.Once` Failure Handling

ข้อควรระวังสำคัญของ sync.Once คือ: หากฟังก์ชัน f() คืนค่า Error หรือเกิด Panic ตัวแปร done จะยังคงถูกตั้งค่าเป็น 1 เสมอ! ส่งผลให้การเรียกครั้งต่อไปจะไม่รัน f() ใหม่อีกเลยตลอดกาล

หากคุณต้องการ Initialized ระบบที่สามารถ Retry ได้เมื่อเกิด Error ต้องใช้ Custom Once Pattern ร่วมกับ Atomic CAS:

type RetriableInit struct { done atomic.Uint32 mu sync.Mutex } func (r *RetriableInit) Do(initFunc func() error) error { if r.done.Load() == 1 { return nil } r.mu.Lock() defer r.mu.Unlock() if r.done.Load() == 0 { if err := initFunc(); err != nil { return err // เกิด Error จะไม่ตั้งค่า done ทำให้รอบถัดไปสามารถ Retry ได้! } r.done.Store(1) } return nil }

Section 5: Knowledge Verification Quiz

16 Comprehensive Questions — sync.WaitGroup, sync.Once & sync.Cond

160 Points Total

Section 6: Hands-on Engineering Labs

ฝึกฝนการเขียน Concurrency Coordination จัดการคิวและรับประกันการรันอย่างปลอดภัยตามมาตรฐานวิศวกรรมการเงิน

Lab 1: Coordinated Batch Settlement with sync.WaitGroup

โจทย์: เขียนฟังก์ชัน BatchSettle(txIDs []string) โดยสร้าง var wg sync.WaitGroup, สั่ง wg.Add(1) ก่อนแตก Goroutine ในทุกลูป, ส่ง id เข้าฟังก์ชันผ่าน Parameter, สั่ง defer wg.Done() และรอให้ทุกตัวทำงานเสร็จด้วย wg.Wait()

Lab 2: Idempotent Ledger Gateway with sync.Once

โจทย์: เขียนเมธอด InitSession() ให้กับ Struct GatewayClient โดยใช้ c.once.Do(func() { ... }) เพื่อกำหนดค่า c.connected = true เพียงครั้งเดียวตลอดการทำงาน

Lab 3: Priority Event Waiter with sync.Cond

โจทย์: เขียนเมธอด AwaitApproval() บน Struct ApprovalGate โดยล็อก g.mu.Lock(), ใช้ for !g.approved { g.cond.Wait() } เพื่อป้องกัน Spurious Wakeups และปลดล็อกเมื่อได้รับการอนุมัติ

``` eof ไฟล์บทเรียนและการจำลองเชิงสถาปัตยกรรมสำหรับ **Day 21: Coordination Primitives: `sync.WaitGroup`, `sync.Once` & `sync.Cond`** ถูกสร้างเสร็จสมบูรณ์ในรูปแบบ Single-File Interactive Application (`day21-coordination-primitives.html`) เรียบร้อยแล้วครับ ### สรุปสาระสำคัญของ Day 21: 1. **Unlearning JVM Latches & Double-Checked Locking:** เลิกใช้ `CountDownLatch` ที่ใช้ซ้ำไม่ได้และ Double-Checked Locking ที่เปราะบาง สู่ **`sync.WaitGroup` 64-bit Atomic State** และ **`sync.Once` Fast-Path Atomic Loading** 2. **Dissecting `sync.WaitGroup`:** ทำความเข้าใจโครงสร้าง 64 บิตที่แบ่งครึ่ง: 32 บิตบนเป็น Counter และ 32 บิตล่างเป็น Waiters เพื่อให้รัน Atomic CAS ปรับค่าพร้อมกันในรอบเดียว 3. **The Spurious Wakeup Guard in `sync.Cond`:** ทำไมต้องครอบการรอด้วย `for !condition { c.Wait() }` เสมอ เพื่อป้องกันไม่ให้ Goroutine ตื่นขึ้นมาประมวลผลข้อมูลที่ว่างเปล่า 4. **The `sync.Once` Panic & Deadlock Rules:** ข้อควรระวังเรื่อง Non-reentrant Mutex ใน Slow-Path และเทคนิคการเขียน Custom Once เพื่อรองรับ Retry เมื่อเกิด Error คุณสามารถเปิดไฟล์ `day21-coordination-primitives.html` บน Web Browser เพื่อศึกษาแบบโต้ตอบ ผ่าลึกสถานะ Atomic State ทำแบบทดสอบ Quiz 16 ข้อ และทดสอบฝีมือใน Engineering Labs ได้ทันทีครับ เมื่อพร้อมลุยต่อใน **Day 22: High-Performance Lock-Free Primitives with `sync/atomic`** แจ้งโค้ชได้เลยครับ!