Go 1.27 หา Goroutine ที่ไม่มีวันตื่นด้วย Leak Profile
server ที่ goroutine เพิ่มทีละนิดเหมือนก๊อกน้ำหยดครับ ช่วงแรกไม่รู้สึกอะไร แต่หลายวันต่อมา memory, timer และ connection เริ่มสะสม Go 1.27 ทำ goroutineleak profile เป็น stable ช่วยชี้ goroutine ที่ติดกับ synchronization primitive ซึ่งไม่มีทางถูกปลดได้
Go 1.27 release notes ระบุว่า profile นี้เคยเป็น experiment ใน Go 1.26 และตอนนี้ใช้ได้ผ่าน runtime/pprof รวมถึง endpoint /debug/pprof/goroutineleak ของ net/http/pprof โดยไม่ต้องเปิด GOEXPERIMENT แล้ว
WHY: จำนวน Goroutine บอกอาการ แต่ไม่บอกว่าใครตายคาที่
runtime.NumGoroutine() บอกแนวโน้มได้ แต่ goroutine จำนวนมากอาจถูกต้อง เช่น worker ที่รอ queue หรือ connection ที่ยังมีเจ้าของ Profile แบบ goroutine ปกติแสดงทุก stack จึงต้องให้คนเดาว่าตัวไหนรอชั่วคราวและตัวไหนไม่มีวันตื่น
goroutineleak ใช้ข้อมูล reachability จาก garbage collector ถ้า goroutine ติดอยู่กับ channel, mutex หรือ condition และ primitive นั้นเข้าถึงไม่ได้จาก goroutine ที่ยังรันหรือปลดมันได้ runtime จึงสรุปได้ว่ามันไม่มีทางตื่น เหมือนกุญแจถูกล็อกไว้ในห้องที่ไม่มีประตู
วิธีนี้ตรวจได้เป็นกลุ่มใหญ่ แต่ไม่ครบทุก leak หาก channel หรือ lock ยังถูกอ้างจาก global variable หรือ local variable ของ goroutine ที่ runnable อยู่ GC จะยังมองเห็นทางเข้าถึง จึงอาจไม่รายงานแม้ logic จริงจะไม่ส่งสัญญาณอีกแล้ว
HOW: สร้าง Leak แล้วให้ Profile จับ
test ต่อไปนี้สร้าง channel ใน function แล้วปล่อย goroutine รอรับค่า หลัง function จบ ไม่มีใครถือปลายส่งเหลืออยู่ เรา polling profile ช่วงสั้น ๆ เพราะการตรวจอาศัย GC cycle แล้วเช็ก stack name ที่เราควบคุม
package leakdemo
import (
"runtime/pprof"
"strings"
"testing"
"time"
)
func waitForever() {
ch := make(chan struct{})
go func() {
<-ch
}()
}
func TestGoroutineLeakProfileFindsUnreachableChannel(t *testing.T) {
waitForever()
profile := pprof.Lookup("goroutineleak")
if profile == nil {
t.Fatal("goroutineleak profile is unavailable")
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
var out strings.Builder
if err := profile.WriteTo(&out, 2); err != nil {
t.Fatalf("write profile: %v", err)
}
if strings.Contains(out.String(), "waitForever.func1") {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("leaked goroutine was not reported before deadline")
}
test นี้มีความหมายเพราะตรวจว่ามี leak stack จริง ไม่ใช่เพียงเรียก profile แล้วจบ หากเอาไปใช้ใน CI ให้ตั้ง timeout และเลี่ยง assertion ที่ผูกกับจำนวน goroutine ทั้ง process เพราะ test runner และ library อื่นอาจมี goroutine ของตัวเอง
ใช้ใน Service จริงอย่างระวัง
ถ้าเปิด net/http/pprof อย่า expose สู่ internet สาธารณะ Profile อาจมีชื่อ function, path หรือ label ที่บอกโครงสร้างระบบ ให้ผูกกับ loopback/admin network และ authentication ตามมาตรฐานองค์กร
เมื่อพบ stack ให้ย้อนกลับไปดู ownership: ใครสร้าง goroutine, ใครต้อง cancel, channel ปิดที่ไหน และ error path คืนก่อน cleanup หรือไม่ วิธีแก้มักเป็น context.Context, การปิด channel โดย owner, หรือ defer ที่อยู่ใน scope ถูกต้อง ไม่ใช่ kill goroutine จากข้างนอก
ควรเก็บ metric จำนวน goroutine ควบคู่ profile เพราะสองอย่างตอบคนละคำถาม Metric บอกว่าอาการกำลังโตหรือไม่ ส่วน leak profile ช่วยชี้ stack ที่ runtime พิสูจน์ได้ว่าไม่มีวันตื่น
Recap
Go 1.27 เพิ่มเครื่องมือที่ต่างจากการไล่อ่าน goroutine dump ทั้งกอง มันใช้ reachability ของ GC หา synchronization primitive ที่ไม่มีใครปลดได้ แต่ยังไม่ใช่เครื่องตรวจ leak ครบทุกชนิด
พรุ่งนี้ลองเพิ่ม test แบบตัวอย่างให้ component ที่เคยมีปัญหา แล้วตรวจ ownership ของ goroutine ทุกจุด: creator, cancel path และ cleanup ถ้าสามชื่อนี้ตอบไม่ได้ leak profile อาจกำลังรอช่วยเราอยู่ครับ