Select
คำสั่ง select
หัวข้อที่มีชื่อว่า “คำสั่ง select”select เป็นวิธีของ Go ในการรอหลาย channel operations พร้อมกัน โดยบล็อกจนกว่า case หนึ่งพร้อม แล้วรัน case นั้น ถ้าหลาย case พร้อมในเวลาเดียวกัน จะเลือกหนึ่งแบบ สุ่ม — นี่คือการออกแบบภาษาโดยตั้งใจเพื่อหลีกเลี่ยง starvation
select {case v := <-ch1: fmt.Println("received from ch1:", v)case ch2 <- 99: fmt.Println("sent to ch2")}select case สามารถเป็น send หรือ receive — คำสั่งไหนก็ได้ที่ดำเนินต่อได้โดยไม่บล็อก
Default Clause
หัวข้อที่มีชื่อว่า “Default Clause”การเพิ่ม default case ทำให้ select เป็น non-blocking ถ้าไม่มี channel พร้อม branch default จะรันทันที:
select {case v := <-ch: fmt.Println("got", v)default: fmt.Println("nothing ready yet")}นี่คือวิธี idiomatic ในการ poll channel โดยไม่บล็อก
flowchart TD
Start["select { ... }"] --> Ready{"Any case ready?"}
Ready -->|"exactly one ready"| Run["run that case"]
Ready -->|"multiple ready"| Rand["pick one at random"]
Ready -->|"none ready"| HasDefault{"default present?"}
HasDefault -->|yes| Def["run default immediately"]
HasDefault -->|no| Block["block the goroutine until a case becomes ready"] Default Clause แบบ Busy-Spin (Anti-Pattern)
หัวข้อที่มีชื่อว่า “Default Clause แบบ Busy-Spin (Anti-Pattern)”default case น่าดึงดูดเวลาที่คุณอยากจะ “เช็กแล้วไปต่อ” แต่การห่อมันไว้ใน bare for loop เปลี่ยนการเช็กแบบ non-blocking ที่ถูก ให้กลายเป็น busy-spin ที่เผา CPU:
for { select { case v := <-ch: process(v) default: // nothing ready — loop right back around and check again }}ไม่มีอะไรมาชะลอมันเลย loop นี้ประเมิน select ซ้ำหลายล้านครั้งต่อวินาที ตรึง CPU core หนึ่งไว้ที่ 100% แม้ ch จะว่างเปล่าอยู่ก็ตาม แทบไม่มีสถานการณ์จริงที่คุณต้องการแบบนี้เลย มีสองวิธีแก้ที่ครอบคลุมเกือบทุกกรณีจริง: ถ้าคุณกำลัง poll ตามตารางเวลา ให้ใช้ time.Ticker แล้วเพิ่ม channel ของมันเป็น select case จริง (เอา default และ loop ที่เขียนเองออกไปทั้งหมด); ถ้าคุณกำลังรอค่าถัดไปโดยไม่มี deadline ให้ตัด default ออกไปเลยแล้วปล่อยให้ select block — นั่นคือสิ่งที่มันถูกออกแบบมาให้ทำ และไม่มีต้นทุนอะไรเลยตอนที่ไม่มีงาน
Timeouts
หัวข้อที่มีชื่อว่า “Timeouts”Pattern ทั่วไปคือการรวม select กับ time.After ซึ่ง return channel ที่รับค่าหลังจาก duration ที่กำหนด:
select {case result := <-work: fmt.Println("done:", result)case <-time.After(2 * time.Second): fmt.Println("timed out")}ใช้ context.WithTimeout (สอนในบท Context) เมื่อ deadline ต้อง propagate ไปยัง callee; time.After ใช้ได้ดีสำหรับ timeout แบบ local ครั้งเดียว — ตั้งแต่ Go 1.23 timer ที่ไม่มีใครอ้างอิงถึงจะถูก garbage collect แม้จะไม่เคยเรียก Stop
time.After ข้างใน Loop
หัวข้อที่มีชื่อว่า “time.After ข้างใน Loop”การที่ GC เก็บ timer ที่ไม่ได้ Stop ให้เอง (ตั้งแต่ Go 1.23) แก้ memory leak แบบเก่าได้ก็จริง แต่ไม่ได้ทำให้ time.After ฟรี ทุกครั้งที่เรียกยัง allocate time.Timer ใหม่และ channel ใหม่เสมอ — ใช้ได้ดีสำหรับ timeout ครั้งเดียว แต่แพงเมื่อเรียกทุกรอบของ hot loop:
for { select { case v := <-ch: process(v) case <-time.After(time.Second): // allocates a new timer every single iteration fmt.Println("no value this second") }}ข้างใน loop ให้สร้าง timer แค่ครั้งเดียวด้วย time.NewTimer แล้วเรียก Reset หลังจากแต่ละรอบแทน — allocate แค่ครั้งเดียวรวม ไม่ใช่ทุกรอบ:
t := time.NewTimer(time.Second)defer t.Stop()for { select { case v := <-ch: process(v) case <-t.C: fmt.Println("no value this second") } if !t.Stop() { <-t.C // drain a timer that fired between the select and the Stop call } t.Reset(time.Second)}ปิดการใช้งาน Case ด้วย Nil Channel
หัวข้อที่มีชื่อว่า “ปิดการใช้งาน Case ด้วย Nil Channel”การ assign nil ให้ channel variable ที่ select case อ่านอยู่จะปิดการใช้งาน case นั้น ถาวร — nil channel ไม่มีวันพร้อมเลย ดังนั้น select จะไม่มีวันเลือกมันอีก ไม่ว่า loop จะรันอีกกี่รอบก็ตาม นี่คือวิธีมาตรฐานในการหยุดไม่ให้ select เลือก channel ที่ให้ทุกอย่างที่มันจะให้ไปแล้วซ้ำ ๆ
ตัวอย่างที่ชัดที่สุดคือการดึงข้อมูลจากสอง channel จนกว่าทั้งคู่จะถูก close case receive ของ channel ที่ close แล้วจะพร้อมทันทีเสมอ (ด้วย ok == false) ดังนั้นถ้าไม่ปิดการใช้งานมัน loop จะ spin อยู่บน channel ที่ดึงหมดแล้วแทนที่จะรอ channel ที่ยังผลิตค่าอยู่:
for a != nil || b != nil { select { case v, ok := <-a: if !ok { a = nil // disable: this case can never fire again continue } sum += v case v, ok := <-b: if !ok { b = nil // disable: this case can never fire again continue } sum += v }}Loop condition a != nil || b != nil และการ nil-out ข้างในแต่ละ branch !ok รวมกันการันตีว่า loop จะ exit พอดีตอนที่ channel ทั้งคู่ถูกดึงหมดและ close แล้ว — ไม่ spin บน channel ที่ close แล้วเลย ไม่ exit ก่อนเวลาเลย
ctx.Done() ในทุก Loop ที่รันนาน
หัวข้อที่มีชื่อว่า “ctx.Done() ในทุก Loop ที่รันนาน”select ใด ๆ ข้างใน loop ที่อาจรันสักพักควรมี case <-ctx.Done() เหมือนกับที่ pattern ด้านบนปิดการใช้งาน channel ที่ดึงหมดแล้ว ถ้าไม่มี loop อาจ consume จาก work channel ต่อไปนานหลังจาก request ที่ล้อมมันถูกยกเลิกไปแล้ว — งานที่เสียเปล่าที่ไม่มีใครรออยู่:
for { select { case v, ok := <-work: if !ok { return } process(v) case <-ctx.Done(): return ctx.Err() // stop promptly instead of draining everything first }}บท Context สอน ctx.Done() และ ctx.Err() แบบเต็ม ๆ กฎจากมุมมองของ select ก็แค่: ถ้า loop มีชีวิตอยู่นานกว่า operation เดียวที่รวดเร็ว ให้การยกเลิกมี case ของตัวเอง
Runnable Example
หัวข้อที่มีชื่อว่า “Runnable Example”Snippet ด้านล่างสาธิตพฤติกรรมทั้งสามของ select ตามลำดับ — แบบ deterministic โดยใช้ select แยกกันกับ buffered channel ที่โหลดค่าไว้ล่วงหน้า ตามด้วยตัวอย่าง default branch
package main
import "fmt"
func main() { // Pre-load two buffered channels so both are ready ch1 := make(chan string, 1) ch2 := make(chan string, 1) ch1 <- "one" ch2 <- "two"
// Drain ch1 with its own select — guaranteed to receive "one" select { case msg := <-ch1: fmt.Println("ch1:", msg) }
// Drain ch2 with its own select — guaranteed to receive "two" select { case msg := <-ch2: fmt.Println("ch2:", msg) }
// default makes select non-blocking ch3 := make(chan int) select { case v := <-ch3: fmt.Println("received", v) default: fmt.Println("no value ready, default taken") }
// Fan-in: select from whichever of two channels has a value // (use separate selects for deterministic playground output) r1 := make(chan int, 1) r2 := make(chan int, 1) r1 <- 10 r2 <- 20 select { case v := <-r1: fmt.Println("r1:", v) } select { case v := <-r2: fmt.Println("r2:", v) }
fmt.Println("done")} Snippet ที่สองด้านล่างครอบคลุมสอง pattern ที่เพิ่งแนะนำไป: การ nil-out channel ที่ดึงหมดแล้วเพื่อให้ select เลิกเลือกมัน และให้ context ที่ถูกยกเลิกมี case ของตัวเองเพื่อให้ loop หยุดทันทีแทนที่จะดึงทุกอย่างที่เหลือก่อน
package main
import ( "context" "fmt")
func main() { // Nil-out to disable a case: once a channel is drained and closed, set // the local variable to nil so its select case can never fire again -- // otherwise a closed channel's case is always immediately ready (with // ok == false), spinning the loop on it forever. a := make(chan int, 2) b := make(chan int, 2) a <- 1 a <- 2 close(a) b <- 10 b <- 20 close(b)
sum := 0 for a != nil || b != nil { select { case v, ok := <-a: if !ok { a = nil // disable: this case never fires again continue } sum += v case v, ok := <-b: if !ok { b = nil // disable: this case never fires again continue } sum += v } } fmt.Println("sum:", sum) // deterministic regardless of interleaving order
// ctx.Done() inside a loop: exit promptly on cancellation instead of // draining every remaining item first. ctx, cancel := context.WithCancel(context.Background()) work := make(chan int) // deliberately never sent to cancel() // cancel before the loop starts processed := 0loop: for { select { case <-work: processed++ case <-ctx.Done(): fmt.Println("stopped early:", ctx.Err()) break loop } } fmt.Println("processed before stopping:", processed)} ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| select over multiple channels | handle หลาย event source พร้อมกัน | random selection เมื่อหลาย case ready |
| default case | non-blocking check | loop tight ทำให้ CPU spin |
| select กับ context.Done() | cancellation ง่าย | ต้องใส่ทุก select ที่อาจรันนาน |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- select เหมือน switch — select เลือกแบบ random ถ้าหลาย case ready — ไม่ใช่ first-match
- default ใน select ทำให้เร็วขึ้น — default loop แบบ tight = CPU spinning = ใช้ CPU สูง
- time.After leak เพราะสร้าง goroutine ใหม่ทุกครั้ง — timer ไม่เคย spawn goroutine เลย ก่อน Go 1.23 timer ที่ไม่เรียก
Stopจะค้างอยู่ใน heap จนกว่าจะยิง (นี่คือ leak จริง — เป็น timer object ไม่ใช่ goroutine); ตั้งแต่ 1.23 GC เก็บให้เองแม้ไม่ Stop แต่ใน hot loop การเรียกtime.Afterซ้ำ ๆ ยัง allocate timer object ใหม่ทุกรอบอยู่ดี — ใช้time.NewTimer+Resetแทน
select และเหตุผลที่มัน Deadlock main
หัวข้อที่มีชื่อว่า “select และเหตุผลที่มัน Deadlock main”select ที่ไม่มี case เลย — select {} — ไม่มีทางมี case ที่พร้อมได้ โดยโครงสร้างของมันเอง ดังนั้นมันจะ block goroutine ปัจจุบันตลอดไป ถ้า goroutine นั้นคือ main และไม่มี goroutine อื่นเหลือรันอยู่ นั่นคือ deadlock ทั้งโปรแกรมจริง ๆ และ runtime รายงานมันเหมือนกับ nil-channel deadlock จากบท Channels เป๊ะ:
func main() { select {}}fatal error: all goroutines are asleep - deadlock!
goroutine 1 [select (no cases)]:main.main() /tmp/selectempty/main.go:4 +0x1cexit status 2Tag [select (no cases)] ระบุสาเหตุตรง ๆ เหมือนกับที่ [chan receive (nil chan)] ทำสำหรับ nil channel select {} บางครั้งใช้โดย ตั้งใจ เพื่อให้ main มีชีวิตอยู่ตลอดไป — ทางเลือกที่เบากว่า for {} — แต่จะได้ผลก็ต่อเมื่อมี goroutine อื่นกำลังทำงานที่มีประโยชน์อยู่เบื้องหลังจริง ๆ (เช่น goroutine ของ listener ใน HTTP server) ถ้าใช้โดยไม่มีอะไรอื่นรันอยู่เลย มันแยกไม่ออกจาก fatal error นี้เลย
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อโค้ดที่ AI สร้างมาใช้ select ข้างใน loop ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:
caseข้างในselectที่อยู่ในforใช้breakเปล่า ๆ โดยหวังว่ามันจะออกจาก loop ไหม? มันออกได้แค่จากselectเท่านั้น —forยังทำงานต่อ- มี
defaultcase ข้างใน loop โดยไม่มีอะไรมาชะลอมันเลยไหม? นั่นคือ busy-spin ที่เผา CPU core เต็มตัว - loop เรียก
time.After(d)เป็นselectcase ทุกรอบแทนที่จะสร้างtime.NewTimerตัวเดียวก่อน loop แล้วเรียกResetหลังแต่ละรอบไหม? - มี
case <-ctx.Done()ในทุกselectที่อยู่ใน loop ที่อาจรันสักพักไหม — หรือ loop อาจ consume งานต่อไปได้แม้ caller จะยกเลิกไปแล้ว? - channel ที่ควรเลิกถูกเลือก (ดึงหมดและ close แล้ว) ถูก nil-out หรือเปล่า หรือ case
ok == falseที่พร้อมตลอดของมันจะทำให้ loop spin ตลอดไป?
🐛 หา bug ในโค้ดนี้
for { select { case <-ctx.Done(): fmt.Println("stopping") break // intended to exit the for loop case v := <-work: process(v) }}เฉลย
break ข้างใน select case ออกได้แค่จาก select statement เท่านั้น — select เป็นหนึ่งใน construct (พร้อมกับ switch) ที่ break เปล่า ๆ เล็งไปที่มันก่อนที่จะไปถึง for ที่ล้อมอยู่ เมื่อ ctx ถูกยกเลิกไปแล้ว case ของ ctx.Done() จะพร้อมตลอดไป loop จึงกลับเข้า select ทันที ชน case เดิม print "stopping" อีกครั้ง แล้ววนซ้ำ — busy-spin ที่ไม่มีวันหยุดจริง ๆ output จริงที่จับได้จากโปรแกรมเวอร์ชันที่เพิ่มตัวนับความปลอดภัยไว้แค่เพื่อสังเกตพฤติกรรม:
stoppingstoppingstoppingstoppingวิธีแก้คือ labeled break ที่เรียกชื่อ for loop ตรง ๆ: loop: for { select { case <-ctx.Done(): return /* or: break loop */ ... } } การ return จาก function ที่ล้อมอยู่ (เมื่อทำได้) หลีกเลี่ยงความกำกวมนี้ไปเลย นี่คือเหตุผลที่ตัวอย่างที่รันได้ด้านบนใช้ return หรือ labeled break loop