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 的測試核心是避免所有邏輯都只能透過無限迴圈測。把單次工作抽成 SyncOnceProcessOneCleanupOnce,可以讓規則測試和 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 情境下保持可預測。