⚡ Go Senior Mentor Engine | Phase 1: Week 1 - Day 1 (Master Edition)
Score: 0 / 15
Answered: 0 / 15
Accuracy: 0%
Phase 1: Unlearning Memory Architecture Master Edition (15 Questions)

Day 1: Values vs. Pointers & Stack vs. Heap Allocation

ตำราเรียนรู้เชิงลึกระดับ Bare-Metal: ชำแหละความแตกต่างระหว่าง JVM Heap Reference และ Go Value Type, วิเคราะห์กลไก Escape Analysis แบบเจาะลึก, ความต่างของ Pass-by-Value กับ Pass-by-Pointer ในระดับ RAM และ Assembly

1. Concept Mapping: JVM Heap References vs. Go Value-First Stack

1.1 ภาพจำที่ต้องล้างจากโลก JVM (Kotlin / Java)

ใน Kotlin หรือ Java เมื่อคุณสร้าง Instance ของ Class ขึ้นมา:

val runner = Runner(bib = 101, checkpoint = 1)

สัญชาตญาณที่คุณคุ้นเคยคือ: ตัวแปรทุกตัวที่เป็น Object คือ Reference Type
Object Runner จะถูกจัดสรรลงบน JVM Heap เสมอ โดยมี Object Header ขนาด 12 ถึง 16 Bytes (Mark Word 8B เก็บสถานะ Lock/GC + Klass Word 4-8B ชี้ไปยัง Class Metadata) แปะหัวอยู่ข้างหน้าข้อมูลจริง
ตัวแปร runner ใน Stack Frame เก็บเพียง Pointer ขนาด 4 หรือ 8 Bytes ชี้ไปยังก้อน Object บน Heap และเมื่อเลิกใช้งาน มันจะกลายเป็นขยะ (Garbage) ที่ต้องรอให้ Garbage Collector (G1, ZGC) เข้ามาสแกนและตามกวาดล้าง

⚠️ Spring Boot / JVM Mental Trap: โปรแกรมเมอร์สาย Java/Kotlin มักติดนิสัย "ส่ง Pointer ทุกที่เพื่อประหยัด Memory" เพราะชินกับการส่ง Reference แต่ใน Go สำหรับ Struct ขนาดเล็ก การส่ง Pointer กลับสร้าง Escape to Heap บังคับให้ Go Runtime ต้องขอ Heap Memory และเพิ่มภาระให้ Garbage Collector หนักกว่าเดิม!

1.2 Go Runtime: Value by Default บน Stack Frame

ในภาษา Go เมื่อคุณประกาศ Struct:

runner := RunnerRecord{Bib: 101, Checkpoint: 1}
  • Go กำหนดให้ Struct เป็น Value Type โดยค่าเริ่มต้น
  • ตัวแปร runner จะถูกจัดสรรลงบน Stack Frame ของฟังก์ชันนั้นทันที
  • ขนาด Memory: มีขนาดเท่ากับขนาดของ Field ด้านในรวมกันเป๊ะๆ ไม่มี Object Header แม้แต่ Byte เดียว (0-Byte Overhead)
  • ต้นทุนการสร้าง (Allocation Cost): รวดเร็วระดับ ~1 nanosecond เพราะ CPU แค่สั่งเลื่อน Register Stack Pointer (SP) ลงไปตามขนาดไบต์ (เช่น คำสั่ง Assembly SUBQ $16, SP)
  • ต้นทุนการคืนพื้นที่ (Deallocation Cost): เท่ากับ 0 nanosecond เพราะเมื่อฟังก์ชัน Return ตัว CPU จะเลื่อน Stack Pointer กลับ คืนพื้นที่ Stack Frame ทันที Go GC ไม่ต้องเข้ามายุ่งเลยแม้แต่น้อย (Zero GC Impact / 0 allocs/op)
คุณสมบัติ Stack Frame (Go Default) Heap (JVM Default / Go Escape)
ต้นทุนเวลาการจอง (Allocation) ~0 ns (แค่ขยับ CPU Stack Pointer) ~20-50 ns (ค้นหา Free Chunk ใน Runtime)
การคืนพื้นที่ (Deallocation) ทันทีเมื่อจบฟังก์ชัน (0 ns, Zero GC) พึ่งพา Garbage Collector (Tricolor Marking)
CPU Cache Locality สูงมาก (Contiguous Memory บน Stack) เสี่ยง Cache Miss จากการกระโดดตาม Pointer
Metadata Header 0 Byte (ไม่มี Header แปะหน้า Struct) มี Runtime Allocation Metadata กำกับ
2. Visual Architecture: โครงสร้าง RAM (Pass-by-Value vs. Pass-by-Pointer)

ลองพิจารณาระบบเช็คพอยต์งานวิ่ง เมื่อนักวิ่งแตะชิป RFID ที่พรมจับเวลา การส่งข้อมูลระหว่างฟังก์ชันใน Go จะส่งผลต่อ Memory แตกต่างกันอย่างสิ้นเชิง 2 รูปแบบ:

2.1 Pass by Value: การคัดลอกข้อมูลลง Stack Frame ใหม่

type RunnerRecord struct {
    Bib        int64 // 8 Bytes
    Checkpoint int64 // 8 Bytes
}

func UpdateCheckpoint(r RunnerRecord, newCP int64) {
    r.Checkpoint = newCP // แก้ไขเฉพาะก้อน Copy บน Stack ชั่วคราว!
}
+-----------------------------------------------------------------------------------+ | CASE A: PASS BY VALUE (Zero GC, Memory Disconnected) | | | | [Caller Stack Frame]: | | runnerA: [ Bib: 101 (8B) | Checkpoint: 1 (8B) ] (Address: 0x7ffe00) | | | | │ | | ▼ memcpy 16 bytes ลง Stack Frame ใหม่ | | | | [Callee Stack Frame - UpdateCheckpoint]: | | r (Copy): [ Bib: 101 (8B) | Checkpoint: 1 (8B) ] (Address: 0x7ffdb0) | | r.Checkpoint = 2 ---> อัปเดตเฉพาะ Memory 0x7ffdb0 | | | | ผลลัพธ์: เมื่อจบฟังก์ชัน Callee Stack Frame สลายตัวทิ้งไป | | ตัวแปร runnerA ที่ต้นทาง (0x7ffe00) ยังคงมีค่า Checkpoint = 1 เหมือนเดิม! | +-----------------------------------------------------------------------------------+

2.2 Pass by Pointer: การส่ง Memory Address เพื่อแก้ไขต้นขั้ว

func UpdateCheckpointByPtr(r *RunnerRecord, newCP int64) {
    r.Checkpoint = newCP // Dereference กลับไปแก้ไข RAM ต้นขั้วโดยตรง!
}
+-----------------------------------------------------------------------------------+ | CASE B: PASS BY POINTER (Direct In-place Mutation) | | | | [Caller Stack Frame]: | | runnerA: [ Bib: 101 (8B) | Checkpoint: 1 (8B) ] (Address: 0x7ffe00) | | | | ▲ | | │ Dereference วิ่งกลับไปเขียนทับ RAM ต้นทาง | | | | [Callee Stack Frame - UpdateCheckpointByPtr]: | | r (Pointer): 0x7ffe00 (ขนาด 8 Bytes ชี้กลับไปยัง Stack Frame ของ Caller) | | r.Checkpoint = 2 ---> เขียนทับที่ Address 0x7ffe00 โดยตรง | | | | ผลลัพธ์: ค่า Checkpoint ของ runnerA ใน Caller เปลี่ยนเป็น 2 สำเร็จ | +-----------------------------------------------------------------------------------+
🎯 กฎทองคำของ Idiomatic Go: "Share Down, Copy Up"
  • Copy Up: เมื่อสร้าง Struct ในฟังก์ชันแล้วต้องการส่งกลับขึ้นไปด้านบน ให้ Return by Value ตรงๆ ตัว Go Runtime จะคัดลอกผ่าน CPU Register หรือ Stack โดยมีต้นทุนต่ำมากและไม่กวน GC
  • Share Down: เมื่อต้องการส่งข้อมูลลงไปให้ฟังก์ชันลูกแก้ไขค่า หรือเมื่อ Struct มีขนาดใหญ่มาก (> 128 Bytes) ให้ส่งเป็น Pointer &struct
3. Escape Analysis: เมื่อไหร่ที่ Go จะย้ายตัวแปรลง Heap?

คำถามสำคัญ: ถ้า Go นิยมเก็บตัวแปรไว้บน Stack แล้วเมื่อไหร่ที่มันจะย้ายลง Heap?
คำตอบคือ: ขึ้นอยู่กับการวิเคราะห์ของ Compiler ในขั้นตอน Build เรียกว่า Escape Analysis

🎯 กฎเหล็กของ Escape Analysis ใน Day 1:
"ถ้าตัวแปรมีโอกาสถูกเรียกใช้หลังจากที่ Stack Frame ของฟังก์ชันผู้สร้างมันตายไปแล้ว ตัวแปรนั้นจะหนีรอด (Escape) และต้องถูกย้ายไปสร้างบน Heap ทันที"

3.1 การ Return Pointer ออกจากฟังก์ชัน (Dangling Pointer Defense)

func NewRunnerRecord(bib int64) *RunnerRecord {
    r := RunnerRecord{Bib: bib, Checkpoint: 0}
    return &r // <--- ส่ง Memory Address ของตัวแปรท้องถิ่นออกไปข้างนอก!
}

ถ้าเป็นภาษา C โค้ดลักษณะนี้จะเกิดหายนะ Dangling Pointer ทันที เพราะ Stack Frame ถูกทำลาย แต่ Pointer ชี้ไปยังพื้นที่ที่ไม่มีอยู่จริง
ในภาษา Go ตัว Compiler จะตรวจจับการไหลของข้อมูล (Flow Analysis):

  1. ตัวแปร r ถูกสร้างขึ้นในฟังก์ชัน NewRunnerRecord
  2. ฟังก์ชัน Return ค่า &r ออกไปให้ Caller ด้านนอก
  3. Caller มีอายุยาวนานกว่า Stack Frame ของ NewRunnerRecord
  4. Compiler ตัดสินใจ: ย้ายตัวแปร r ไปจองบน Heap ตั้งแต่วินาทีแรกผ่านคำสั่ง runtime.newobject เพื่อความปลอดภัย

3.2 ตรวจสอบสิ่งที่ Compiler คิดด้วย `-gcflags="-m"`

คุณสามารถใช้คำสั่งคอมไพเลอร์ของ Go เพื่อส่องดูผลลัพธ์ของ Escape Analysis ได้โดยตรง:

$ go build -gcflags="-m" ./cmd/timing/
# chiptiming/engine/cmd/timing
./main.go:12:2: moved to heap: r
./main.go:13:9: &r escapes to heap

เมื่อเห็นข้อความ moved to heap แสดงว่าตัวแปรนั้นไม่ได้อยู่บน Stack อีกต่อไป แต่ถูกส่งไปให้ Heap และ Garbage Collector ดูแลแทน

🧠 Knowledge Verification: Master 15-Question Matrix
🛠️ Hands-on Engineering Lab 1.1: Zero-Allocation Timing Event Handler
Stack vs. Heap Lab
📋 ข้อกำหนดทางเทคนิค (Technical Specification):
  1. สร้าง struct ชื่อ CheckpointPing ประกอบด้วย BibNumber int64, CheckpointID int64, และ Timestamp int64
  2. ฟังก์ชัน NewPing(bib int64, cp int64, ts int64) CheckpointPing ต้อง Return by Value 100% เพื่อให้ Struct อยู่บน Stack (ห้าม Return Pointer)
  3. ฟังก์ชัน UpdateCheckpoint(p *CheckpointPing, newCP int64) ต้องรับพารามิเตอร์เป็น Pointer เพื่อแก้ไขค่า CheckpointID บน Memory เดิมโดยตรง
  4. ฟังก์ชัน CalculateSplitPace(p CheckpointPing, distanceKm float64) float64 ต้องรับพารามิเตอร์แบบ Pass by Value (Read-only Calculation)