Day 16

Phase 2 Kickoff: Goroutines & Concurrency Primitives

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

Goroutines Lifecycle, M:N Preemptive Scheduling & 2KB Stack Mechanics

ก้าวเข้าสู่แกนกลางของความเร็วในภาษา Go: ปลดแอกข้อจำกัด 1MB Thread Stack และ Context Switch Overhead ของ JVM สู่ Goroutine (Lightweight User-Space Green Thread) ขนาดเริ่มต้นเพียง 2KB พร้อมผ่าลึกกลไกการสลับงานแบบ Preemptive M:N Scheduler ในระดับ Bare-Metal CPU

Section 0: The "Why" & Core Vocabulary

ในโลกของ JVM (Java / Kotlin / Spring Boot) การรองรับงานพร้อมกัน (Concurrency) เดิมพึ่งพา 1:1 OS Thread Model โดยทุกครั้งที่คุณสั่ง new Thread() หรือให้ Spring สร้าง Worker Thread ระบบจะเรียก Kernel Syscall clone() เพื่อสร้าง OS Thread ขนาด 1MB ขึ้นมาหนึ่งเส้น

The JVM Thread & Project Loom Trap We Are Overcoming

1. Memory Bloat: OS Thread 1 เส้นใช้ Stack คงที่ประมาณ 1,024 KB (1MB) หากเปิด 10,000 Threads ระบบต้องใช้ RAM สูงถึง 10 GB เพียงเพื่อเก็บ Call Stack!
2. Expensive Context Switching: เมื่อ OS ทำการสลับ Thread (Context Switch) ซีพียูต้องบันทึก Register ทั้งหมด, Flush TLB (Translation Lookaside Buffer), และสลับโหมด User-to-Kernel ซึ่งกินเวลาสูงถึง 1,000–2,000 นาโนวินาที
3. Even Kotlin Coroutines: แม้ Kotlin จะมี Coroutine (Virtual suspension) แต่ยังต้องอาศัย State Machine Bytecode Generation และมีขอบเขตจำกัดเฉพาะฟังก์ชันที่มีคีย์เวิร์ด suspend ทำให้เกิดปัญหา "Function Color Problem" (ฟังก์ชันธรรมดาเรียก suspend ไม่ได้)

The Go Solution: Go ฝัง M:N User-Space Scheduler เข้ามาใน Runtime ตั้งแต่วันแรก ทุกฟังก์ชันสามารถรันเป็น Goroutine ได้ทันทีโดยไม่ต้องเปลี่ยน Signature เริ่มต้นขนาด Stack เพียง 2,048 Bytes (2KB) และสลับ Context ใน User-Space ด้วยเวลาเพียง 10–20 นาโนวินาทีเท่านั้น!

คำศัพท์สถาปัตยกรรม (Go) นิยามและพฤติกรรมระดับ Runtime / Hardware แนวคิดเทียบเท่าใน Kotlin / JVM
Goroutine (G) Lightweight User-space Thread ที่ควบคุมโดย Go Runtime มี Stack ขนาดไดนามิกเริ่มต้นที่ 2KB Java 21 Virtual Threads (Project Loom) / Kotlin Coroutine
OS Thread (M) Machine Thread ของระบบปฏิบัติการที่ผูกกับ CPU Core จริง ทำหน้าที่หยิบ Goroutine ไปประมวลผล Platform Thread (java.lang.Thread)
Logical Processor (P) Context ทางตรรกะที่เก็บ Local Run Queue ของ Goroutines โดยปกติมีจำนวนเท่ากับ runtime.GOMAXPROCS (จำนวน CPU Cores) ForkJoinPool Worker / CoroutineDispatcher
Asynchronous Preemption กลไกตั้งแต่ Go 1.14 ที่ใช้สัญญาณระดับ OS (SIGURG) สั่งขัดจังหวะ Goroutine ที่รันลูป CPU แน่นๆ เพื่อสลับให้ตัวอื่นทำงานอย่างเป็นธรรม JVM Thread Interruption (แต่ Go ทำอัตโนมัติใน Runtime)
Contiguous Stack Growth การขยาย Stack โดยจอง Chunk ใหม่ที่มีขนาด 2 เท่า แล้วคัดลอก Stack เก่าไปวางพร้อมปรับ Pointer (Stack Copying) ไม่ใช้ Split Stack JVM Fix Stack Depth (ก่อให้เกิด StackOverflowError)

Memory Footprint & Context Switch Cost: 10,000 Concurrent Units

Section 1: Go Syntax & Command Blueprint

ในภาษา Go การสร้าง Goroutine ทำได้ง่ายดายด้วยคีย์เวิร์ดเดียวคือ go วางหน้าการเรียกฟังก์ชันใดๆ แต่วิศวกรต้องเข้าใจกลไกการประสานงาน (Coordination) ด้วย sync.WaitGroup เพื่อป้องกันไม่ให้ Main Goroutine สิ้นสุดการทำงานก่อน Worker เสมอ

// Banking Ingestion Worker Pattern with sync.WaitGroup
package main

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

func processTransaction(wg *sync.WaitGroup, txID string, amount int64) {
    // 1. defer wg.Done() เพื่อหักลบ Counter เมื่อ Worker ฟังก์ชันทำงานเสร็จสมบูรณ์
    defer wg.Done()

    // จำลองการประมวลผลธุรกรรมทางการเงิน
    fmt.Printf("[Worker] Processing %s for %d cents\n", txID, amount)
    time.Sleep(10 * time.Millisecond)
}

func main() {
    var wg sync.WaitGroup
    transactions := []struct {
        id     string
        amount int64
    }{
        {"TX-101", 50000},
        {"TX-102", 12500},
        {"TX-103", 99000},
    }

    for _, tx := range transactions {
        // 2. wg.Add(1) ต้องเรียกใน Goroutine แม่ "ก่อน" เรียกคำสั่ง go func เสมอ!
        wg.Add(1)

        // 3. ส่งค่า tx.id และ tx.amount เข้าไปในพารามิเตอร์ของฟังก์ชันเพื่อตัดขาด Reference
        go processTransaction(&wg, tx.id, tx.amount)
    }

    // 4. wg.Wait() จะบล็อก Main Goroutine จนกระทั่ง Counter ของ WaitGroup กลายเป็น 0
    wg.Wait()
    fmt.Println("[Ledger] All concurrent transactions settled successfully!")
}

คำสั่งตรวจสอบและวิเคราะห์ Goroutine ระหว่างรัน:

$ go test -race ./...                          // เปิด ThreadSanitizer ตรวจจับ Data Race
runtime.NumGoroutine()                        // คืนค่าจำนวน Goroutine ที่มีชีวิตอยู่ทั้งหมด
runtime.GOMAXPROCS(0)                         // อ่านจำนวน Logical Processors (P) ปัจจุบัน
  • wg.Add(1): ห้ามใส่ไว้ข้างในลูปของ Goroutine ลูกเด็ดขาด มิฉะนั้นอาจเกิด Race Condition ที่ Main Goroutine รันไปถึง wg.Wait() ก่อนที่ลูกจะทันได้บวกเลข
  • &wg (Pointer): Struct sync.WaitGroup ห้ามส่งผ่านแบบ Value เด็ดขาด เพราะการ Copy ค่าจะทำให้ Counter ภายในแยกออกจากกัน

Section 2: Memory Architecture & The `runtime.g` Anatomy

อะไรที่ทำให้ Goroutine มีขนาดเพียง 2KB? เมื่อผ่า Source Code ของ Go Runtime (src/runtime/runtime2.go) เราจะพบว่า Goroutine เป็นเพียง Struct ธรรมดาตัวหนึ่งชื่อ g:

// src/runtime/runtime2.go (แกนกลางของ Goroutine)
type g struct {
    // 1. Stack Allocation Boundaries
    stack       stack   // 16 Bytes: stack.lo (ล่างสุด) และ stack.hi (บนสุด)
    stackguard0 uintptr // 8 Bytes: จุดตรวจเช็ก Stack Overflow ใน Function Prologue

    // 2. Machine & Execution Context
    m           *m      // 8 Bytes: ชี้ไปยัง OS Thread (M) ตัวจริงที่กำลังประมวลผลมันอยู่
    sched       gobuf   // โครงสร้างบันทึก CPU Registers สำหรับ Context Switch
    goid        int64   // 8 Bytes: Goroutine ID ภายใน Runtime
    atomicstatus atomic.Uint32 // 4 Bytes: สถานะ เช่น _Grunning, _Grunnable, _Gwaiting

    // 3. Preemption Flag
    preempt     bool    // 1 Byte: แจ้งเตือนให้ Goroutine ตัวนี้ยอมสละ CPU
    ...
}

// gobuf: เก็บเฉพาะ Register ที่จำเป็นในการฟื้นตัวกลับมาทำงาน
type gobuf struct {
    sp   uintptr // Stack Pointer (ตำแหน่งหัว Stack ล่าสุด)
    pc   uintptr // Program Counter (ตำแหน่งคำสั่ง Machine Code ถัดไป)
    g    guintptr
    ret  uintptr
    lr   uintptr // Link Register (บน ARM64)
}

Contiguous Stack Allocation vs Split Stack

ในยุค Go 1.3 ยุคแรก Go เคยใช้แนวทาง "Split Stack" (เมื่อ Stack เต็ม จะสร้าง Linked List เชื่อม Stack ก้อนใหม่) ซึ่งทำให้เกิดปัญหา "Hot Split" (หากมีการเรียกฟังก์ชันข้ามรอยต่อในลูป CPU จะเสียเวลาสร้างและลบ Stack ตลอดเวลา)

ตั้งแต่ Go 1.4 เป็นต้นมา Go เปลี่ยนมาใช้ Contiguous Stack Copying:

[Goroutine Initial State: 2 KB] +------------------------------------+ <-- stack.hi (0x800) | Current Call Frames | | | +------------------------------------+ <-- SP (Stack Pointer) | Free Space (~1.5 KB) | +------------------------------------+ <-- stackguard0 (Threshold) +------------------------------------+ <-- stack.lo (0x000) | v (ฟังก์ชัน Recursion ลึกเกิน 2 KB: runtime.morestack) [New Doubled State: 4 KB Contiguous Memory] +------------------------------------+ <-- New stack.hi (0x1000) | Copied Call Frames (All pointers | | adjusted to new addresses) | +------------------------------------+ <-- New SP | Abundant Free Space (~2.8 KB) | +------------------------------------+ +------------------------------------+ <-- New stack.lo (0x000) * Stack เก่าขนาด 2KB จะถูกคืนให้ Go Heap Allocator ทันที ปราศจากปัญหา Hot Split!

Section 3: Visual Workflow & The GMP Scheduler Model

หัวใจที่ขับเคลื่อน Goroutine นับแสนตัวพร้อมกันคือ GMP Model:

[Global Run Queue (GRQ)] <-- Goroutines ที่ถูกแย่งงานหรือเพิ่งสร้างใหม่ | v +---------------------------------------------------------------+ | Logical Processor (P) Logical Processor (P) | | LRQ: [G2] [G3] [G4] (Max 256)| LRQ: [G6] [G7] (Work Stealing!) | +------------------------------+--------------------------------+ | | v v +------------------+ +------------------+ | OS Thread (M1) | | OS Thread (M2) | | Executing: [G1] | | Executing: [G5] | +------------------+ +------------------+ | | v v +------------------+ +------------------+ | CPU Core 0 | | CPU Core 1 | +------------------+ +------------------+

Work-Stealing Algorithm ในเสี้ยววินาที:

  1. เมื่อ M1 ประมวลผล G1 เสร็จ จะดึง G2 จาก Local Run Queue (LRQ) ของ P ตัวเองมาทำงาน
  2. หาก LRQ ของตัวเองว่างเปล่า M1 จะสุ่มตรวจดู P ตัวอื่นในระบบ และทำ Work Stealing: ขโมยงานจำนวน ครึ่งหนึ่ง (50%) จาก LRQ ของ Processor ตัวอื่นมาใส่ในคิวตัวเองทันที!
  3. หากไม่มี Processor ตัวใดมีงานเหลือ จึงจะไปดึงจาก Global Run Queue (GRQ) หรือหลับรอสัญญาณ (Idle Sleep)

Section 4: Edge Cases, Hazards & Defensive Rules

Hazard #1: The Loop Variable Capture Trap

ใน Go ก่อนเวอร์ชัน 1.22 ตัวแปรลูป for i := range items มี Memory Address ตัวเดิมตลอดทุกลูป หากเรียก go func() { fmt.Println(i) }() ตัว Goroutine ทั้งหมดจะอ่านค่า i ของรอบสุดท้ายเพียงค่าเดียว!
กฎเหล็ก: ส่งตัวแปรผ่าน Parameter ของฟังก์ชันเสมอ: go func(val int) { ... }(i) แม้ว่า Go 1.22+ จะแก้ scoping ให้แล้ว แต่การส่ง Parameter ถือเป็น Defensive Coding ที่ชัดเจนที่สุด

Hazard #2: Goroutine Panic Crashing the Entire Process

ใน Java หาก Thread ลูกเกิด Unhandled Exception ตัว Thread หลักและ Server จะยังคงทำงานต่อไปได้ แต่ใน Go: หากมี Panic เกิดขึ้นใน Goroutine ลูกใดๆ และไม่มีการดักด้วย recover() ตัว Go Runtime จะสั่ง Crash ทั้ง Process ของโปรแกรมธนาคารทันที!
กฎเหล็ก: Background Worker ทุกตัวที่รับคำสั่งจากภายนอก ต้องมี defer func() { if r := recover(); r != nil { ... } }() ดักไว้เสมอ

Section 5: Knowledge Verification Quiz

16 Comprehensive Questions — Concurrency Primitives & Runtime Mechanics

160 Points Total

Section 6: Hands-on Engineering Labs

ฝึกฝนการเขียน Concurrency ป้องกันข้อผิดพลาดด้าน Memory Race และกั้น Panic Boundary ตามมาตรฐานระดับสถาปัตยกรรมธนาคาร

Lab 1: Safe Concurrent Worker Dispatcher

โจทย์: เขียนฟังก์ชัน DispatchWorkers(ids []string, wg *sync.WaitGroup) โดยวนลูปเรียก wg.Add(1) ก่อนเริ่ม Goroutine และส่ง id เข้าไปใน Anonymous Goroutine ฟังก์ชันผ่าน Parameter พร้อมเรียก defer wg.Done() ภายใน Goroutine ลูก

Lab 2: Safe Panic Isolation Boundary

โจทย์: เขียนฟังก์ชัน ExecuteSafely(fn func()) เพื่อรัน fn() ใน Goroutine ใหม่ โดยต้องมี defer กั้นดัก recover() เอาไว้ หากเกิด Panic จะต้องไม่ทำให้โปรแกรมหลักล่ม

Lab 3: Synchronized Fan-Out Accumulator

โจทย์: เขียนฟังก์ชัน RunBatchWorkers(count int) โดยสร้าง var wg sync.WaitGroup, สั่ง wg.Add(count), วนลูปเปิด Goroutine จำนวน count ตัว และรอผลลัพธ์ด้วย wg.Wait() ก่อนคืนค่า