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 เสมอ
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 ระหว่างรัน:
wg.Add(1): ห้ามใส่ไว้ข้างในลูปของ Goroutine ลูกเด็ดขาด มิฉะนั้นอาจเกิด Race Condition ที่ Main Goroutine รันไปถึงwg.Wait()ก่อนที่ลูกจะทันได้บวกเลข&wg (Pointer): Structsync.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:
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:
Section 3: Visual Workflow & The GMP Scheduler Model
หัวใจที่ขับเคลื่อน Goroutine นับแสนตัวพร้อมกันคือ GMP Model:
Work-Stealing Algorithm ในเสี้ยววินาที:
- เมื่อ M1 ประมวลผล G1 เสร็จ จะดึง G2 จาก Local Run Queue (LRQ) ของ P ตัวเองมาทำงาน
- หาก LRQ ของตัวเองว่างเปล่า M1 จะสุ่มตรวจดู P ตัวอื่นในระบบ และทำ Work Stealing: ขโมยงานจำนวน
ครึ่งหนึ่ง (50%)จาก LRQ ของ Processor ตัวอื่นมาใส่ในคิวตัวเองทันที! - หากไม่มี 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
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() ก่อนคืนค่า