ข้ามไปยังเนื้อหา

Runtime Internals: อ่าน Go จากด้านใน

ทุกโมดูลก่อนหน้านี้สอนให้คุณ เขียน Go: syntax, type, method, concurrency primitive, standard library ไม่มีโมดูลไหนบังคับให้คุณรู้ว่าสิ่งเหล่านั้นทำงานจริงอย่างไร คุณเขียน goroutine ที่ทำงานได้ เขียน sync.Mutex ที่ถูกต้อง เขียน struct สิบกว่า field ได้ โดยไม่เคยต้องถามว่า scheduler ทำอะไรกับตัวแรก “ถูกต้อง” สำหรับตัวที่สองหมายความว่าอย่างไรจริง ๆ หรือตัวที่สามถูกจัดวางใน memory อย่างไร

ช่องว่างตรงนี้คือจุดที่โค้ด Go ที่ AI เขียนพังบ่อยที่สุด — และเป็นจุดที่ reviewer ที่ไม่เคยเห็นกลไกด้านล่างจับไม่ได้ snippet ที่ AI generate มาอาจถูก syntax เป๊ะ, ผ่าน go vet, แต่ยังทำให้ goroutine leak, race บน flag ที่ share กัน, หรือ allocate ทุกครั้งที่เรียกใน hot loop ไม่มีข้อไหนเป็น syntax error ทั้งสามข้อคือข้อเท็จจริงระดับ runtime

โมดูลนี้มีหน้าที่เดียว: ให้ความรู้ runtime สี่ชิ้นที่ทำให้คุณดู source ของคนอื่น — ไม่ต้องรัน ไม่ต้อง profile แค่อ่าน — แล้วตอบสามคำถามนี้ได้อย่างมั่นใจ:

  1. สิ่งนี้ allocate หรือไม่? (escape analysis, memory layout)
  2. สิ่งนี้ race ได้ไหม? (memory model, happens-before)
  3. สิ่งนี้ leak หรือค้างตลอดไปได้ไหม? (scheduler, goroutine lifecycle)
บทเรียนตอบคำถามอะไร
Memory Modelwrite ในหนึ่ง goroutine จะรับประกัน visible ต่อ read ใน goroutine อื่นเมื่อไหร่?
Schedulergoroutine ตัวหนึ่งขึ้นไปทำงานบน CPU core ได้อย่างไร และอะไรทำให้มันค้าง?
GC and Escape Analysisค่าตัวนี้อยู่บน stack หรือ heap และ collector ทำอะไรกับสิ่งที่ escape?
Memory Layoutค่าตัวนี้กิน byte เท่าไหร่จริง ๆ และถูกจัดวางอย่างไร?
Cgo and FFImemory model, scheduler และมุมมองของ GC ต่อโลกเปลี่ยนไปอย่างไร เมื่อโค้ด Go เรียกเข้าไปใน C?

บทเรียน Goroutines และ Sync Primitives แนะนำ GMP model, work stealing, async preemption และรูปร่างพื้นฐานของ memory model ไปแล้ว — โมดูลนี้ไปไกลกว่าการแนะนำเข้าสู่กลไกจริง และ link กลับไปแทนที่จะพูดซ้ำ บทเรียน Pointers แนะนำ escape analysis ในระดับ “ทำไม pointer ไม่ได้เร็วกว่าเสมอไป”; GC and Escape Analysis ลงลึกไปที่ collector ทำอะไรกับสิ่งที่ escape บทเรียน Interfaces แสดง interface value สองคำ (two-word) และ itab ไปแล้ว; Memory Layout ใส่ตัวเลข byte ให้กับทุก type ไม่ใช่แค่ interface

คำถามที่รันดูได้จริง: runtime รู้อะไรเกี่ยวกับโปรแกรมของคุณอยู่แล้วบ้าง?

หัวข้อที่มีชื่อว่า “คำถามที่รันดูได้จริง: runtime รู้อะไรเกี่ยวกับโปรแกรมของคุณอยู่แล้วบ้าง?”

ก่อนจะเข้ากลไกที่ลึกกว่านี้ package runtime เองก็ตอบคำถามทั้งสามข้อในเวอร์ชันง่าย ๆ ได้จากภายในโปรแกรมที่กำลังรันอยู่ — ไม่ต้องใช้ profiler เลย

package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
fmt.Println("Go version:", runtime.Version())
fmt.Println("GOARCH/GOOS:", runtime.GOARCH, runtime.GOOS)
fmt.Println("NumCPU:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
fmt.Println("goroutines before:", runtime.NumGoroutine())
var wg sync.WaitGroup
block := make(chan struct{})
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-block // parked, not spinning on CPU
}()
}
fmt.Println("goroutines after launch:", runtime.NumGoroutine())
close(block)
wg.Wait()
fmt.Println("goroutines after Wait:", runtime.NumGoroutine())
}

Output จริงบน darwin/arm64, 11 logical CPU:

Go version: go1.27.0
GOARCH/GOOS: arm64 darwin
NumCPU: 11
GOMAXPROCS: 11
goroutines before: 1
goroutines after launch: 4
goroutines after Wait: 1

runtime.NumGoroutine() นับได้ 4 หลังจาก launch 3 ตัว (main goroutine บวกอีกสามตัวที่ parked อยู่) แล้วกลับมา 1 หลัง wg.Wait() — นี่คือวิธีตรวจ leak ที่ถูกที่สุดที่มี: snapshot จำนวนก่อนกับหลังโค้ดที่ต้องการตรวจรัน แล้ว assert ว่ามันกลับมาที่ baseline คุณจะเจอเทคนิคนี้อีกครั้งในบท Scheduler

flowchart TD
  subgraph Left["Your only view of unfamiliar Go"]
    Src["source code"] -->|"does this look right?"| Compiles["compiles, vets clean"]
    Compiles --> Idiomatic["looks idiomatic"]
  end
  subgraph Right["What actually decides behavior"]
    MM["memory model (happens-before)"] -->|"is this write visible elsewhere?"| Sched["scheduler (GMP)"]
    Sched -->|"does this goroutine ever run again?"| GCEA["GC + escape analysis"]
    GCEA -->|"does this allocate, and when is it freed?"| Layout["memory layout (how many bytes, how aligned?)"]
  end
  Compiles --> Sched
อ่าน source code รู้แค่ว่า compile ผ่านและดู idiomatic แต่ memory model, scheduler, GC และ memory layout ต่างหากที่ตัดสินพฤติกรรมจริง

การอ่าน Go ให้ดีคือการรันคอลัมน์ขวาในหัวของคุณ ในขณะที่สายตากำลังทำแค่คอลัมน์ซ้าย

สิ่งที่ได้ประโยชน์ต้นทุน
รู้ memory modelมองเห็น race ได้โดยไม่ต้องรัน -raceต้องศึกษาจริงจัง — happens-before ไม่ใช่แค่ “เรียงบรรทัดเดียวกัน”
รู้ schedulerคาดเดาได้ว่า goroutine จะ block หรือ leak เมื่อไหร่GMP มีความลึกจริง (local/global queue, netpoller, sysmon)
รู้ escape analysisคาดเดา allocation จากการอ่านได้ ไม่ต้องรอ benchmarkเหตุผลจริงของ compiler เปลี่ยนไปตาม release
รู้ memory layoutมองเห็น struct padding ที่เปลืองได้ทันทีสำคัญเฉพาะตอนที่ scale ใหญ่ — ปรับ layout ก่อนเวลาอันควรเสีย readability เปล่า ๆ
  • “compile ผ่านและ go vet ผ่านแล้ว แปลว่าโอเค” — go vet และ compiler เช็ก syntax กับชุด mistake ที่รู้จักตายตัว ไม่ได้เช็กว่าโปรแกรม race, leak goroutine, หรือ allocate เกินจำเป็นหรือไม่ ทั้งสามอย่าง compile ผ่านสบาย
  • “runtime internals มีไว้สำหรับคนเขียน Go compiler เท่านั้น” — คุณต้องรู้แค่พอจะ อ่าน ไม่ต้อง reimplement scheduler ทุกข้อเท็จจริงในโมดูลนี้คือสิ่งที่คุณชี้ได้ใน diff ของคนอื่น
  • “race detector บอกได้เสมอว่าโค้ด safe หรือไม่” — มันรายงานเฉพาะ race ที่เกิดขึ้นจริงระหว่างการรันที่ instrument เท่านั้น code path ที่ test ไม่เคยเดินก็ยัง race ใน production ได้ บท Memory Model ครอบคลุมว่า -race จับอะไรได้และจับอะไรไม่ได้
  • “profiling เป็นทางเดียวที่จะรู้ว่าอะไร allocate” — profiling ยืนยันผล แต่การอ่าน output ของ -gcflags='-m -m' หรือรู้จักรูปแบบสี่ห้าอย่างที่บังคับให้ escape ทำให้คาดเดาได้จาก source เลย ซึ่งนั่นคือจุดประสงค์ของโมดูลนี้

ประเด็นทั้งหมดของโมดูลนี้คือ section นี้ ซ้ำสี่ครั้ง — บทละครั้ง นี่คือเวอร์ชัน survey-level ก่อน: snippet ที่ compile ผ่าน, ผ่าน go vet, แต่ยังผิดในแบบที่ไม่มีโมดูลไหนก่อนหน้าจับได้

🐛 หา bug ในโค้ดนี้

func recordHit(counts map[string]int, key string) {
go func() {
counts[key]++ // no lock, no channel, called from many goroutines
}()
}
เฉลย

โค้ดนี้ compile ผ่าน ผ่าน go vet ไม่มีอะไรในโมดูล Basics, Types หรือ Methods ที่จับได้ แต่ counts[key]++ จาก goroutine ที่ไม่มีการ synchronize ถูกเรียกพร้อมกันมากกว่าหนึ่งที่คือ data race บน map ธรรมดาของ Go — ซึ่งไม่ปลอดภัยด้วยซ้ำแม้จะมีแค่ หนึ่ง writer กับ reader พร้อมกัน ไม่ต้องพูดถึงสอง writer สอง goroutine สามารถทำให้โครงสร้างภายในของ map เสียหายจริง และ runtime ของ Go ตรวจจับกรณีนี้ได้และ crash ด้วย fatal error: concurrent map writes แทนที่จะปล่อยให้ข้อมูลเสียหายเงียบ ๆ — แต่ต้องโชคดีพอที่จะไป trigger การเช็กของ runtime เองก่อน บท Memory Model ในโมดูลนี้อธิบายว่าทำไม “ไม่มี shared mutable state” ต้องถูกบังคับโดยคุณเอง ไม่ใช่อนุมานจากการที่โค้ด compile ผ่าน

func recordHit(mu *sync.Mutex, counts map[string]int, key string) {
mu.Lock()
defer mu.Unlock()
counts[key]++
}

💡 ตัวอย่างจากของจริง

doc comment ของ sync.Map เอง (sync/map.go ใน standard library) อธิบายไว้ชัดเจนว่าเมื่อไหร่ map ธรรมดาบวก Mutex ดีกว่า และเมื่อไหร่ไม่ดีกว่า — เป็นการตัดสินใจที่จะเข้าใจได้ก็ต่อเมื่อรู้ว่า data race คืออะไรจริง ๆ และต้องเสียอะไรเพื่อป้องกันมัน สี่บทถัดไปสร้างพื้นฐานนั้น

เป้าหมายหลักของโมดูลนี้คืออะไร?
ทำไม go vet ผ่านสะอาดไม่ได้แปลว่า function ที่ spawn goroutine นั้นปลอดภัย?
ค่าต่ำสุดที่ runtime.NumGoroutine() พิมพ์ได้ในที่นี้คือเท่าไหร่ และทำไมไม่ใช่ศูนย์?
func main() {
    fmt.Println("goroutines:", runtime.NumGoroutine())
}
ตามบทเรียนนี้ ข้อใดที่ race detector (-race) ไม่รับประกัน?