GC and Escape Analysis
จุดที่บทนี้เริ่มต่อ
หัวข้อที่มีชื่อว่า “จุดที่บทนี้เริ่มต่อ”บท Pointers แนะนำ escape analysis ในระดับที่คุณต้องรู้ว่า “pointer ไม่ได้เร็วกว่าเสมอไป” ไปแล้ว พร้อมตัวอย่างหนึ่งชิ้น (newConfig ที่ return &c) และการจับ -gcflags=-m จริงหนึ่งครั้ง บทนี้ไปต่อในสองทิศทาง: รูปแบบเฉพาะของโค้ดที่บังคับให้ escape อ่านจาก compiler output จริงทุกครั้ง และสิ่งที่เกิดขึ้นจริงกับทุกอย่างที่ escape ไป — collector ที่ต้อง track มัน
Escape analysis: รูปแบบที่บังคับให้ไป heap
หัวข้อที่มีชื่อว่า “Escape analysis: รูปแบบที่บังคับให้ไป heap”ห้า pattern ครอบคลุม escape เกือบทั้งหมดที่ reviewer ต้องจำได้ทันทีที่เห็น แต่ละอันด้านล่างคือโปรแกรมจริง แยกกัน ใช้ //go:noinline compile ด้วย go build -gcflags='-m -m' บน Go 1.26.5 — เฉพาะบรรทัดตัดสินใจ ตัด diagnostic เรื่อง inlining ออกเพื่อความอ่านง่าย (output เต็มมีบรรทัดอื่นอีกมากเกี่ยวกับ inlining budget ที่ไม่เปลี่ยนคำตัดสินเรื่อง escape)
1. Return pointer ของ local variable — บท Pointers พูดถึงแล้ว พูดซ้ำที่นี่เป็น baseline:
func newConfig() *Config { c := Config{Workers: 4} return &c}./main.go:7:2: moved to heap: c2. Boxing เข้า interface:
func asAny(n int) any { return n}./main.go:5:9: n escapes to heap3. Closure ที่ capture ตัวแปรโดย reference:
func makeCounter() func() int { count := 0 return func() int { count++ return count }}./main.go:5:2: moved to heap: count./main.go:6:9: func literal escapes to heap4. Slice ของ pointer:
func pointerSlice(n int) []*int { out := make([]*int, n) for i := 0; i < n; i++ { v := i out[i] = &v } return out}./main.go:7:3: moved to heap: v./main.go:5:13: make([]*int, n) escapes to heap5. ขนาดที่ compiler รู้ไม่ได้ตอน compile time:
func makeBuf(n int) []byte { return make([]byte, n) // size not known at compile time -- runtime decides}./main.go:5:13: make([]byte, n) escapes to heapmake ที่มีขนาดเป็นค่าคงที่ตอน compile time และเล็กพอที่จะพิสูจน์ได้ว่าไม่ escape จะอยู่บน stack ทันทีที่ขนาดเป็นตัวแปร (n, ไม่รู้จนกว่าจะ runtime) compiler จองพื้นที่ stack ที่แน่นอนล่วงหน้าให้ไม่ได้ จึงต้องไป heap ไม่ว่า slice นั้นจะอยู่เกิน function หรือไม่ก็ตาม
ทั้งห้าข้อไม่ต้องท่องจำ internal ของ compiler เลย — มันคือรูปแบบที่จับต้องได้ของ “อายุของค่านี้จบเมื่อ function return หรือเปล่า” ค่าที่ถูก return เป็น pointer, ถูกเก็บใน interface, ถูก closure จับไว้, ถูกชี้จากสิ่งที่อยู่นานกว่ามันเอง หรือมีขนาดที่กำหนดแบบ dynamic ล้วนสอบตกเทสต์นั้น
วัด allocation โดยตรง: testing.AllocsPerRun
หัวข้อที่มีชื่อว่า “วัด allocation โดยตรง: testing.AllocsPerRun”การอ่าน output ของ -gcflags คือการคาดเดา allocation; testing.AllocsPerRun คือการวัดจริง — และต่างจาก benchmark เต็มรูปแบบ มันรันได้ในโปรแกรมธรรมดา ไม่ต้องใช้ binary ของ go test เลย
package main
import ( "fmt" "testing")
type Config struct{ Workers int }
var sinkPtr *Configvar sinkVal Configvar sinkAny any
//go:noinlinefunc newConfigPtr() *Config { c := Config{Workers: 4} return &c}
//go:noinlinefunc newConfigVal() Config { return Config{Workers: 4}}
//go:noinlinefunc asAnySmall(n int) any { return n}
//go:noinlinefunc asAnyLarge(n int) any { return n}
func main() { avgPtr := testing.AllocsPerRun(1000, func() { sinkPtr = newConfigPtr() // escapes: stored in a package-level var }) avgVal := testing.AllocsPerRun(1000, func() { sinkVal = newConfigVal() // stays a value -- no pointer ever taken }) avgBoxSmall := testing.AllocsPerRun(1000, func() { sinkAny = asAnySmall(42) // small int (0-255): Go's staticuint64s table, no alloc }) avgBoxLarge := testing.AllocsPerRun(1000, func() { sinkAny = asAnyLarge(424242) // outside the small-int table: real heap alloc }) fmt.Printf("newConfigPtr (escapes, stored): %.0f allocs/op\n", avgPtr) fmt.Printf("newConfigVal (value, no escape): %.0f allocs/op\n", avgVal) fmt.Printf("asAny(42) (small int, 0-255): %.0f allocs/op\n", avgBoxSmall) fmt.Printf("asAny(424242) (large int): %.0f allocs/op\n", avgBoxLarge)} Output จริงบน Go 1.27, darwin/arm64:
newConfigPtr (escapes, stored): 1 allocs/opnewConfigVal (value, no escape): 0 allocs/opasAny(42) (small int, 0-255): 0 allocs/opasAny(424242) (large int): 1 allocs/opบรรทัดที่สามไม่ใช่ความผิดพลาด: runtime เก็บตารางค่า any ที่ box ไว้ล่วงหน้าสำหรับ integer ตัวเล็ก (0–255) — runtime.staticuint64s — ดังนั้นการ box ค่าพวกนี้ไม่เสียอะไรเลย box 424242 ที่อยู่นอกตารางนั้น allocation จริงก็กลับมา นี่คือรายละเอียด runtime จริง ไม่ใช่การลดความซับซ้อนเพื่อสอน: มันคือเหตุผลที่ “สิ่งนี้ box integer constant ตัวเล็กหรือเปล่า” ไม่ใช่สัญญาณที่เชื่อถือได้เพียงลำพังเวลาอ่านโค้ดเพื่อดู allocation — ค่า ไม่ใช่แค่รูปร่าง ก็มีผลได้
Collector: tri-color concurrent mark and sweep
หัวข้อที่มีชื่อว่า “Collector: tri-color concurrent mark and sweep”collector ของ Go ถูกบันทึกไว้ใน source ของมันเอง (runtime/mgc.go, Go 1.26.5) ว่าเป็น “a concurrent mark and sweep that uses a write barrier… non-generational and non-compacting.” concurrent หมายความว่างานส่วนใหญ่ของมันเกิดขึ้นในขณะที่ goroutine ของคุณยังรันต่อไป ไม่ใช่ pause ยาวครั้งเดียว — เป็นการแลกเปลี่ยนที่ตั้งใจ ต่างจากภาษาที่ collector หยุดโปรแกรมทั้งหมดเพื่อเก็บกวาด
algorithm จัดทุก object เข้าหนึ่งในสามสี ตรงตามที่ runtime source อธิบาย:
- White — ยังไม่ถูกพบว่า reachable ใน cycle นี้ (ผู้สมัครสำหรับการเก็บกวาด)
- Grey — พบแล้วว่า reachable แต่ pointer ข้างในยังไม่ถูก scan
- Black — พบแล้วว่า reachable และ pointer ข้างในทุกตัวถูก scan แล้ว
GC cycle เริ่มด้วยทุกอย่างเป็น white ย้าย root (global, goroutine stack) เป็น grey แล้วเลือก grey object ซ้ำ ๆ scan มัน (ทำให้มันเป็น black และทำให้ทุกอย่างที่มันชี้ไปเป็น grey) จนไม่มี grey object เหลือ อะไรก็ตามที่ยังเป็น white ณ จุดนั้นคือ unreachable จริง ๆ และถูก sweep
flowchart LR
subgraph Start["Cycle start"]
W1["white: everything"]
end
subgraph Marking["Marking (concurrent with your goroutines)"]
G["grey: reachable, not yet scanned"]
B["black: reachable, fully scanned"]
G -->|scan, shade pointers found| B
G -->|newly-found pointers| G
end
subgraph End["Cycle end"]
W2["still white = unreachable -> swept"]
end
W1 --> G
B --> End Write barrier ทำให้การ mark ปลอดภัยขณะโปรแกรมยังรันอยู่
หัวข้อที่มีชื่อว่า “Write barrier ทำให้การ mark ปลอดภัยขณะโปรแกรมยังรันอยู่”การ mark แบบ concurrent ไปพร้อมกับโปรแกรมที่รันอยู่สร้างอันตรายจริงหนึ่งอย่าง: goroutine ของคุณอาจเขียน pointer จาก object สีดำ (scan แล้ว) ไปยัง object สีขาว (ยังไม่พบ) แล้วเขียนทับหรือทำให้ reference อีกตัวเดียวที่เหลือของ object สีขาวนั้นหายไป — collector จะไม่กลับมาดู object สีดำนั้นอีกเพื่อหามัน และจะ sweep สิ่งที่ยัง reachable อยู่ทิ้งไป write barrier — โค้ดเสริมที่ compiler แทรกรอบทุกการเขียน pointer ขณะ GC cycle active — คือสิ่งที่ป้องกันเรื่องนี้ ตาม comment ใน source: “The write barrier shades both the overwritten pointer and the new pointer value for any pointer writes” — ทำให้ทั้ง target เก่าและใหม่กลายเป็น grey ทันที ไม่มีอะไรที่การเขียน pointer ที่ยัง live อยู่แตะต้องจะถูกพลาดได้ write barrier active เฉพาะตอน marking phase เท่านั้น (_GCmark/_GCmarktermination ใน phase enum ของ runtime) — นอก GC cycle การเขียน pointer ธรรมดาไม่เสียอะไรเพิ่มเลย
สอง stop-the-world pause
หัวข้อที่มีชื่อว่า “สอง stop-the-world pause”ตาม go doc runtime (ส่วน gctrace ของ runtime/extern.go): “The phases are stop-the-world (STW) sweep termination, concurrent mark and scan, and STW mark termination.” มีแค่ตัวแรกและตัวที่สามในสามเฟสนี้ที่หยุดทุก goroutine จริง ๆ เฟสกลาง — เกือบทั้งหมดของเวลา wall-clock ใน GC cycle — รันไปพร้อมกับโปรแกรมของคุณ นี่คือความเข้าใจผิดที่พบบ่อยที่สุดเรื่อง “GC pause”: ตัวเลขหลายมิลลิวินาทีที่เห็นใน trace ส่วนใหญ่ไม่ใช่ช่วง pause เลย
อ่าน GC pause จริงด้วย GODEBUG=gctrace=1
หัวข้อที่มีชื่อว่า “อ่าน GC pause จริงด้วย GODEBUG=gctrace=1”โปรแกรมสร้าง linked list เล็ก ๆ, GODEBUG=gctrace=1, Go 1.27, darwin/arm64:
gc 1 @0.013s 3%: 0.055+0.41+0.39 ms clock, 0.61+0.36/0.74/0+4.3 ms cpu, 3->4->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 11 Pอ่านเทียบกับ field documentation ของ go doc runtime โดยตรง:
| ส่วน | ความหมายในที่นี้ |
|---|---|
gc 1 | GC cycle แรกที่ process นี้เคยรัน |
@0.013s | 13ms หลังโปรแกรมเริ่ม |
3% | 3% ของ CPU time ทั้งหมดตั้งแต่เริ่มไปกับ GC แล้ว |
0.055+0.41+0.39 ms clock | สามเฟส แบบ wall-clock: 0.055ms STW sweep termination, 0.41ms concurrent mark (goroutine ของคุณยังรันต่อ), 0.39ms STW mark termination |
0.61+0.36/0.74/0+4.3 ms cpu | CPU time (ไม่ใช่ wall-clock) สำหรับเฟสเดียวกัน โดยตัวเลข concurrent-mark แยกย่อยเป็น assist/background/idle GC work |
3->4->1 MB | ขนาด heap ตอนเริ่ม cycle, ตอนจบ cycle, และ live heap ที่พบว่า reachable จริง |
4 MB goal | ขนาด heap ที่จะ trigger cycle ถัดไป |
0 MB stacks, 0 MB globals, 11 P | ขนาด stack ที่ scan ได้, ขนาด global ที่ scan ได้ และ GOMAXPROCS ณ ตอนนั้น |
“pause” ที่ request ซึ่งถูกจัดการระหว่าง cycle นี้จะรู้สึกได้จริง คือสองตัวเลข STW บวกกัน — ประมาณ 0.45ms ในที่นี้ — ไม่ใช่ผลรวมทั้งหมด 0.86ms และไม่ใช่ timestamp 13ms แน่นอน goroutine ที่รันตลอดเฟส concurrent-mark จะช้าลงจาก write-barrier overhead และอาจมีงาน GC assist แต่ไม่เคยถูกหยุดโดยมันเลย
GOGC และ GOMEMLIMIT
หัวข้อที่มีชื่อว่า “GOGC และ GOMEMLIMIT”GOGC ตั้งค่าอัตราส่วนการโตของ heap เป้าหมาย: ค่า default, 100, หมายความว่า “trigger a collection when the heap has grown to roughly double the size of the live heap after the last collection.” การเพิ่มค่านี้แลก memory กับการเก็บกวาดที่น้อยครั้งลงและห่างขึ้น; GOGC=off ปิด ratio trigger ไปเลย (การเก็บกวาดยังเกิดอยู่ แค่น้อยลงมาก — forced periodic GC หรือการเรียก runtime.GC() ตรง ๆ ยังรันอยู่) จำนวน cycle จริงจากการสร้าง linked list 200,000 node เดียวกัน, GODEBUG=gctrace=1, นับบรรทัดที่ขึ้นต้นด้วย gc :
GOGC (default, 100): 15 GC cyclesGOGC=400: 6 GC cyclesGOGC=off: 3 GC cyclesGOMEMLIMIT เป็นปุ่มคนละแบบเลย: soft memory ceiling เป็น byte เป็นอิสระจาก ratio ของ GOGC ตาม doc ของ runtime/debug.SetMemoryLimit: “This limit will be respected even if GOGC=off.” ตรวจสอบโดยตรง — เพิ่ม limit 20 MiB บน GOGC=off:
GOMEMLIMIT=20MiB GOGC=off: 36 GC cyclesGOGC=off เพียวลด cycle count เหลือ 3; การเพิ่ม memory limit บน GOGC=off เดียวกันนั้นดันกลับขึ้นไปที่ 36 — collector รันบ่อยขึ้นมากโดยเฉพาะเพื่อรักษาไว้ใต้ byte ceiling ตรงตามที่ document ไว้ทุกประการ ไม่สนใจเลยว่า ratio trigger ถูกปิดในนาม GOMEMLIMIT คือเครื่องมือที่ถูกต้องสำหรับ “อย่าให้ process นี้เกิน memory limit ของ container เด็ดขาด”; GOGC คือเครื่องมือที่ถูกต้องสำหรับ “แลก memory กับ CPU” เมื่อไม่มี ceiling ตายตัวต้องรักษาไว้
sync.Pool: reuse โดยไม่ตกกับดัก boxing
หัวข้อที่มีชื่อว่า “sync.Pool: reuse โดยไม่ตกกับดัก boxing”sync.Pool เก็บชุด object ชั่วคราวไว้ reuse แทนที่จะปล่อยให้ถูก garbage collect แล้ว allocate ใหม่ — มีประโยชน์จริงสำหรับอะไรก็ตามที่ allocate แพงและถูกใช้สั้น ๆ ซ้ำ ๆ (scratch buffer ต่อ request ในรูปแบบที่ net/http เองใช้ภายใน) แต่ sync.Pool.Get/Put ทำงานบน any และการเก็บค่าลง interface จะ allocate เว้นแต่ค่านั้นพอดีกับ data word เดียวของ interface (pointer พอดี; type แบบ multi-word อย่าง []byte header ธรรมดาไม่พอดี) วัดด้วย testing.AllocsPerRun, การ pool รูปร่างผิดอาจเสียมากกว่าไม่ pool เลย:
pooling a raw []byte value (boxed into `any` on every Put): 1 allocs/opnot pooling at all (bytes.Buffer stays a local value): 1 allocs/oppooling *bytes.Buffer (a pointer, fits in the interface): 0 allocs/oppackage main
import ( "bytes" "fmt" "sync" "testing")
var bufPool = sync.Pool{ New: func() any { return new(bytes.Buffer) // pointer type: fits directly in the interface word },}
//go:noinlinefunc withoutPool() { var buf bytes.Buffer buf.WriteString("hello") _ = buf.String()}
//go:noinlinefunc withPool() { buf := bufPool.Get().(*bytes.Buffer) buf.Reset() buf.WriteString("hello") _ = buf.String() bufPool.Put(buf)}
func main() { avgWithout := testing.AllocsPerRun(1000, withoutPool) avgWith := testing.AllocsPerRun(1000, withPool) fmt.Printf("without sync.Pool (bytes.Buffer value): %.0f allocs/op\n", avgWithout) fmt.Printf("with sync.Pool (*bytes.Buffer): %.0f allocs/op\n", avgWith)} Output จริงบน Go 1.27, darwin/arm64:
without sync.Pool (bytes.Buffer value): 1 allocs/opwith sync.Pool (*bytes.Buffer): 0 allocs/opรูปแบบที่ใช้ได้จริง: pool pointer ไปยังสิ่งที่มี internal state จริงให้ reuse (*bytes.Buffer, *MyScratchStruct), reset มันหลัง Get และอย่าสมมติว่า Get คืนค่าที่ zero เสมอ — ประเด็นทั้งหมดคือมันอาจส่งสิ่งที่ caller ก่อนหน้าใช้แล้วกลับมาให้ sync.Pool ยังมีจุดแปลกเรื่อง lifetime ที่ควรรู้เวลาอ่านโค้ดที่พึ่งมัน: garbage collector มีสิทธิ์ล้าง pool ทั้งหมดใน GC cycle ไหนก็ได้ ดังนั้นมันเป็นแค่ cache แบบอาจ-reuse ไม่ใช่ guaranteed-reuse เลย และโค้ดต้องทำงานถูกต้อง (แค่ช้าลง) เมื่อ cache miss ด้วย
-benchmem: แปลงการคาดเดา escape ให้เป็นตัวเลข
หัวข้อที่มีชื่อว่า “-benchmem: แปลงการคาดเดา escape ให้เป็นตัวเลข”go test -bench=. -benchmem รายงาน B/op และ allocs/op ต่อ iteration — เป็นน้องของ testing.AllocsPerRun ในรูปแบบ benchmark พร้อม timing จริงควบคู่ไปด้วย นี่ต้องใช้ binary ของ go test จึงเป็น fenced snippet ในที่นี้ ไม่ใช่ Playground — รันเองในเครื่องด้วย go test -bench=. -benchmem output จริงสำหรับคู่ pointer-vs-value จากก่อนหน้าในบทนี้:
goos: darwingoarch: arm64pkg: benchcpu: Apple M3 ProBenchmarkNewConfigPtr-11 186178467 6.137 ns/op 8 B/op 1 allocs/opBenchmarkNewConfigVal-11 692306694 1.734 ns/op 0 B/op 0 allocs/opส่วนต่อท้าย -11 คือ GOMAXPROCS ตอน benchmark ไม่ใช่ส่วนหนึ่งของชื่อ เวอร์ชัน value ไม่ได้แค่ปลอด allocation เท่านั้น มันยังรันเร็วกว่า 3.5 เท่าบนเครื่องนี้ เพราะการ escape ไป heap ยังหมายความว่า object นั้นเป็นสิ่งที่ collector ต้อง track ในทุก cycle ถัดไปจนกว่ามันจะ unreachable ไม่ใช่แค่ต้นทุนการ allocate ครั้งเดียว การอ่าน output ของ -m -m คาดเดาคอลัมน์ allocs/op ได้ แต่ไม่เคยคาดเดาคอลัมน์ ns/op ได้เพียงลำพัง เพราะมันขึ้นกับพฤติกรรม cache, GC load และทุกอย่างอื่นที่รันอยู่ตอนนั้นด้วย
runtime/metrics: อ่านตัวเลขของ collector เองจากในโปรแกรม
หัวข้อที่มีชื่อว่า “runtime/metrics: อ่านตัวเลขของ collector เองจากในโปรแกรม”runtime/pprof และ GODEBUG=gctrace เป็นการสังเกตจากภายนอกทั้งคู่; runtime/metrics คือวิธีที่โปรแกรมที่กำลังรันอ่านข้อมูลแบบเดียวกันเกี่ยวกับตัวเอง — เป็นตัวสืบทอดแบบ stable, typed ของ runtime.ReadMemStats ตัวเก่าที่ขยายยากกว่า
package main
import ( "fmt" "runtime/metrics")
func main() { samples := []metrics.Sample{ {Name: "/gc/heap/allocs:bytes"}, {Name: "/gc/heap/objects:objects"}, {Name: "/sched/goroutines:goroutines"}, {Name: "/memory/classes/heap/free:bytes"}, } metrics.Read(samples) for _, s := range samples { switch s.Value.Kind() { case metrics.KindUint64: fmt.Printf("%-30s %d\n", s.Name, s.Value.Uint64()) case metrics.KindFloat64: fmt.Printf("%-30s %f\n", s.Name, s.Value.Float64()) default: fmt.Printf("%-30s (unsupported kind)\n", s.Name) } }} Output จริง (ตัวเลข byte และ object ต่างกันแต่ละครั้งที่รัน — นี่คือสิ่งที่การรันจริงหนึ่งครั้งบน Go 1.27 ให้มา):
/gc/heap/allocs:bytes 122752/gc/heap/objects:objects 15/sched/goroutines:goroutines 6/memory/classes/heap/free:bytes 0metrics.All() แสดงรายการ metric ทั้งหมดที่ Go version ที่กำลังรันรองรับ แต่ละตัวมีชื่อและคำอธิบายที่ stable — กลไกเดียวกับที่ library monitoring อย่าง expvar หรือ Prometheus exporter ใช้ภายใน แทนที่จะขุด field ของ runtime.MemStats ที่ความหมายและการมีอยู่เปลี่ยนไปตาม Go release
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”flowchart TD
subgraph EA["Escape analysis (compile time)"]
Q["Does this value's lifetime end when the function returns?"]
Q -->|"yes"| Stack["stack (free)"]
Q -->|"no"| Heap["heap (GC-tracked from here on)"]
end
subgraph GC["The collector (runtime, ongoing)"]
White["white"] --> Grey["grey"] --> Black["black"]
WB["write barrier keeps this safe while goroutines run"]
STW1["STW sweep-term (short)"] --> CM["concurrent mark (most of the time)"] --> STW2["STW mark-term (short)"]
end ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| Concurrent mark and sweep | goroutine ยังรันต่อได้ตลอดเกือบทั้ง GC cycle | write barrier overhead ทุกครั้งที่เขียน pointer ขณะ marking |
| Escape analysis ที่เก็บค่าไว้บน stack | allocation ต้นทุนศูนย์ และไม่ต้อง GC track เลย | การพิสูจน์ของ compiler ระมัดระวังเกินไป — บางกรณีที่ปลอดภัยจริงยัง escape อยู่ |
เพิ่ม GOGC | เก็บกวาดน้อยครั้งลงและห่างขึ้น | memory สูงสุดสูงขึ้น (heap ใหญ่ขึ้นก่อนแต่ละ trigger) |
GOMEMLIMIT | ceiling ตายตัวไม่ว่า GOGC จะเป็นอะไร | การเก็บกวาดกลายเป็นการตอบสนองต่อแรงกดดัน memory ซึ่งอาจหมายถึง GC ที่บ่อยขึ้น และทำงานหนักขึ้นใกล้ limit |
sync.Pool (pointer type) | allocation น้อยลงสำหรับ object ที่แพงและถูก reuse | เนื้อหาใน pool หายได้ทุกเมื่อที่มี GC; เป็นแค่ may-reuse cache เสมอ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ตัวเลขในบรรทัด gctrace คือเวลาที่โปรแกรม pause” มีแค่ตัวแรกและตัวที่สามในสามตัวเลข clock ที่เป็น STW; ตัวกลาง ซึ่งเป็นเกือบทั้งหมดของเวลา wall-clock รันไปพร้อมกับ goroutine ของคุณ
- “
GOGC=offแปลว่าไม่มี GC อีกเลย” มันปิดแค่ ratio trigger เรื่องการโตของ heap โดยเฉพาะ; forced collection เป็นระยะและการเรียกruntime.GC()ยังรันอยู่ และGOMEMLIMITก็ยังบังคับให้เก็บกวาดถ้าตั้งไว้ ไม่ว่าGOGCจะเป็นอะไร - “Boxing integer ตัวเล็กเข้า
anyallocate เสมอ” ค่า 0–255 ถูกเสิร์ฟจากตารางคงที่ (runtime.staticuint64s) และไม่เสียอะไรเลย allocation กลับมาทันทีที่ค่านั้นอยู่นอกช่วงนั้น - “
sync.Poolลด allocation เสมอ” การ pool value type ที่ไม่พอดีกับ interface word เดียว (raw slice, plain struct ที่ใหญ่กว่า pointer) อาจเสีย allocation ทุกครั้งที่Putจาก interface boxing — pool pointer แทน - “output ของ escape analysis คือหลักฐานสมบูรณ์ว่าค่าไม่ escape ตลอดไป” มันคือการตัดสินใจจริงของ compiler ที่เปลี่ยนไปตาม Go release และการตัดสินใจเรื่อง inlining ถือ output ของ
-gcflags=-mเป็นความจริงของการ build ครั้งนี้ ไม่ใช่คุณสมบัติถาวรของ source text
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”โค้ด Go ที่ AI เขียน return pointer และ box ค่าเข้า any แบบ defensive มากกว่าที่โค้ดต้องการจริงบ่อยมาก ด้วยทฤษฎีว่าทั้งสองอย่าง “ปลอดภัยกว่า” ตรวจสิ่งเหล่านี้:
- function return
&SomeStruct{...}เมื่อ caller อ่าน field แค่ครั้งเดียวและไม่เคยต้องแก้ original หรือ share มันหรือเปล่า? การ return value ไม่ใช่ pointer จะให้ compiler เก็บมันนอก heap ได้ - อะไรบางอย่างถูกเก็บใน
map[string]any, parameterinterface{}, หรือส่งผ่านfmt.Sprintf("%v", x)ทั้งที่ทางเลือกแบบ concrete type ทำงานเดียวกันได้โดยไม่ต้อง box หรือเปล่า? sync.Poolเก็บ raw slice, array หรือ plain struct value แทนที่จะเป็น pointer ไปยังมัน — เสีย boxing allocation เงียบ ๆ ทุกครั้งที่Putหรือเปล่า?- comment เรื่อง benchmark หรือ performance (“this avoids allocation”) ขาดตัวเลขจริงจาก
-benchmemหรือAllocsPerRunรองรับหรือเปล่า? GOGCหรือGOMEMLIMITถูกปรับใน comment หรือ config file พร้อมผลที่อ้างไว้ (“this eliminates GC pauses”) ที่สับสนระหว่างการปิด ratio trigger กับการปิดการเก็บกวาดไปเลยหรือเปล่า?
🐛 หา bug ในโค้ดนี้
type Stats struct { Requests int Errors int}
func recordStats(s Stats) map[string]any { return map[string]any{ "requests": s.Requests, "errors": s.Errors, }}
func logAll(entries []Stats) { for _, e := range entries { m := recordStats(e) fmt.Println(m) }}เฉลย
recordStats box field int สองตัวเข้า any ข้างใน map[string]any ที่ allocate ใหม่ทุกครั้งที่เรียก สำหรับข้อมูลที่ถูก print เท่านั้น map[string]any ทุกอันจอง bucket ภายในของ map เอง และค่าที่ไม่ใช่ small-int ทุกตัวที่ assign เข้าไป (ในที่นี้ s.Requests กับ s.Errors จะไม่เสีย box ก็ต่อเมื่อบังเอิญตกอยู่ในตาราง static 0–255 — request counter จะไม่อยู่ในนั้นนานหรอก) box เข้า heap allocation ใหม่ ไม่มี indirection ไหนในนี้ที่คุ้มค่าเลย: fmt.Println print struct แบบ concrete type ได้ดีพอ ๆ กันโดยไม่มี allocation เหล่านี้เลย เพราะ Stats เองไม่จำเป็นต้องออกจาก stack เลย
func logAll(entries []Stats) { for _, e := range entries { fmt.Printf("requests=%d errors=%d\n", e.Requests, e.Errors) }}ถ้ามีความต้องการจริงสำหรับค่ารูปร่าง dynamic (เช่น serialize เป็น JSON ที่มี field แบบ optional) ให้ใช้ map[string]any ตรงนั้น — pattern นี้ไม่ได้ผิดโดยทั่วไป ผิดเฉพาะที่นี่ ที่ struct แบบ concrete มีทุกอย่างที่ caller ต้องการอยู่แล้ว
💡 ตัวอย่างจากของจริง
server ของ
net/http(net/http/server.go, standard library) เก็บsync.Poolของค่า*bufio.Readerและ*bufio.Writer(newBufioReader/putBufioReaderและตัวเทียบเท่าฝั่ง writer) ด้วยเหตุผลเดียวกับที่บทนี้อธิบาย: connection ถูกเปิดและปิดตลอดเวลา และการสร้าง buffered reader/writer ใหม่ต่อ connection จะเป็นต้นทุน allocation จริงที่คูณไปตามทุก request — การ pool pointer type หลีกเลี่ยงต้นทุน boxing ที่ตัวอย่างsync.Poolของบทนี้แสดงไว้ และ semantic แบบ “GC ล้างได้ทุกเมื่อ” ก็โอเคเพราะ cache miss แค่แปลว่า allocate ใหม่หนึ่งตัว เหมือนกับไม่ pool เลย