Goroutine Leak: วิญญาณเร่ร่อน 100,000 ตัว ที่แอบสูบ RAM จน Server ดับเงียบ
⏱️ ใช้เวลาอ่านประมาณ: 4 นาที 20 วินาที
จุดขายอันดับหนึ่งที่ทำให้คนหลงรักภาษา Go คือ: "Goroutine มันเบามาก! กิน Memory แค่ประมาณ 2 KB ต่อตัว เปิดทิ้งเป็นหมื่นเป็นแสนตัวก็ไม่สะเทือน!"
ด้วยความมั่นใจนี้ หลายคนเลยสั่ง go doSomething() รัวๆ ในทุกคำขอที่วิ่งเข้ามา... แต่พอรันระบบไปได้ 2 สัปดาห์ กราฟ RAM บน Grafana กลับไต่ระดับขึ้นเป็นเส้นตรงชันเรื่อยๆ ไม่มีทีท่าว่าจะลดลง จนในที่สุด Linux OOM Killer ก็เข้ามาเตะเซิร์ฟเวอร์ทิ้งดื้อๆ
ทำไม Garbage Collector ถึงไม่ยอมเก็บกวาด Goroutine ที่ทำงานเสร็จไปแล้ว?
👻 ความจริงที่เจ็บปวด: GC ไม่เก็บกวาดวิญญาณ Goroutine
หลายคนคิดว่าถ้าตัวแปรข้างในไม่มีใครใช้แล้ว Go GC จะช่วยฆ่า Goroutine ทิ้งให้... แต่ความจริงคือ ไม่ใช่เลย!
ตราบใดที่ Goroutine ยังติดบล็อก (Blocked) รออะไรบางอย่างอยู่ Go Runtime จะถือว่า "หมอนี่ยังมีชีวิตและยังมีงานต้องทำ" มันจะถูกตรึงไว้ในหน่วยความจำ พร้อมตัวแปรทั้งหมดบน Stack ของมันตลอดกาล
🕳️ ตัวอย่างฆาตกรรมเงียบ: Channel ที่ไม่มีวันมีคนมารับ
ลองดูโค้ดยอดฮิตที่เขียนดัก Timeout กันทั่วไป:
ch := make(chan string) // Unbuffered Channel!
go func() {
result := callSlowDatabase() // สมมติใช้เวลา 2 วินาที
ch <- result // 💥 จุดรั่วไหล: ติดบล็อกตรงนี้ชั่วนิรันดร์!
}()
select {
case res := <-ch:
return res
case <-time.After(500 * time.Millisecond):
return "timeout" // ถ้ารอนานเกิน 0.5 วิ เดินหนีทันที!
}
}
ดูเหมือนจะปลอดภัยใช่ไหมครับ? แต่ดูสิ่งที่เกิดขึ้นเบื้องหลังให้ดี:
- เมื่อเวลาผ่านไป 500ms ฝั่ง
selectเดินหนีออกจากฟังก์ชันไปพร้อมคำว่า "timeout" - ผ่านไป 2 วินาที Database ตอบกลับมา... Goroutine พยายามจะยัด
ch <- result - แต่เพราะ
chเป็น Unbuffered Channel และคนรับเดินหนีกลับบ้านไปแล้ว! - Goroutine ตัวนี้เลยถูก แช่แข็ง (Block) ค้างเติ่งอยู่ใน RAM ทันที กลายเป็นผีดิบที่ไม่มีวันถูกปลุกและไม่มีวันตาย!
ถ้าฟังก์ชันนี้ถูกเรียกวินาทีละ 100 ครั้ง ในหนึ่งวันคุณจะมี ผี Goroutine ตกค้างในระบบถึง 8.6 ล้านตัว! ต่อให้ตัวละแค่ 2 KB รวมกันก็ทะลุ 17 GB จนแรมแตกแน่นอน
🛡️ 2 ทางรอด: ใช้ Buffer 1 ช่อง หรือส่ง Context ไปตัดสาย
1. ใส่ Buffer เผื่อไว้ให้วางของแล้วชิ่งได้:
ch := make(chan string, 1)
แม้คนรับจะเดินหนีไปแล้ว Goroutine ที่ทำงานช้าก็ยังสามารถโยน result แปะลงกล่อง 1 ชิ้นแล้วจบฟังก์ชัน (Exit) ตัวเองได้อย่างสงบ GC จึงเข้ามาเก็บกวาดหน่วยความจำได้ตามปกติ
2. ส่ง context.Context ไปสะกิดเตือนเสมอ:
ส่ง ctx พร้อมสั่ง WithTimeout หรือ WithCancel ลงไปด้วย เพื่อให้ฟังก์ชันข้างในคอยเช็กว่า ctx.Done() หรือยัง ถ้าปลายทางตัดใจแล้ว ก็ให้หยุดทำงานทันทีโดยไม่ต้องส่งข้อมูลต่อ
💡 5-Min Takeaway
"สร้าง Goroutine ง่ายแค่พิมพ์คำว่า 'go' แต่หน้าที่ของวิศวกรคือต้องรู้ว่ามันจะ 'จบชีวิต' เมื่อไหร่เสมอ" ทุกครั้งที่เริ่ม Goroutine ใหม่ ให้ถามตัวเองเสมอว่า: ถ้าการทำงานผิดพลาด หรือคนรับเดินหนีไป Goroutine ตัวนี้จะเลิกทำงานได้อย่างไร? ตรวจสอบจำนวน Goroutine ในระบบอย่างสม่ำเสมอด้วย runtime.NumGoroutine() หรือผ่าน pprof เพื่อดักจับวิญญาณเร่ร่อนก่อนที่มันจะสูบ RAM จนหมดเครื่อง