บทที่ 17 · Part 5 — Concurrency That Stops

Goroutine Ownership

ทุก goroutine ต้องมีเจ้าของ เงื่อนไขหยุด และเส้นทางส่งผลลัพธ์หรือความผิดพลาดกลับ

คำสั่ง go f() ใช้พิมพ์เพียงไม่กี่ตัวอักษร แต่สร้าง unit of work ที่อาจอยู่หลัง request, ถือ socket, เขียน state และล้มโดยไม่มีใครรับ error Goroutine จึงควรถูกมองเป็น resource ที่มี owner ไม่ใช่ของฟรี เป้าหมายไม่ใช่ห้าม concurrency แต่ทำให้ lifetime เป็นส่วนหนึ่งของ design

จบบทนี้คุณจะ

  • ตอบได้ว่าใครเริ่ม หยุด รอ และรับ error จากทุก goroutine
  • ใช้ synchronous API, WaitGroup.Go และ errgroup ตามความต้องการ
  • ออกแบบ worker tree ที่ shutdown ได้โดยไม่ทิ้งงานเงียบ ๆ

Four Questions ก่อนใช้ go

ทุกจุดที่ spawn goroutine ต้องตอบ:

  1. ใครเป็น owner และ owner มีอายุเท่าไร
  2. มันหยุดอย่างไร เมื่อสำเร็จ, error หรือ cancellation
  3. ใครรอ completion ก่อนคืน response หรือปิด process
  4. error/result ไปไหน และถ้าไม่มีผู้รับจะเกิดอะไรขึ้น

ถ้าตอบไม่ได้ ให้ function เป็น synchronous ก่อน Caller สามารถวาง go รอบ function ได้เมื่อมี owner ที่เหมาะสม แต่ synchronous function ไม่สามารถดึง goroutine ที่ซ่อนไว้กลับมาควบคุมได้

tree นี้คือ structured concurrency ในทางปฏิบัติ งานลูกไม่ควรมี lifetime ยาวกว่า parent โดยไม่มีการ โอน ownership ที่ชัด เมื่อ process shutdown มันหยุดรับงานใหม่, cancel งานที่อนุญาตให้ยกเลิก, รอถึง grace period แล้วรายงานงานที่ยังค้าง

WaitGroup.Go สำหรับ Completion ธรรมดา

Go 1.25 เพิ่ม sync.WaitGroup.Go ลดความผิดพลาดจาก Add/Done ไม่ตรงกัน เหมาะเมื่อแค่รอทุกงาน และ function ไม่ควร panic ตาม contract ของ method:

var wg sync.WaitGroup
for _, job := range jobs {
    job := job
    wg.Go(func() {
        process(job)
    })
}
wg.Wait()

loop variable workaround job := job ไม่จำเป็นสำหรับ module Go 1.22+ แต่ใส่ใน code ที่ต้องรองรับ language semantics เก่าหรือเพื่อสื่อ capture ได้ คอร์สนี้ใช้ Go 1.25 จึงตัดออกได้โดยไม่เกิด bug

ถ้างานคืน error, ต้อง cancel sibling หรือจำกัด concurrency ให้ใช้ golang.org/x/sync/errgroup:

group, ctx := errgroup.WithContext(ctx)
group.SetLimit(8)

for _, job := range jobs {
    group.Go(func() error {
        return process(ctx, job)
    })
}

if err := group.Wait(); err != nil {
    return fmt.Errorf("processing jobs: %w", err)
}

SetLimit ทำให้ submission block เมื่อเต็ม จึงเป็น backpressure หนึ่งรูปแบบ อย่าเรียก SetLimit ขณะ group มี goroutine active และอย่าลืมว่า error แรก cancel context แต่ function ที่ไม่ฟัง context ยังทำงานต่อ

Channel Ownership และ Closure

ผู้สร้าง channel และฝั่งที่รู้ว่าไม่มี send อีกแล้วควรเป็นผู้ close Receiver ไม่ควร close channel เพื่อ “ขอให้ sender หยุด” เพราะ sender อาจ panic เมื่อส่ง ให้ใช้ context/done signal แยก และระบุ direction ใน signature:

func produce(ctx context.Context, out chan<- Job) error
func consume(ctx context.Context, in <-chan Job) error

การ close เป็น signal ว่าไม่มีค่าเพิ่ม ไม่ใช่การ free channel และไม่ต้อง close channel ที่ GC จะเก็บ หาก receiver ไม่ได้รอ termination signal นั้น

ทุก blocking send/receive ใน long-lived goroutine ต้องพิจารณา ctx.Done() มิฉะนั้น cancellation มาถึงแต่ goroutine ติดรอ channel ที่ไม่มีคนอ่าน เกิด leak พร้อม resource ที่มันถือ

Fire-and-Forget แทบไม่มีจริง

analytics หรือ audit ที่ดูเหมือน “ไม่ต้องรอ” ยังต้องมี queue capacity, failure policy, shutdown flush และ metric เมื่อ drop ถ้าต้องรับประกันว่าจะไม่หาย ให้ใช้ durable queue/outbox หรือ Temporal ไม่ใช่ goroutine ใน handler context.WithoutCancel ไม่ได้ทำให้ process crash แล้วงานรอด

Production Toolbox

Default: synchronous API ใช้ WaitGroup.Go สำหรับ wait-only, errgroup.WithContext สำหรับ error/cancellation/limit และ durable system สำหรับงานที่ต้องรอด process go.uber.org/goleak ช่วยตรวจ leak ใน test แต่ design ownership ยังต้องอ่านจาก code ได้

Checklist ของ Goroutine Lifetime

  • ทุก spawn point มี owner, exit, wait และ error path
  • parent cancellation ไปถึง blocking operation ของลูก
  • channel ถูก close โดย sender/owner ที่รู้ว่า send จบแล้ว
  • concurrency มี limit ไม่ขึ้นกับขนาด input ที่ untrusted
  • shutdown หยุดรับงานก่อน cancel/drain และมี grace period
  • งานที่ต้อง durable ไม่พึ่ง process-local goroutine
  • tests ตรวจ success, failure, cancellation และ leak

อ่านเพิ่ม: Go Concurrency Patterns: Pipelines, sync.WaitGroup และ errgroup