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
เวลายืนรอลิฟต์ในห้าง เราไม่ได้จ้องอยู่ตัวเดียวแล้วรอจนตัวนั้นเปิด
เรายืนตรงกลาง มองทุกตัวพร้อมกัน ตัวไหนเปิดก่อน ก็เดินเข้าตัวนั้น
ถ้าไปยืนจ้องตัวซ้ายสุดตัวเดียว แล้วตัวขวาเปิดก่อน — เราก็ยืนรอต่อไปเปล่า ๆ
นั่นคือ 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)
เพื่อบอกให้มันหยุดเอง
- เขียน
fetchFrom(name string, delay time.Duration, out chan<- string)ที่Sleepตามdelayแล้วส่ง"ผลจาก " + nameเข้า channel - ใน
mainยิงสองเจ้าพร้อมกัน (เจ้า A 500ms, เจ้า B 800ms) ลง channel คนละตัว แล้วใช้selectรับคนที่มาก่อน — จับเวลารวมด้วย - เพิ่ม
case <-time.After(300*time.Millisecond)เข้าไป → รันใหม่ ได้อะไร? - เปลี่ยน timeout เป็น 600ms → ได้ผลจากเจ้าไหน?
- ท้าทาย 1 — จับ goroutine leak: ตั้ง timeout 300ms
แล้วเพิ่ม
time.Sleep(2*time.Second)ท้ายmainพร้อมพิมพ์runtime.NumGoroutine()ก่อน select · หลัง select · และท้ายสุด
ตัวเลขบอกอะไร? - ท้าทาย 2 — แก้ leak: เปลี่ยน channel เป็น
make(chan string, 1)แล้วทำท้าทาย 1 ซ้ำ — ตัวเลขเปลี่ยนไปยังไง ทำไม? - ท้าทาย 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() ที่ได้มาให้ดูได้เลย
บทถัดไปเปลี่ยนโหมด: จากการ "ส่งของหากัน" เป็นการ
"แย่งกันแก้ของก้อนเดียว" — และทำไมมันถึงพังแบบที่คุณคาดไม่ถึง
อ้างอิง
- A Tour of Go — Select · Default Selection
- Go by Example — Select · Timeouts · Non-Blocking Channel Operations
- Go Spec — Select statements · ระบุเรื่องการสุ่มเลือกไว้ตรง ๆ
- pkg.go.dev — time.After