☕ 5-Min Tech Story #28

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 กันทั่วไป:

func queryWithTimeout() string {
  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 วิ เดินหนีทันที!
  }
}

ดูเหมือนจะปลอดภัยใช่ไหมครับ? แต่ดูสิ่งที่เกิดขึ้นเบื้องหลังให้ดี:

  1. เมื่อเวลาผ่านไป 500ms ฝั่ง select เดินหนีออกจากฟังก์ชันไปพร้อมคำว่า "timeout"
  2. ผ่านไป 2 วินาที Database ตอบกลับมา... Goroutine พยายามจะยัด ch <- result
  3. แต่เพราะ ch เป็น Unbuffered Channel และคนรับเดินหนีกลับบ้านไปแล้ว!
  4. Goroutine ตัวนี้เลยถูก แช่แข็ง (Block) ค้างเติ่งอยู่ใน RAM ทันที กลายเป็นผีดิบที่ไม่มีวันถูกปลุกและไม่มีวันตาย!

ถ้าฟังก์ชันนี้ถูกเรียกวินาทีละ 100 ครั้ง ในหนึ่งวันคุณจะมี ผี Goroutine ตกค้างในระบบถึง 8.6 ล้านตัว! ต่อให้ตัวละแค่ 2 KB รวมกันก็ทะลุ 17 GB จนแรมแตกแน่นอน

🛡️ 2 ทางรอด: ใช้ Buffer 1 ช่อง หรือส่ง Context ไปตัดสาย

1. ใส่ Buffer เผื่อไว้ให้วางของแล้วชิ่งได้:

// ทำเป็น Buffered Channel ขนาด 1 ช่อง
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 จนหมดเครื่อง