select loop 的核心責任是管理長時間 goroutine 的生命週期。它不只是等待多個 channel 的語法,而是決定元件如何接收輸入、處理定時任務、回應取消、釋放資源與停止。

本章目標

學完本章後,你將能夠:

  1. 拆解 select loop 中每個 case 的責任
  2. 用 ctx.Done() 設計一致的退出路徑
  3. 正確建立與停止 ticker
  4. 處理 channel 關閉後的 nil channel pattern
  5. 測試 worker 在事件、ticker、取消下的行為

【觀察】長期 goroutine 通常同時等待多種訊號

長期 goroutine 的核心特徵是它不只處理一種資料。背景 worker 可能同時等待外部事件、定時掃描、清理工作與停止訊號。

 1func (w Worker) Run(ctx context.Context) error {
 2    statusTicker := time.NewTicker(w.statusInterval)
 3    defer statusTicker.Stop()
 4
 5    cleanupTicker := time.NewTicker(w.cleanupInterval)
 6    defer cleanupTicker.Stop()
 7
 8    for {
 9        select {
10        case <-ctx.Done():
11            return ctx.Err()
12        case event, ok := <-w.events:
13            if !ok {
14                return nil
15            }
16            w.processEvent(ctx, event)
17        case <-statusTicker.C:
18            w.scanStatus(ctx)
19        case <-cleanupTicker.C:
20            w.cleanup(ctx)
21        }
22    }
23}

這個 loop 的責任不是「跑一個無限迴圈」,而是定義 worker 活著時能接受哪些訊號,以及停止時要如何退出。

【判讀】select loop 是元件的生命週期表

select loop 的核心價值是把元件生命週期寫成明確表格。每個 case 都應該能回答:收到什麼訊號、代表什麼意思、下一步做什麼。

case系統意義下一步
ctx.Done()上層要求停止回傳 context error
w.events收到外部事件套用處理流程
statusTicker.C到時間掃描狀態執行週期任務
cleanupTicker.C到時間清理暫存資料回收資源

若某個 case 的意義說不清楚,通常代表 worker 責任太多,或事件來源還沒有被整理成清楚的 channel。

【策略】每個長期 goroutine 先回答四個問題

Select loop 設計的核心檢查是生命週期,而不是語法。寫 loop 前先回答四個問題:

  1. 誰能停止它?
  2. 它消費哪些輸入?
  3. 它擁有哪些資源?
  4. 停止時要回報錯誤、靜默退出,還是交給上層判斷?

例如:

1type Worker struct {
2    events          <-chan Event
3    statusInterval  time.Duration
4    cleanupInterval time.Duration
5    processor       Processor
6}

Worker 消費 events,擁有兩個 ticker,停止訊號來自 context.Context。這些資訊應該能從型別與 Run 方法看出來,而不是藏在任意 goroutine 裡。

【執行】ticker 要由使用者停止

Ticker 的核心規則是建立者負責停止。time.NewTicker 會建立 runtime 資源;不再使用時應呼叫 Stop。

 1func (w Worker) Run(ctx context.Context) error {
 2    ticker := time.NewTicker(w.interval)
 3    defer ticker.Stop()
 4
 5    for {
 6        select {
 7        case <-ctx.Done():
 8            return ctx.Err()
 9        case <-ticker.C:
10            if err := w.SyncOnce(ctx); err != nil {
11                return err
12            }
13        }
14    }
15}

Ticker 放在 Run 裡建立,表示它的生命週期和 Run 一致。defer ticker.Stop() 讓 worker 不論因為 context、錯誤或 channel 關閉退出,都能釋放 ticker。

如果 ticker 由外部傳入,外部就應該負責停止。擁有權要一致,否則測試和 shutdown 都會變得模糊。

【執行】處理已關閉 channel 要避免忙等

已關閉 channel 的核心行為是讀取會立即回傳零值與 ok=false。在 select loop 裡,如果不處理這件事,loop 可能一直選到同一個已關閉 channel。

當一個輸入關閉後,還要繼續處理其他輸入,可以把它設成 nil:

 1func (w Worker) Run(ctx context.Context) error {
 2    events := w.events
 3    alerts := w.alerts
 4
 5    for events != nil || alerts != nil {
 6        select {
 7        case <-ctx.Done():
 8            return ctx.Err()
 9        case event, ok := <-events:
10            if !ok {
11                events = nil
12                continue
13            }
14            w.processEvent(ctx, event)
15        case alert, ok := <-alerts:
16            if !ok {
17                alerts = nil
18                continue
19            }
20            w.processAlert(ctx, alert)
21        }
22    }
23
24    return nil
25}

Nil channel 在 select 中永遠不會 ready。這讓 worker 能在某個來源關閉後繼續處理其他來源,而不是忙等或提早退出。

【判讀】default case 會改變 loop 的語意

default 的核心效果是讓 select 不等待。這在非阻塞送出很有用,但在長期 worker 的主 loop 中要小心,因為它可能造成 busy loop。

反模式:

 1for {
 2    select {
 3    case <-ctx.Done():
 4        return
 5    case event := <-events:
 6        process(event)
 7    default:
 8        cleanup()
 9    }
10}

當沒有事件時,這個 loop 會不停執行 cleanup(),可能吃滿 CPU。週期任務應該用 ticker 表達,不應用 default 假裝閒置時執行。

較清楚的做法:

 1cleanupTicker := time.NewTicker(time.Minute)
 2defer cleanupTicker.Stop()
 3
 4for {
 5    select {
 6    case <-ctx.Done():
 7        return
 8    case event := <-events:
 9        process(event)
10    case <-cleanupTicker.C:
11        cleanup()
12    }
13}

Ticker 讓頻率明確,也讓測試可以透過可控時間或手動觸發 channel 驗證行為。

【策略】長工作要移出主要 loop

Select loop 的核心風險是某個 case 裡的工作太久,導致其他訊號無法被處理。若 processEvent 可能執行很久,worker 在這段期間就不會回應 context 或 ticker。

可選策略:

策略適用情境代價
case 內同步執行工作短、需要順序處理慢事件會阻塞整個 loop
啟動 bounded worker pool工作可並行、需要限制併發需要排隊與 shutdown 設計
送入另一個 queue入口要快速接收需要 backpressure 策略

長工作需要 bounded worker pool、另一個 queue 或明確的同步策略。無限制地在 case 裡 go process(event) 只會把排隊問題從 channel 轉成 goroutine 堆積,並讓 shutdown 和錯誤回報更難處理。

【測試】把單次工作抽成方法

Select loop 的測試核心是避免所有邏輯都只能透過無限迴圈測。把單次工作抽成 SyncOnce、ProcessOne 或 CleanupOnce,可以讓規則測試和 lifecycle 測試分開。

1func (w Worker) SyncOnce(ctx context.Context) error {
2    return w.processor.Sync(ctx)
3}

Run 只負責排程:

1case <-ticker.C:
2    if err := w.SyncOnce(ctx); err != nil {
3        return err
4    }

單次工作測試:

 1func TestSyncOnceCallsProcessor(t *testing.T) {
 2    processor := &fakeProcessor{}
 3    worker := Worker{processor: processor}
 4
 5    if err := worker.SyncOnce(context.Background()); err != nil {
 6        t.Fatalf("sync once: %v", err)
 7    }
 8    if !processor.called {
 9        t.Fatalf("processor should be called")
10    }
11}

Lifecycle 測試則只確認 context 取消能讓 Run 退出:

 1func TestRunStopsOnContextCancel(t *testing.T) {
 2    ctx, cancel := context.WithCancel(context.Background())
 3    cancel()
 4
 5    worker := Worker{
 6        events:          make(chan Event),
 7        statusInterval:  time.Hour,
 8        cleanupInterval: time.Hour,
 9    }
10
11    if err := worker.Run(ctx); !errors.Is(err, context.Canceled) {
12        t.Fatalf("run error = %v, want context canceled", err)
13    }
14}

這種拆法讓測試不需要等待真實 ticker,也不需要在無限 loop 裡猜時間。

本章不處理

本章先把長生命週期 goroutine 的停止、輸入與排空講清楚;更完整的 worker 協調與平台排程責任,會在下列章節再往外延伸:

和 Go 教材的關係

這一章承接的是 goroutine、channel 與 shutdown loop;如果你要先回看語言教材,可以讀:

小結

select loop 是長期 goroutine 的生命週期表。好的 loop 會明確處理 context 取消、輸入 channel、ticker、資源釋放與 channel 關閉。避免在主 loop 中濫用 default 或無限制開 goroutine,才能讓服務在高流量、錯誤與 shutdown 情境下保持可預測。