Goroutines
Goroutine คืออะไร?
หัวข้อที่มีชื่อว่า “Goroutine คืออะไร?”Goroutine คือ function ที่ทำงานพร้อมกันกับ goroutines อื่น ๆ ในพื้นที่ address เดียวกัน คุณสร้างได้ด้วย keyword เดียว:
go f(args)นั่นคือ syntax ทั้งหมด scheduler จัดคิว f ให้รันแบบ concurrent แล้ว goroutine ที่เรียกก็ดำเนินต่อทันทีโดยไม่รอให้ f return
ทำไม goroutine ถึงต้นทุนต่ำ
หัวข้อที่มีชื่อว่า “ทำไม goroutine ถึงต้นทุนต่ำ”OS thread โดยทั่วไปเริ่มต้นด้วย stack ขนาดคงที่ 1–8 MB แต่ goroutine เริ่มต้นด้วย stack ขนาดประมาณ 2–8 KB ที่ขยายและหดตัวได้อัตโนมัติตามต้องการ Go runtime จัดสรร goroutines ลง OS threads โดยใช้ M:N scheduler (M goroutines บน N threads) Context switch ระหว่าง goroutines เกิดขึ้นใน user-space และถูกกว่า kernel thread switch หลายเท่า
ในระบบ production การมี goroutines หลายหมื่นตัวรันพร้อมกันเป็นเรื่องปกติ
Go Scheduler: M, P, G
หัวข้อที่มีชื่อว่า “Go Scheduler: M, P, G”Scheduler ทำงานบนสามแนวคิด:
| สัญลักษณ์ | ความหมาย |
|---|---|
| G | Goroutine — หน่วยของงาน concurrent |
| M | Machine — OS thread |
| P | Processor — scheduling context (มี GOMAXPROCS ตัว) |
แต่ละ P มี run queue ของ Gs M ต้องถือ P ถึงจะรัน G ได้ เมื่อ goroutine block บน network I/O, netpoller จะ park goroutine นั้นไว้และ M ยังถือ P ต่อไป แต่เมื่อ goroutine block ใน system call (file I/O, cgo), M จะส่งต่อ P ให้ thread อื่นเพื่อให้ goroutine อื่นทำงานต่อได้
Work Stealing และ Async Preemption
หัวข้อที่มีชื่อว่า “Work Stealing และ Async Preemption”เมื่อ run queue ของ P ตัวหนึ่งว่างเปล่า มันจะไม่นั่งรอเฉย ๆ — มันจะ steal ครึ่งหนึ่งของ Gs จาก run queue ของ P ตัวอื่น วิธีนี้ทำให้ทุก core ยุ่งอยู่เสมอแม้งานจะกระจายไม่เท่ากันระหว่าง goroutine โดยไม่ต้องมีการประสานงานจากโค้ดของคุณเลย
Work stealing จะช่วยได้ก็ต่อเมื่อ G เปิดโอกาสให้ scheduler ย้ายมันได้บ้าง ก่อน Go 1.14 goroutine จะ yield แค่ที่จุดเฉพาะเจาะจงเท่านั้น — function call, channel operation, allocation — ดังนั้น loop แน่นที่ไม่มีจุดเหล่านั้นเลย (for { x++ }) สามารถยึด P ของมันไว้ตลอดไป ทำให้ goroutine อื่นที่ถูก assign ไปที่ P เดียวกันอดตาย Go 1.14 เพิ่ม asynchronous preemption: runtime ส่ง signal (SIGURG บน Unix) เป็นระยะไปยัง thread ที่รัน goroutine ที่ถือ P ของมันนานเกินไป (ประมาณ 10ms) และ signal handler จะบังคับให้ goroutine นั้น yield ที่ safe point แม้จะอยู่กลาง loop ก็ตาม นี่คือเหตุผลที่ CPU-bound loop ที่ไม่มี function call เลยไม่สามารถล็อกส่วนที่เหลือของโปรแกรมได้อีกต่อไป เหมือนที่เคยเกิดขึ้นได้บน Go 1.13 และเก่ากว่า
GOMAXPROCS vs NumCPU
หัวข้อที่มีชื่อว่า “GOMAXPROCS vs NumCPU”สองตัวนี้สับสนกันง่าย แต่ตอบคำถามคนละอย่าง:
runtime.NumCPU() | runtime.GOMAXPROCS(0) | |
|---|---|---|
| ตอบคำถาม | OS รายงาน logical CPU กี่ตัวสำหรับ process นี้? | มี P กี่ตัว — goroutine รัน Go code พร้อมกันได้กี่ตัวในเวลาเดียวกัน? |
| เปลี่ยนตอน runtime ได้ไหม? | ไม่ได้ — อ่านครั้งเดียวตอน process เริ่ม | ได้ — GOMAXPROCS(n) ที่ n >= 1 เปลี่ยนค่าทันที |
| ความสัมพันธ์ default | — | ในอดีตเท่ากับ NumCPU(); ตั้งแต่ Go 1.25 ถูกจำกัดด้วย cgroup CPU quota ใน container ด้วย และตรวจซ้ำเป็นระยะ |
| ส่ง 0 เข้าไป | ไม่เกี่ยวข้อง | return ค่าปัจจุบันโดยไม่เปลี่ยนแปลง — วิธี idiomatic ในการอ่านค่าเฉย ๆ |
runtime.GOMAXPROCS จะไม่ตั้งค่าต่ำกว่า 2 เองเด็ดขาด (เว้นแต่เครื่องมี CPU ใช้งานได้น้อยกว่า 2 จริง ๆ) แม้อยู่ภายใต้ cgroup quota ที่จำกัดมาก — รายละเอียดที่ควรรู้ถ้า CPU limit ของ container เป็นเศษส่วนอย่าง 0.5
สำหรับดู internal state ของ scheduler แบบสด ๆ ไม่ใช่แค่จำนวน P ให้รันโปรแกรมไหนก็ได้ด้วย GODEBUG=schedtrace=1000 เพื่อพิมพ์ snapshot ของ scheduler ทุก 1000ms ไปที่ stderr:
SCHED 0ms: gomaxprocs=11 idleprocs=8 threads=5 spinningthreads=1 needspinning=0 idlethreads=0 runqueue=0 [ 1 0 0 0 0 0 0 0 0 0 0 ] schedticks=[ 0 0 0 0 0 0 0 0 0 0 2 ](gomaxprocs ตรงนี้เป็น 11 เพราะ trace นี้จับมาจากเครื่อง 11 core — ตัวเลขจริงขึ้นอยู่กับเครื่องที่รันเสมอ) runqueue คือความยาวของ global run queue ส่วน list ในวงเล็บคือความยาว local run queue ของแต่ละ P ใช้เครื่องมือนี้เมื่อ goroutine ดูเหมือนค้างและต้องดูว่า scheduler ขาด P หรือแค่โค้ดมี logic bug
รอด้วย sync.WaitGroup
หัวข้อที่มีชื่อว่า “รอด้วย sync.WaitGroup”sync.WaitGroup เป็นวิธีมาตรฐานในการรอ goroutines จำนวนคงที่ให้เสร็จ:
wg.Add(n)— เพิ่ม counter ขึ้น n (เสมอก่อนgostatement)wg.Done()— ลด counter ลง 1 (เรียกด้วยdeferใน goroutine)wg.Wait()— บล็อกจนกว่า counter จะถึงศูนย์
wg.Go — Go 1.25+
หัวข้อที่มีชื่อว่า “wg.Go — Go 1.25+”ตั้งแต่ Go 1.25 WaitGroup มี method Go ที่รวม Add(1), go statement และ defer Done() ไว้ในการเรียกครั้งเดียว:
var wg sync.WaitGroupfor i := 1; i <= 3; i++ { wg.Go(func() { work(i) })}wg.Wait()wg.Go ตัดความผิดพลาดคลาสสิกสองอย่างออกไป — ลืม Add ก่อน go statement หรือลืม Done — และ go vet (analyzer waitgroup, Go 1.25+) จะเตือนเมื่อวาง Add ผิดที่ รูปแบบ Add/Done ด้านล่างยังใช้ได้และเป็นสิ่งที่คุณจะเจอในโค้ดเก่า runner ในเบราว์เซอร์เกิดก่อน wg.Go playground จึงยังใช้รูปแบบคลาสสิก
package main
import ( "fmt" "sync")
func worker(id int, results []string, wg *sync.WaitGroup) { defer wg.Done() sum := 0 for j := 1; j <= 5; j++ { sum += j } results[id-1] = fmt.Sprintf("worker %d: sum 1..5 = %d", id, sum)}
func main() { var wg sync.WaitGroup results := make([]string, 3)
for i := 1; i <= 3; i++ { wg.Add(1) go worker(i, results, &wg) }
wg.Wait()
for _, r := range results { fmt.Println(r) } fmt.Println("all workers done")} Goroutine Leak
หัวข้อที่มีชื่อว่า “Goroutine Leak”Goroutine leak คือ goroutine ที่ยังมีชีวิตอยู่แต่ไม่มีทางไปต่อได้และไม่มีทาง exit เลย — มันหยุดค้างอยู่ตลอดไป ถือ stack ของมันและทุกอย่างที่มัน closure ไว้ ต่างจาก memory leak จาก allocation ที่ไม่มีการอ้างอิงถึง garbage collector ไม่สามารถปล่อยมันได้ เพราะ runtime เองยังถือ reference ที่ยังมีชีวิตอยู่ไปยังมัน สามสาเหตุนี้ครอบคลุม leak เกือบทั้งหมดในโค้ดจริง:
- Block บน channel ที่ไม่มีคู่. goroutine receive จาก (หรือ send เข้า) channel ที่ไม่มีใคร send เข้า (หรือ receive จาก) มันเลย — สาเหตุที่พบบ่อยที่สุด
- Block บน lock ที่ไม่เคยถูกปล่อย. goroutine เรียก
mu.Lock()และ goroutine ที่ควรเรียกUnlock()panic, return ก่อนเวลา, หรือตัวมันเองก็รั่วไปแล้ว - Loop ที่ไม่มีเงื่อนไขออก.
forloop ที่ตั้งใจให้รัน “จนกว่าจะถูกยกเลิก” แต่ไม่เคยเช็กctx.Done()หรือ stop channel เลยสักรอบ
โปรแกรมที่รันได้ด้านล่างคือสาเหตุ #1: ห้า goroutine แต่ละตัว receive จาก unbuffered channel ที่ไม่มีใคร send เข้าเลย
func leaky() { ch := make(chan int) // unbuffered, nobody ever sends go func() { <-ch // blocks forever: nothing ever sends on ch or closes it }()}
func main() { before := runtime.NumGoroutine() for i := 0; i < 5; i++ { leaky() } time.Sleep(50 * time.Millisecond) // let the leaked goroutines actually start after := runtime.NumGoroutine() fmt.Println("leaked goroutines:", after-before)}leaked goroutines: 5runtime.NumGoroutine() คือเครื่องมือตรวจ leak ที่ถูกที่สุด — table-driven test ที่ snapshot จำนวนก่อนและหลังเรียกโค้ดที่ทดสอบ แล้ว assert ว่ามันกลับมาที่ baseline จับ leak ส่วนใหญ่ได้ใน CI ก่อนที่จะไปถึง production ใน production, goroutine profile ของ runtime/pprof (หรือตั้งแต่ Go 1.27 คือ profile เฉพาะทาง goroutineleak ที่พูดถึงด้านล่าง) จะแสดงตำแหน่งที่ goroutine ที่ยังมีชีวิตอยู่ทุกตัว block อยู่พอดี
Panic ใน Goroutine เดียวฆ่าทั้ง Process
หัวข้อที่มีชื่อว่า “Panic ใน Goroutine เดียวฆ่าทั้ง Process”recover() จับ panic ได้เฉพาะใน goroutine เดียวกัน ที่มันถูก defer ไว้เท่านั้น panic ใน goroutine ที่คุณเปิดด้วย go f() ไม่มีทางถูก recover โดย defer/recover() ใน main หรือ goroutine อื่นเลย — ถ้า goroutine นั้นไม่ recover panic ของตัวเอง ทั้ง process จะจบทันที พา goroutine อื่นทั้งหมดรวมถึง main ไปด้วย ไม่ว่าจะเหลืองานค้างอยู่แค่ไหน:
func main() { defer func() { if r := recover(); r != nil { fmt.Println("recovered:", r) // never reached } }() go func() { panic("boom") }() time.Sleep(100 * time.Millisecond) fmt.Println("main finished normally") // never reached}panic: boom
goroutine 7 [running]:main.main.func2() /tmp/goroutine-panic/main.go:15 +0x2ccreated by main.main in goroutine 1 /tmp/goroutine-panic/main.go:14 +0x40exit status 2defer/recover() ใน main ไม่เคยรันเลย — runtime ไม่พยายามด้วยซ้ำ เพราะ recover ใช้ได้เฉพาะกับ goroutine ที่กำลัง unwind อยู่ตอนนั้นเท่านั้น goroutine ไหนก็ตามที่ panic ได้ต้อง recover panic ของตัวเองถ้าอยากให้โปรแกรมรอดชีวิต รูปแบบที่ใช้กันบ่อยคือ defer func() { recover() }() เล็ก ๆ ที่ต้นของทุก worker goroutine ที่มีอายุยืน โดย log ค่าที่ recover ได้แทนที่จะทิ้งไปเฉย ๆ
Unbounded Spawning
หัวข้อที่มีชื่อว่า “Unbounded Spawning”go handle(req) ครั้งเดียวต่อ request ที่เข้ามาคือโมเดล concurrency ที่ง่ายที่สุดเท่าที่จะเป็นไปได้ และมันทำงานได้ดี — จนกว่าปริมาณ request จะพุ่งขึ้นและจำนวน goroutine ที่กำลังทำงานอยู่จะโตขึ้นโดยไม่มีเพดานเลย แต่ละตัวถือ stack, สิ่งที่มัน capture ไว้ และบ่อยครั้งคือ database connection หรือ file handle มากพอก็ทำให้ memory หรือ connection pool หมดก่อนที่ CPU จะกลายเป็นคอขวดด้วยซ้ำ วิธีแก้เสมอคือต้องมีเพดานว่ารันพร้อมกันได้กี่ตัว — worker pool ขนาดคงที่, buffered channel ที่ใช้เป็น semaphore (ดูบท Channels), หรือ errgroup.SetLimit (อยู่ในบท Sync Primitives) — แทนที่จะเชื่อว่า input จะเล็กตลอดไป
Runtime ในปี 2026
หัวข้อที่มีชื่อว่า “Runtime ในปี 2026”การเปลี่ยนแปลงของ runtime สามอย่างจาก Go 1.25–1.27 ที่มีผลกับโค้ด goroutine ในชีวิตประจำวัน:
GOMAXPROCSรู้จัก container (Go 1.25) — บน Linux ค่า default เคารพ cgroup CPU limit แล้ว pod ที่ถูกจำกัด 2 CPU บน node 64 core จะได้GOMAXPROCS=2แทนที่จะเป็น 64 และ runtime ตรวจซ้ำเป็นระยะหาก limit เปลี่ยน ไม่ต้องใช้uber-go/automaxprocsอีกต่อไป และruntime.SetDefaultGOMAXPROCS()ใช้คืนค่า default หลังตั้งเองด้วยมือ- Green Tea garbage collector (default ตั้งแต่ Go 1.26) — marking phase ที่ออกแบบใหม่ให้ memory locality ดีขึ้น ลด GC overhead ลง 10–40% ในโปรแกรมจริง ไม่ต้องแก้โค้ด ปิดได้ด้วย
GOEXPERIMENT=nogreenteagc - Goroutine leak profile (GA ใน Go 1.27) —
runtime/pprofมี profilegoroutineleak(เสิร์ฟที่/debug/pprof/goroutineleakผ่านnet/http/pprofด้วย) ที่ใช้ reachability analysis ของ GC หา goroutine ที่ block บน channel หรือ lock ซึ่งไม่มีใครปลดได้อีกแล้ว traceback ของ modulego 1.27+ยังพิมพ์ goroutine label ของruntime/pprofทำให้ระบุที่มาของ goroutine ที่รั่วง่ายขึ้น
// in a real server, with the pprof handler mounted on :6060import _ "net/http/pprof"
// then, with the pprof handler mounted on :6060// curl "localhost:6060/debug/pprof/goroutineleak?debug=1"Runtime Behavior
หัวข้อที่มีชื่อว่า “Runtime Behavior”flowchart TD
subgraph Gs["Goroutines (G) -- thousands, cheap"]
G1["G"]
G2["G"]
G3["G"]
G4["G"]
end
subgraph Ps["Processors (P) -- GOMAXPROCS of these"]
P1["P1 run queue"]
P2["P2 run queue"]
end
subgraph Ms["OS threads (M)"]
M1["M1"]
M2["M2"]
end
G1 --> P1
G2 --> P1
G3 --> P2
G4 --> P2
P1 --> M1 --> CPU0["CPU core 0"]
P2 --> M2 --> CPU1["CPU core 1"]
P1 -.P1 idle: steal a G from P2.-> P2
subgraph IO["When a G blocks on I/O"]
Gio["G"] -->|"blocks"| WaitQ["waiting queue"]
Mrel["M"] -.->|"releases P"| Pfree["P"]
Pfree -.->|"picked up by another M"| Mnew["M"]
Mnew --> Grun["another G runs"]
end ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| goroutine (2-8 KB stack) | สร้างได้หลักแสนตัว | scheduler overhead ต่อ goroutine |
| dynamic stack growth | ไม่เสีย memory ล่วงหน้า | stack copy cost เมื่อขยาย |
| M:N scheduler | ไม่ block OS thread บน I/O | scheduler complexity, preemption cost |
| work stealing | CPU utilization สูง | cache miss เมื่อ goroutine ย้าย P |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- goroutine = OS thread — goroutine stack เริ่มต้น 2-8 KB, OS thread 1-8 MB
- สร้าง goroutine ฟรีทั้งหมด — เริ่มต้นมี stack allocation ~2 KB + scheduler bookkeeping ต่อ goroutine
- GOMAXPROCS มากขึ้น = เร็วขึ้นเสมอ — I/O-bound workload ได้ประโยชน์น้อยกว่า CPU-bound
- goroutine leak ไม่เป็นปัญหา — goroutine ที่ไม่มีทางออกคือ memory leak
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อโค้ดที่ AI สร้างมาเปิด goroutine ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:
wg.Add(1)ถูกเรียก ก่อนgostatement ใน calling goroutine จริงไหม — ไม่ใช่เป็นบรรทัดแรกข้างใน goroutine ใหม่?- ทุก goroutine มีทางออกจริงไหม: loop condition ที่จบลงในที่สุด, case
ctx.Done(), หรือ channel ที่การันตีว่ามันจะ receive หรือ send แค่ครั้งเดียวแน่ ๆ? - ถ้า goroutine นี้ panic ได้ มีอะไรสมมติว่า
recover()ที่อื่น (รวมถึงmain) จะจับมันได้ไหม? มันจับไม่ได้ — panic ใน goroutine ที่ไม่ recover panic ของตัวเองจะฆ่าทั้ง process - จำนวน goroutine ที่เปิดถูกจำกัดด้วย worker pool, semaphore, หรือ
errgroup.SetLimitไหม — หรือมันโตไปตาม input ที่ไม่มีขอบเขตอย่างหนึ่งตัวต่อ request หรือหนึ่งตัวต่อบรรทัดของไฟล์? sync.WaitGroup(หรือ struct ที่มีมันอยู่ข้างใน) ถูกส่งหรือ return โดยค่าแทนที่จะเป็น pointer หรือเปล่า?
🐛 หา bug ในโค้ดนี้
func main() { var wg sync.WaitGroup results := make([]int, 5) for i := 0; i < 5; i++ { go func(id int) { wg.Add(1) // BUG: Add happens inside the goroutine defer wg.Done() results[id] = id * id }(i) } wg.Wait() fmt.Println(results)}เฉลย
wg.Add(1) ต้องรันใน calling goroutine ก่อนที่ wg.Wait() จะมีโอกาสเห็นมันได้ — ตรงนี้มันรันข้างใน goroutine ใหม่แต่ละตัวแทน ดังนั้น scheduler จึงมีสิทธิ์ไปถึง wg.Wait() ขณะที่ counter ยังเป็น 0 เพราะยังไม่มีตัวไหนในห้า goroutine เรียก Add เลย Wait() จึง return ทันที main อ่าน results ขณะที่บาง goroutine ยังเขียนอยู่ และ go run -race จับ race บน results ได้:
WARNING: DATA RACERead at 0x00c000140000 by main goroutine: fmt.Println() /opt/homebrew/Cellar/go/1.26.5/libexec/src/fmt/print.go:315 +0x1ec main.main() /tmp/wgaddbug/main.go:19 +0x88
Previous write at 0x00c000140000 by goroutine 7: main.main.func1() /tmp/wgaddbug/main.go:15 +0x94...Found 1 data race(s)exit status 66slice ที่ print ออกมาอาจมีค่าขาดหรือเป็น 0 หรือ race detector อาจรายงาน race มากกว่าหนึ่งจุด ขึ้นอยู่กับ scheduling ล้วน ๆ — ความไม่แน่นอนนี้เองคือตัวบอกใบ้ วิธีแก้คือกฎเดิมจากบท Goroutines: เรียก wg.Add(1) หนึ่งครั้งต่อรอบ loop ก่อน go statement