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

Goroutines

Goroutine คือ function ที่ทำงานพร้อมกันกับ goroutines อื่น ๆ ในพื้นที่ address เดียวกัน คุณสร้างได้ด้วย keyword เดียว:

go f(args)

นั่นคือ syntax ทั้งหมด scheduler จัดคิว f ให้รันแบบ concurrent แล้ว goroutine ที่เรียกก็ดำเนินต่อทันทีโดยไม่รอให้ f return

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 หลายหมื่นตัวรันพร้อมกันเป็นเรื่องปกติ

Scheduler ทำงานบนสามแนวคิด:

สัญลักษณ์ความหมาย
GGoroutine — หน่วยของงาน concurrent
MMachine — OS thread
PProcessor — 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 อื่นทำงานต่อได้

เมื่อ 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 และเก่ากว่า

สองตัวนี้สับสนกันง่าย แต่ตอบคำถามคนละอย่าง:

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 เป็นวิธีมาตรฐานในการรอ goroutines จำนวนคงที่ให้เสร็จ:

  • wg.Add(n) — เพิ่ม counter ขึ้น n (เสมอก่อน go statement)
  • wg.Done() — ลด counter ลง 1 (เรียกด้วย defer ใน goroutine)
  • wg.Wait() — บล็อกจนกว่า counter จะถึงศูนย์

ตั้งแต่ Go 1.25 WaitGroup มี method Go ที่รวม Add(1), go statement และ defer Done() ไว้ในการเรียกครั้งเดียว:

var wg sync.WaitGroup
for 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 ที่ยังมีชีวิตอยู่แต่ไม่มีทางไปต่อได้และไม่มีทาง exit เลย — มันหยุดค้างอยู่ตลอดไป ถือ stack ของมันและทุกอย่างที่มัน closure ไว้ ต่างจาก memory leak จาก allocation ที่ไม่มีการอ้างอิงถึง garbage collector ไม่สามารถปล่อยมันได้ เพราะ runtime เองยังถือ reference ที่ยังมีชีวิตอยู่ไปยังมัน สามสาเหตุนี้ครอบคลุม leak เกือบทั้งหมดในโค้ดจริง:

  1. Block บน channel ที่ไม่มีคู่. goroutine receive จาก (หรือ send เข้า) channel ที่ไม่มีใคร send เข้า (หรือ receive จาก) มันเลย — สาเหตุที่พบบ่อยที่สุด
  2. Block บน lock ที่ไม่เคยถูกปล่อย. goroutine เรียก mu.Lock() และ goroutine ที่ควรเรียก Unlock() panic, return ก่อนเวลา, หรือตัวมันเองก็รั่วไปแล้ว
  3. Loop ที่ไม่มีเงื่อนไขออก. for loop ที่ตั้งใจให้รัน “จนกว่าจะถูกยกเลิก” แต่ไม่เคยเช็ก 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: 5

runtime.NumGoroutine() คือเครื่องมือตรวจ leak ที่ถูกที่สุด — table-driven test ที่ snapshot จำนวนก่อนและหลังเรียกโค้ดที่ทดสอบ แล้ว assert ว่ามันกลับมาที่ baseline จับ leak ส่วนใหญ่ได้ใน CI ก่อนที่จะไปถึง production ใน production, goroutine profile ของ runtime/pprof (หรือตั้งแต่ Go 1.27 คือ profile เฉพาะทาง goroutineleak ที่พูดถึงด้านล่าง) จะแสดงตำแหน่งที่ goroutine ที่ยังมีชีวิตอยู่ทุกตัว block อยู่พอดี

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 +0x2c
created by main.main in goroutine 1
/tmp/goroutine-panic/main.go:14 +0x40
exit status 2

defer/recover() ใน main ไม่เคยรันเลย — runtime ไม่พยายามด้วยซ้ำ เพราะ recover ใช้ได้เฉพาะกับ goroutine ที่กำลัง unwind อยู่ตอนนั้นเท่านั้น goroutine ไหนก็ตามที่ panic ได้ต้อง recover panic ของตัวเองถ้าอยากให้โปรแกรมรอดชีวิต รูปแบบที่ใช้กันบ่อยคือ defer func() { recover() }() เล็ก ๆ ที่ต้นของทุก worker goroutine ที่มีอายุยืน โดย log ค่าที่ recover ได้แทนที่จะทิ้งไปเฉย ๆ

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 สามอย่างจาก 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 มี profile goroutineleak (เสิร์ฟที่ /debug/pprof/goroutineleak ผ่าน net/http/pprof ด้วย) ที่ใช้ reachability analysis ของ GC หา goroutine ที่ block บน channel หรือ lock ซึ่งไม่มีใครปลดได้อีกแล้ว traceback ของ module go 1.27+ ยังพิมพ์ goroutine label ของ runtime/pprof ทำให้ระบุที่มาของ goroutine ที่รั่วง่ายขึ้น
// in a real server, with the pprof handler mounted on :6060
import _ "net/http/pprof"
// then, with the pprof handler mounted on :6060
// curl "localhost:6060/debug/pprof/goroutineleak?debug=1"
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
GMP model: goroutine เข้าคิวที่ P, P ต้องถูก M ถือไว้ถึงจะรันบน CPU core ได้ และ P ที่ว่างจะขโมยงานจาก P ที่ยุ่ง
สิ่งที่ได้ประโยชน์ต้นทุน
goroutine (2-8 KB stack)สร้างได้หลักแสนตัวscheduler overhead ต่อ goroutine
dynamic stack growthไม่เสีย memory ล่วงหน้าstack copy cost เมื่อขยาย
M:N schedulerไม่ block OS thread บน I/Oscheduler complexity, preemption cost
work stealingCPU 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 สร้างมาเปิด goroutine ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:

  • wg.Add(1) ถูกเรียก ก่อน go statement ใน 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 RACE
Read 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 66

slice ที่ print ออกมาอาจมีค่าขาดหรือเป็น 0 หรือ race detector อาจรายงาน race มากกว่าหนึ่งจุด ขึ้นอยู่กับ scheduling ล้วน ๆ — ความไม่แน่นอนนี้เองคือตัวบอกใบ้ วิธีแก้คือกฎเดิมจากบท Goroutines: เรียก wg.Add(1) หนึ่งครั้งต่อรอบ loop ก่อน go statement

ขนาด stack เริ่มต้นโดยประมาณของ goroutine ใหม่คือเท่าไหร่?
ต้องเรียก wg.Add(1) ที่ไหนสัมพันธ์กับ go statement?
เกิดอะไรขึ้นถ้า main return ขณะที่ goroutines ยังรันอยู่?
GOMAXPROCS ควบคุมอะไร?
เกิดอะไรขึ้นกับ 5 goroutine ที่เปิดข้างใน leaky?
func leaky() {
    ch := make(chan int)
    go func() {
        <-ch
    }()
}

func main() {
    for i := 0; i < 5; i++ {
        leaky()
    }
    time.Sleep(50 * time.Millisecond)
    fmt.Println(runtime.NumGoroutine())
}
deferred recover() ใน main จับ panic นี้ได้ไหม?
func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered:", r)
        }
    }()
    go func() { panic("boom") }()
    time.Sleep(100 * time.Millisecond)
}