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():
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 และการประมวลผลธุรกรรมแบบแบตช์:
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:
- Call wg.Add() Before Spawning Goroutine: ต้องเรียก
wg.Add(1)ในเธรดแม่ ก่อน บรรทัดคำสั่งgo func()เสมอ มิฉะนั้นแม่จะวิ่งไปถึงwg.Wait()และจบโปรแกรมก่อนที่ลูกจะทันได้เริ่มงาน - Prevent Negative WaitGroup Counter: หากเรียก
wg.Done()เกินจำนวนที่Add()ไว้ หรือส่งค่าติดลบเข้าไปในAdd()ระบบจะเกิด Runtime Panic ทันที:panic: sync: negative WaitGroup counter - sync.Cond Wait Requires Holding Lock: การเรียก
c.Wait()ต้องถือ Mutex Lock เสมอ มิฉะนั้นจะเกิด Fatal Runtime Panic:sync: unlock of unlocked mutex - 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 เราจะพบโครงสร้างหน่วยความจำที่เรียบง่ายแต่ทรงพลังระดับฮาร์ดแวร์:
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):
Section 3: Visual Workflow & Runtime Mechanics
มาดูลำดับขั้นตอนระดับฮาร์ดแวร์เมื่อ sync.Once ทำงานแบบ Fast-Path vs Slow-Path และการสลับสถานะของ Goroutine ใน sync.Cond:
กลไกการปลดล็อกและหลับใน `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:
Section 5: Knowledge Verification Quiz
16 Comprehensive Questions — sync.WaitGroup, sync.Once & sync.Cond
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 และปลดล็อกเมื่อได้รับการอนุมัติ