ข้ามไปยังเนื้อหา

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 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"]
กระบวนการตัดสินใจของ select: สุ่มเลือกจาก case ที่พร้อม, ใช้ default เฉพาะเมื่อไม่มีอะไรพร้อม, block เฉพาะเมื่อไม่มี default

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 — นั่นคือสิ่งที่มันถูกออกแบบมาให้ทำ และไม่มีต้นทุนอะไรเลยตอนที่ไม่มีงาน

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

การที่ 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)
}

การ 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 ก่อนเวลาเลย

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 ของตัวเอง

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 := 0
loop:
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 channelshandle หลาย event source พร้อมกันrandom selection เมื่อหลาย case ready
default casenon-blocking checkloop 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 ที่ไม่มี 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 +0x1c
exit status 2

Tag [select (no cases)] ระบุสาเหตุตรง ๆ เหมือนกับที่ [chan receive (nil chan)] ทำสำหรับ nil channel select {} บางครั้งใช้โดย ตั้งใจ เพื่อให้ main มีชีวิตอยู่ตลอดไป — ทางเลือกที่เบากว่า for {} — แต่จะได้ผลก็ต่อเมื่อมี goroutine อื่นกำลังทำงานที่มีประโยชน์อยู่เบื้องหลังจริง ๆ (เช่น goroutine ของ listener ใน HTTP server) ถ้าใช้โดยไม่มีอะไรอื่นรันอยู่เลย มันแยกไม่ออกจาก fatal error นี้เลย

เมื่อโค้ดที่ AI สร้างมาใช้ select ข้างใน loop ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:

  • case ข้างใน select ที่อยู่ใน for ใช้ break เปล่า ๆ โดยหวังว่ามันจะออกจาก loop ไหม? มันออกได้แค่จาก select เท่านั้น — for ยังทำงานต่อ
  • มี default case ข้างใน loop โดยไม่มีอะไรมาชะลอมันเลยไหม? นั่นคือ busy-spin ที่เผา CPU core เต็มตัว
  • loop เรียก time.After(d) เป็น select case ทุกรอบแทนที่จะสร้าง 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 จริงที่จับได้จากโปรแกรมเวอร์ชันที่เพิ่มตัวนับความปลอดภัยไว้แค่เพื่อสังเกตพฤติกรรม:

stopping
stopping
stopping
stopping

วิธีแก้คือ labeled break ที่เรียกชื่อ for loop ตรง ๆ: loop: for { select { case <-ctx.Done(): return /* or: break loop */ ... } } การ return จาก function ที่ล้อมอยู่ (เมื่อทำได้) หลีกเลี่ยงความกำกวมนี้ไปเลย นี่คือเหตุผลที่ตัวอย่างที่รันได้ด้านบนใช้ return หรือ labeled break loop

Go ทำอะไรเมื่อหลาย case ใน select พร้อมในเวลาเดียวกัน?
การเพิ่ม default case ใน select มีผลอย่างไร?
time.After(d) return อะไร?
อะไรกันไม่ให้ loop นี้ spin ตลอดไปบน channel a ที่ close แล้วและว่างเปล่า?
a, b := make(chan int, 1), make(chan int, 1)
close(a)
for a != nil || b != nil {
    select {
    case _, ok := <-a:
        if !ok { a = nil }
    case v := <-b:
        fmt.Println(v)
    }
}
สมมติว่า ctx ถูกยกเลิกไปแล้ว เกิดอะไรขึ้น?
for {
    select {
    case <-ctx.Done():
        fmt.Println("stopping")
        break
    case v := <-work:
        process(v)
    }
}
เกิดอะไรขึ้นเมื่อรัน โดยไม่มี goroutine อื่นเริ่มไว้เลย?
func main() {
    select {}
}