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 แค่อ่าน — แล้วตอบสามคำถามนี้ได้อย่างมั่นใจ:
- สิ่งนี้ allocate หรือไม่? (escape analysis, memory layout)
- สิ่งนี้ race ได้ไหม? (memory model, happens-before)
- สิ่งนี้ leak หรือค้างตลอดไปได้ไหม? (scheduler, goroutine lifecycle)
เนื้อหาในโมดูลนี้
หัวข้อที่มีชื่อว่า “เนื้อหาในโมดูลนี้”| บทเรียน | ตอบคำถามอะไร |
|---|---|
| Memory Model | write ในหนึ่ง goroutine จะรับประกัน visible ต่อ read ใน goroutine อื่นเมื่อไหร่? |
| Scheduler | goroutine ตัวหนึ่งขึ้นไปทำงานบน CPU core ได้อย่างไร และอะไรทำให้มันค้าง? |
| GC and Escape Analysis | ค่าตัวนี้อยู่บน stack หรือ heap และ collector ทำอะไรกับสิ่งที่ escape? |
| Memory Layout | ค่าตัวนี้กิน byte เท่าไหร่จริง ๆ และถูกจัดวางอย่างไร? |
| Cgo and FFI | memory 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.0GOARCH/GOOS: arm64 darwinNumCPU: 11GOMAXPROCS: 11goroutines before: 1goroutines after launch: 4goroutines after Wait: 1runtime.NumGoroutine() นับได้ 4 หลังจาก launch 3 ตัว (main goroutine บวกอีกสามตัวที่ parked อยู่) แล้วกลับมา 1 หลัง wg.Wait() — นี่คือวิธีตรวจ leak ที่ถูกที่สุดที่มี: snapshot จำนวนก่อนกับหลังโค้ดที่ต้องการตรวจรัน แล้ว assert ว่ามันกลับมาที่ baseline คุณจะเจอเทคนิคนี้อีกครั้งในบท Scheduler
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”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 การอ่าน 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 เลย ซึ่งนั่นคือจุดประสงค์ของโมดูลนี้
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ประเด็นทั้งหมดของโมดูลนี้คือ 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 คืออะไรจริง ๆ และต้องเสียอะไรเพื่อป้องกันมัน สี่บทถัดไปสร้างพื้นฐานนั้น