บทเรียน 23 · Day 6 · Concurrency

select: รอหลายทางพร้อมกัน

~13 นาที · ต้องมี: บทเรียน 22 (channel)

msg := <-ch รอได้ทีละท่อ — ถ้ามีทั้ง "ท่อผลลัพธ์" และ "ท่อ error" คุณจะรอทางไหนก่อน? รอผิดทางแล้วอีกทางมาถึงก่อน = ค้าง
select คือคำตอบ: รอทุกทางพร้อมกัน ใครมาถึงก่อนเอาคนนั้น
บทนี้สั้นแต่มีกับดักหนึ่งอันที่กิน CPU 100% แบบไม่มีใครสังเกต

1 · select คือ switch ของ channel

รอทีละท่อ — ต้องเดาถูก

msg := <-messages
// ถ้า error มาก่อน → ค้างตรงนี้

select — รอพร้อมกัน

select {
case msg := <-messages:
    fmt.Println("message:", msg)

case err := <-errs:
    fmt.Println("error:", err)
}

select block จนกว่าจะมี case ใดสักอันทำงานได้ แล้วรันแค่ case นั้นอันเดียว จบ — ไม่ต้องมี break และไม่ตกลง case ถัดไป เหมือน switch

🛗 เทียบให้เห็นภาพ — ยืนรอลิฟต์ 3 ตัว

เวลายืนรอลิฟต์ในห้าง เราไม่ได้จ้องอยู่ตัวเดียวแล้วรอจนตัวนั้นเปิด
เรายืนตรงกลาง มองทุกตัวพร้อมกัน ตัวไหนเปิดก่อน ก็เดินเข้าตัวนั้น

ถ้าไปยืนจ้องตัวซ้ายสุดตัวเดียว แล้วตัวขวาเปิดก่อน — เราก็ยืนรอต่อไปเปล่า ๆ นั่นคือ msg := <-messages · ส่วน select คือยืนตรงกลาง มองทุกตัวพร้อมกัน

ที่ไม่เหมือนกัน: ถ้าลิฟต์เปิดพร้อมกัน 2 ตัว เราเลือกเองได้ว่าจะเข้าตัวไหน แต่ select สุ่มให้ (หัวข้อ 2 ต่อไป)
และ select หนึ่งอันพาเราขึ้นได้เที่ยวเดียว — ทำ case เดียวแล้วจบ อยากรอรอบต่อไปต้องกลับมายืนรอใหม่ ซึ่งเป็นเหตุผลที่หัวข้อ 3 ต้องเอา for มาครอบ

ใส่ case ฝั่ง ส่ง ก็ได้ ไม่จำเป็นต้องเป็นฝั่งรับอย่างเดียว:

select {
case msg := <-messages:
    fmt.Println("message:", msg)

case ch <- value:          // รอจนกว่าจะส่งเข้า ch ได้
    fmt.Println("send")
}
🗣 ศัพท์ที่เพิ่งเรียนไป

สิ่งที่เพิ่งเข้าใจไป วงการเรียกแบบนี้ — ใช้คำให้ตรง แล้วจะถาม senior หรือถาม AI ได้ตรงจุด

block / blocking
“หยุดรออยู่ตรงนั้น ไม่ไปบรรทัดถัดไปจนกว่าจะได้ของ” · เวลาเจอโปรแกรมนิ่ง ให้ถามว่า “มันไป block อยู่บรรทัดไหน” จะสื่อกว่าถามว่า “ทำไมมันค้าง”
case พร้อม (ready)
case ที่ทำได้จริงในตอนนั้น — ฝั่งรับคือมีของมาถึงแล้ว หรือ channel ถูก close ไปแล้ว (รับได้ทันทีเป็นค่าว่าง ไม่ต้องรอ) · ฝั่งส่งคือมีที่ให้ส่งแล้ว · จำข้อ close ไว้ให้ดี เพราะมันคือวิธีทำให้ทุกคนที่รออยู่พร้อมพร้อมกันหมดทีเดียว — ท่านี้จะโผล่มาอีกในหัวข้อ 4
starvation
“อดตาย” — งานบางอย่างไม่เคยได้ทำเลยเพราะมีตัวอื่นแย่งไปหมด · ประโยคที่เอาไปใช้ได้: “ถ้า select เลือกตัวบนเสมอ case ล่างจะ starve”
(ในภาษา Go ปัญหานี้ถูกกันไว้แล้ว — select ไม่ได้เลือกตัวบนเสมอ เดี๋ยวหัวข้อ 2 ถัดไปจะพิสูจน์ให้ดู)

2 · ถ้าพร้อมพร้อมกันหลาย case จะเลือกอันไหน?

a := make(chan string, 10)
b := make(chan string, 10)

// ใส่ของทั้งสองท่อให้พร้อมทั้งคู่ แล้ว select — ทำ 1000 รอบ
a <- "A"
b <- "B"
select {
case <-a:
    countA++
case <-b:
    countB++
}

a เขียนไว้ก่อน — จะได้ countA = 1000 ใช่ไหม?

ผมรัน 3 ครั้ง ครั้งละ 1000 รอบ:

เลือก A 485 ครั้ง, B 515 ครั้ง
เลือก A 478 ครั้ง, B 522 ครั้ง
เลือก A 512 ครั้ง, B 488 ครั้ง

ประมาณครึ่งต่อครึ่ง — select สุ่มเลือก ไม่ได้เรียงตามลำดับที่เขียนเหมือน switch

ทำไมออกแบบมาแบบนี้: ถ้าเลือกตัวบนสุดเสมอ channel ที่มีของตลอดเวลาจะกิน case แรกไปหมด — case ล่าง ๆ จะไม่มีวันได้ทำงานเลย (เรียกว่า starvation)
ผลที่ตามมา: อย่าเขียนโค้ดที่พึ่งลำดับของ case ถ้าต้องการให้บางอย่างมาก่อนจริง ๆ ต้องเช็คแยกก่อนเข้า select

3 · for + select — วนรับไปเรื่อย ๆ

for {
    select {
    case msg := <-messages:
        fmt.Println("message:", msg)

    case err := <-errs:
        fmt.Println("error:", err)
    }
}

นี่คือรูปแบบที่ใช้บ่อยที่สุดในโค้ดจริง — service ที่คอยฟังหลายทางไปตลอด
แต่ต้องมีทางออก ไม่งั้นวนตลอดกาล — บทเรียน 25 (context) จะให้ทางออกที่ถูกต้อง

4 · default — ไม่รอเลยถ้าไม่มีใครพร้อม

select {
case msg := <-messages:
    fmt.Println("message:", msg)

case err := <-errs:
    fmt.Println("error:", err)

default:
    fmt.Println("default!")   // ไม่มีใครพร้อม → ทำอันนี้ทันที ไม่รอ
}
สถานการณ์ไม่มี defaultมี default
มี case พร้อมทำ case นั้นทำ case นั้น (default ไม่ถูกเลือก)
ไม่มี case ไหนพร้อมรอ จนกว่าจะมีทำ default ทันที
ไม่มี case เลย (select{})ค้างตลอดกาล—
📦 เทียบให้เห็นภาพ — รอของที่สั่งไว้

สั่งของแล้วอยากรู้ว่ามาถึงหรือยัง มีสองท่า
· ไปนั่งรอที่ร้านแล้วหลับไปเลย — ของถึงปุ๊บมีคนปลุก ระหว่างหลับเราไม่ได้ออกแรงอะไรเลย แต่ก็ไปทำอย่างอื่นไม่ได้เหมือนกัน → นี่คือ select ที่ไม่มี default
· โทรไปถามเองว่า "ของมาหรือยัง" — ถามแล้วถ้ายังไม่มา ก็วางสายทันที ไม่ยืนรอ → นี่คือ select ที่มี default

ท่าที่สองใช้ได้ ก็ต่อเมื่อวางสายแล้วเรามีงานอย่างอื่นให้ทำจริง ๆ
แต่ถ้าวางสายปุ๊บโทรใหม่ปั๊บไม่หยุด = เสียแรงทั้งวันเพื่อไม่ได้อะไรเลย นั่นคือกับดักในกล่องถัดไป

ที่ไม่เหมือนกัน: สองข้อ
· “หลับรอ” ไม่ได้แปลว่าโค้ดวิ่งต่อไปได้แล้วค่อยมีคนโทรกลับมาบอกทีหลัง — select ที่ไม่มี default ค้างอยู่ตรงนั้นจริง ๆ บรรทัดถัดไปไม่วิ่งจนกว่าจะมี case พร้อม · แต่ goroutine ตัวอื่น ยังทำงานต่อได้ปกติ ระหว่างที่ตัวนี้หลับ (ผมลองแล้ว: ตัวหลักหลับรอ 100ms อีกตัววิ่งไป 5 รอบพอดี)
· คนโทรถามได้เต็มที่นาทีละครั้ง แต่โปรแกรมถามได้ เป็นสิบล้านครั้งต่อวินาที — ความเสียหายคนละสเกลกันเลย

done := make(chan struct{})
go func() {
    time.Sleep(300 * time.Millisecond)
    close(done)
}()

loops := 0
for {
    select {
    case <-done:
        fmt.Println("วนไป", loops, "รอบ")
        return
    default:
        loops++
    }
}

โปรแกรมรอ 300 มิลลิวินาที — ระหว่างนั้นวนไปกี่รอบ?

ผมรันแล้วได้:

busy loop วนไป 95,778,173 รอบ ใน 301ms

เกือบร้อยล้านรอบใน 0.3 วินาที — แต่ละรอบ CPU ทำงานเต็มที่เพื่อไม่ทำอะไรเลย
นี่คือ busy loop (หรือ busy-wait) — กิน CPU เต็ม 1 core โดยที่โปรแกรมยังทำงาน "ถูกต้อง" ทุกประการ

⚠️ กับดัก: default ข้างใน for ที่ไม่มีอะไรคั่น
ถ้าเจอโค้ดแบบนี้ใน production คุณจะเห็นแค่ "CPU 100% ตลอดเวลา" โดยหาสาเหตุไม่เจอ เพราะไม่มี error ไม่มี log อะไรผิด
วิธีแก้: เอา default ออก — ให้ select block รอไปเลย ซึ่งไม่กิน CPU สักนิด เพราะ Go runtime พัก goroutine ไว้จนกว่าจะมีของมาจริง ๆ

💡 ใช้ default เมื่อไหร่ถึงถูก

ใช้เมื่อคุณมีอย่างอื่นให้ทำจริง ๆ — ไม่ใช่เพื่อ "เช็คไปเรื่อย ๆ"

// ✅ ถูก — ส่งได้ก็ส่ง ส่งไม่ได้ก็ทิ้ง ไม่รอใคร
select {
case metrics <- stat:
default:
    droppedCount++      // buffer เต็ม ยอมทิ้ง ดีกว่าให้ระบบหลักช้า
}

// ✅ ถูก — เช็คว่าถูกสั่งยกเลิกหรือยัง แล้วทำงานหนักต่อ
// (ctx คือ 'ตัวแจ้งยกเลิกงาน' ที่ส่งต่อกันได้ — บทเรียน 25 จะสอนวิธีสร้าง
//  ตอนนี้อ่านแค่ว่า <-ctx.Done() = 'มีคนสั่งเลิกหรือยัง' ถ้ายัง ก็ตกลง default
//  แล้วไปทำ doHeavyWork() ต่อ ไม่ต้องรออะไรเลย)
select {
case <-ctx.Done():
    return ctx.Err()
default:
}
doHeavyWork()          // ← มีงานจริงคั่น ไม่ใช่วนเปล่า
🗣 ศัพท์ที่เพิ่งเรียนไป
blocking / non-blocking
“ยอมหยุดรอ” กับ “ไม่รอเลย ไม่ได้ก็ไปต่อ” · default คือสิ่งที่เปลี่ยน select จาก blocking เป็น non-blocking · เวลาถามให้พูดว่า “select ตรงนี้ blocking หรือ non-blocking” จะสื่อกว่าถามว่า “ตรงนี้มันรอไหม”
polling
“คอยถามซ้ำ ๆ ว่ามีของหรือยัง” — ท่าโทรไปถามเองในกล่องข้างบน · ถามได้ว่า “ตรงนี้ควร poll หรือควรให้ block รอ”
busy loop / busy-wait / spin
polling ที่วนถามแบบไม่มีอะไรคั่นเลย จน CPU เต็ม 1 core · เป็นคำที่ senior จะเข้าใจทันที — บอกว่า “น่าจะมี busy loop อยู่” ตรงประเด็นกว่าบอกว่า “CPU ขึ้นแปลก ๆ”
drop
“ยอมทิ้งของไป” เมื่อส่งไม่ทัน — แบบตัวอย่าง metrics ข้างบน · “ตรงนี้ยอม drop ได้ไหม หรือห้ามหายเด็ดขาด” คือคำถามแรกก่อนใส่ default

5 · Timeout — select + time.After

select {
case result := <-resultCh:
    fmt.Println("Result:", result)

case <-time.After(2 * time.Second):
    fmt.Println("Timeout!")
}

time.After(d) คืน channel ที่จะมีค่าโผล่มาเมื่อครบเวลา — พอเอามาใส่เป็น case หนึ่งใน select ก็กลายเป็นการแข่งกัน: งานเสร็จก่อน หรือเวลาหมดก่อน

ผมรันตัวอย่างที่งานใช้ 3 วินาที แต่ timeout ตั้งไว้ 2 วินาที:

Timeout!
// เวลารวมที่ใช้จริง: 2.5 วินาที (รวมเวลาคอมไพล์) — ไม่ได้รอครบ 3

⚠️ timeout ไม่ได้ "หยุด" งานที่กำลังทำอยู่
goroutine ที่ส่ง resultCh ยังทำงานต่อไปจนครบ 3 วินาที — แค่ไม่มีใครรอฟังแล้ว Go ไม่มีทางฆ่า goroutine จากข้างนอกได้เลย
ถ้าต้องการให้มันหยุดจริง ต้องบอกมันแล้วให้มันหยุดเอง — นั่นคือหน้าที่ของ context ใน บทเรียน 25
ยิ่งไปกว่านั้น: ถ้า resultCh เป็น unbuffered (ท่อที่ไม่มีที่พักของ — ต้องมีคนยืนรออยู่ตรงนั้นพอดี ถึงจะส่งของเข้าไปได้) goroutine ตัวนั้นจะค้างตรง resultCh <- result ตลอดไป เพราะไม่มีใครมารับ = goroutine leak
ทางกัน: ทำ resultCh เป็น buffered ขนาด 1 ให้ส่งทิ้งไว้ได้แล้วจบตัวเอง

🛵 เทียบให้เห็นภาพ — สั่งข้าวเดลิเวอรี่แล้วรอไม่ไหว

สั่งข้าวไว้ รอ 30 นาทีแล้วยังไม่มา เราตัดใจเดินไปกินร้านข้าง ๆ — นั่นคือ timeout
แต่สังเกตว่าคนขับยังขี่รถมาส่งอยู่ดี การที่เราเลิกรอ ไม่ได้ทำให้เขาหายไปจากถนน
เราหยุดรอได้ แต่เราสั่งให้คนอื่นหยุดทำงานไม่ได้ — Go ก็เหมือนกันเป๊ะ ๆ

ทีนี้พอเขามาถึงหน้าบ้าน แล้วไม่มีใครอยู่รับ:
· ไม่มีที่ให้วางของ (unbuffered) → เขายืนถือถุงรอหน้าประตูตลอดไป กลับก็ไม่ได้ ส่งก็ไม่ได้ = goroutine leak
· มีตะกร้าฝากของหน้าบ้าน 1 ใบ (make(chan string, 1)) → วางถุงลงตะกร้า แล้วขี่รถกลับได้ทันที งานของเขาจบ

ที่ไม่เหมือนกัน: คนขับจริง ๆ ยืนรอสักพักก็เลิกเอง แต่ goroutine ไม่มีคำว่าเลิกเอง — ค้างอยู่จนโปรแกรมปิด และกินหน่วยความจำเงียบ ๆ ตลอดเวลานั้น

🗣 ศัพท์ที่เพิ่งเรียนไป
timeout
“ตั้งเวลาไว้ว่าจะรอนานสุดเท่าไหร่ เกินนั้นเลิกรอ” · พูดว่า “ตรงนี้มี timeout กี่วินาที” เป็นคำถามมาตรฐานเวลารีวิวโค้ดที่เรียกของนอก
buffered / unbuffered channel
ท่อที่มีที่พักของ กับท่อที่ไม่มี · เวลาถามให้พูดว่า “channel นี้ buffered หรือ unbuffered” จะสื่อกว่าถามว่า “ตัวนี้คืออะไร” · ตามด้วย “buffer ขนาดเท่าไหร่” ได้เลย
goroutine leak
goroutine ที่ไม่มีวันจบ ไม่ว่าจะด้วยเหตุใดก็ตาม — ส่งของแล้วไม่มีใครรับ (เคสของบทนี้) หรือรอของแล้วไม่มีใครส่งและไม่มีใคร close ซึ่งเจอบ่อยกว่าอีก · ไม่ทำให้พังทันที แต่สะสมไปเรื่อย ๆ · ประโยคที่เอาไปใช้ได้: “ตรงนี้ถ้า timeout ก่อน goroutine ตัวนั้น leak ไหม”
cancellation
“การบอกให้เลิกทำงาน” — timeout กับ cancellation ไม่ใช่คนละเรื่องกัน อยู่ที่ว่าทำด้วยอะไร · time.After แบบในหัวข้อนี้ = เราเลิกรอเฉย ๆ อีกฝั่งไม่รู้เรื่อง ยังทำงานต่อ · ส่วน context.WithTimeout = timeout ที่เป็น cancellation จริง เพราะพอครบเวลา ctx.Done() ปิดเอง อีกฝั่งเห็นแล้วหยุดเองได้ และส่งต่อลงไปทั้งสาย · เครื่องมือของ Go สำหรับเรื่องนี้คือ context ในบทเรียน 25

ทดสอบความเข้าใจ

1. ถ้า select มี 2 case ที่พร้อมทำงานพร้อมกัน จะเลือกอันไหน?

ผมทดสอบ 1000 รอบ 3 ครั้ง ได้ประมาณ 485/515 · 478/522 · 512/488 — สุ่มชัดเจน
ออกแบบมาเพื่อกัน starvation: ถ้าเลือกตัวบนเสมอ channel ที่มีของตลอดจะกิน case แรกจน case อื่นไม่ได้ทำงานเลย
อย่าเขียนโค้ดที่พึ่งลำดับ case

2. select ที่มี default แต่ไม่มี case ไหนพร้อม จะเกิดอะไร?

default เปลี่ยน select จาก blocking เป็น non-blocking — ไม่มีใครพร้อมก็ไปต่อทันที
ประโยชน์: "ส่งได้ก็ส่ง ส่งไม่ได้ก็ข้าม" โดยไม่ทำให้ระบบหลักหยุดรอ
โทษ: ถ้าอยู่ใน for เปล่า ๆ จะกลายเป็น busy loop กิน CPU เต็ม core

3. for { select { case <-done: return; default: } } มีปัญหาอะไร?

โปรแกรมทำงานถูกต้อง — ออกจาก loop ได้ปกติเมื่อ done ปิด แต่ระหว่างรอมันวนเปล่า ๆ เกือบ 96 ล้านรอบใน 0.3 วินาที
อันตรายเพราะไม่มี error ไม่มี log — เห็นแค่ CPU 100% แล้วหาสาเหตุไม่เจอ
เอา default ออก → select จะ block ซึ่งไม่กิน CPU เลย

4. หลัง case <-time.After(2*time.Second) ทำงานแล้ว goroutine ที่ทำงานอยู่เป็นยังไง?

Go ไม่มีวิธีฆ่า goroutine จากข้างนอกเลย — timeout แค่ทำให้ คนรอ เลิกรอ ไม่ได้แตะตัวคนทำ
แย่กว่านั้น: ถ้า channel เป็น unbuffered ตัวที่ทำงานเสร็จจะค้างที่ ch <- result ตลอดไปเพราะไม่มีใครรับ = goroutine leak
ทางแก้: buffered ขนาด 1 · และใช้ context (บทเรียน 25) เพื่อบอกให้มันหยุดเอง

🛠️ ภารกิจ — เรียก API สองเจ้า เอาคนที่เร็วกว่า
  1. เขียน fetchFrom(name string, delay time.Duration, out chan<- string) ที่ Sleep ตาม delay แล้วส่ง "ผลจาก " + name เข้า channel
  2. ใน main ยิงสองเจ้าพร้อมกัน (เจ้า A 500ms, เจ้า B 800ms) ลง channel คนละตัว แล้วใช้ select รับคนที่มาก่อน — จับเวลารวมด้วย
  3. เพิ่ม case <-time.After(300*time.Millisecond) เข้าไป → รันใหม่ ได้อะไร?
  4. เปลี่ยน timeout เป็น 600ms → ได้ผลจากเจ้าไหน?
  5. ท้าทาย 1 — จับ goroutine leak: ตั้ง timeout 300ms แล้วเพิ่ม time.Sleep(2*time.Second) ท้าย main พร้อมพิมพ์ runtime.NumGoroutine() ก่อน select · หลัง select · และท้ายสุด
    ตัวเลขบอกอะไร?
  6. ท้าทาย 2 — แก้ leak: เปลี่ยน channel เป็น make(chan string, 1) แล้วทำท้าทาย 1 ซ้ำ — ตัวเลขเปลี่ยนไปยังไง ทำไม?
  7. ท้าทาย 3 — busy loop ของจริง: เขียน for ที่มี select + default ว่าง ๆ รอ 2 วินาที แล้วเปิด Activity Monitor (หรือ top) ดู CPU
    จากนั้นเอา default ออก แล้วดูอีกครั้ง

ผมรันทั้งสองแบบต่อกันในโปรแกรมเดียว ได้แบบนี้เป๊ะ ๆ:

--- unbuffered ---
ก่อน select : 1
Timeout!
หลัง select : 3
หลังรอ 2 วิ  : 3   ← ไม่ลดเลย

--- buffered 1 ---
ก่อน select : 3
Timeout!
หลัง select : 5
หลังรอ 2 วิ  : 3   ← ลดลง 2 ตัว

ท้าทาย 1: รอบ unbuffered ตัวเลขค้างที่ 3 ตลอด — goroutine สองตัวติดอยู่ที่ out <- ... รอคนรับที่ไม่มีวันมา ตายไม่ได้ ทำงานต่อก็ไม่ได้

ท้าทาย 2: buffer ขนาด 1 ทำให้ส่งทิ้งไว้แล้วจบตัวเองได้ → 5 ลดกลับมาเหลือ 3
สังเกตให้ดี: ลดมาถึงแค่ 3 ไม่ใช่ 1 — เพราะสองตัวที่ leak จากรอบแรกยังค้างอยู่จนโปรแกรมจบ นี่คือหน้าตาของ leak ที่สะสมไปเรื่อย ๆ ในระบบจริง
กฎ: channel ที่ "ยิงแล้วอาจไม่มีคนรับ" ควรมี buffer อย่างน้อย 1 เสมอ

💬 ท้าทาย 1–2 คือหัวใจของบทนี้ — goroutine leak ไม่ทำให้โปรแกรมพัง แต่ทำให้ memory ค่อย ๆ โตจนล่มตอนตีสาม ส่งตัวเลข NumGoroutine() ที่ได้มาให้ดูได้เลย
บทถัดไปเปลี่ยนโหมด: จากการ "ส่งของหากัน" เป็นการ "แย่งกันแก้ของก้อนเดียว" — และทำไมมันถึงพังแบบที่คุณคาดไม่ถึง

อ้างอิง