Day 7: select 在多個Channel之間做出選擇

前言

Day 6,我們學會了如何使用 close()for...range 來優雅地處理來自單一 Channel 的數據流。這解決了生產者-消費者模型中的一個核心問題。但是,如果一個 Goroutine 需要同時應對多個 Channel 呢?

想像一個更複雜的場景:一個 Worker Goroutine 不僅要從任務 Channel (tasks) 接收工作,還要同時監聽一個來自系統的停止信號 Channel (stopSignal)。如果它傻傻地先 <-tasks,它可能會永遠阻塞在那裡,而錯過了重要的停止信號。

這時,我們就需要一個更強大的工具,一個能讓我們在並行的十字路口做出選擇的工具。它就是 Go 語言的 select 陳述式。

一個比喻:客服中心的電話總機

你可以把 select 想像成一個多功能電話總機。你是一位客服人員,面前有兩條電話線:

  • 電話線 A:客戶來電,需要你處理任務。
  • 電話線 B:老闆來電,通知你緊急下班。

你的工作方式是:

  1. 你戴著耳機,同時監聽這兩條電話線。
  2. 哪條線先響,你就接哪條。如果客戶先來電,你就處理客戶問題;如果老闆先來電,你就立刻收拾東西走人。
  3. 你不會只盯著一條線而忽略另一條。這個同時監聽、響應最先到達事件的機制,就是 select 的核心。

select 的基本語法

select 的語法和 switch 非常相似,但它的 case 陳述句中必須是一個 Channel 的操作(發送或接收)。

select {
case v := <-channel1:
    // channel1 接收到值 v
    // ... 處理 v
case channel2 <- x:
    // 成功將 x 發送到 channel2
    // ...
case <-channel3:
    // channel3 接收到值(但我們忽略了它)
    // ...
default:
    // 如果上面所有的 case 都沒有準備好,則執行這裡
    // (這是一個非阻塞的 select)
}

select 的工作規則:

  1. select阻塞,直到其中一個 case 的 Channel 操作可以被執行(即可以接收或可以發送)。
  2. 如果有多個 case 同時準備就緒,select隨機選擇一個來執行。這種隨機性是為了保證公平,防止某個 Channel 一直被優先處理。
  3. 如果沒有任何 case 準備好,且存在 default 子句,那麼 select不會阻塞,而是直接執行 default 的內容。

實戰一:合併兩個 Channel 的資料

假設我們有兩個 Goroutine 都在產生資料,我們希望在主 Goroutine 中將它們的資料合併起來處理。

package main

import (
	"fmt"
	"time"
)

func producer(ch chan<- string, name string, delay time.Duration) {
	for i := 1; ; i++ {
		ch <- fmt.Sprintf("From %s: Message %d", name, i)
		time.Sleep(delay)
	}
}

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	go producer(ch1, "Producer 1", 500*time.Millisecond)
	go producer(ch2, "Producer 2", 1*time.Second)

	// 使用 for + select 來不斷接收來自任一 channel 的消息
	for range 10{ // 為了讓範例能結束,我們只接收10次
		select {
		case msg1 := <-ch1:
			fmt.Println("Received:", msg1)
		case msg2 := <-ch2:
			fmt.Println("Received:", msg2)
		}
	}

	fmt.Println("Main: Finished receiving messages.")
}

輸出可能如下:

Received: From Producer 1: Message 1
Received: From Producer 1: Message 2
Received: From Producer 2: Message 1
Received: From Producer 1: Message 3
Received: From Producer 1: Message 4
Received: From Producer 2: Message 2
...

你可以看到,main 函式中的 select 總是會處理那個先準備好的 Channel,因為 Producer 1 的發送頻率更高,所以我們更頻繁地從 ch1 接收到消息。

實戰二:select 與超時 (Timeout) 控制

這是 select 最經典、最有用的場景之一。假設我們呼叫一個遠程服務,我們不希望無限期地等待它的回應。

time 套件提供了一個非常有用的函式:time.After(duration)。它會回傳一個 Channel (<-chan Time),這個 Channel 會在指定的 duration 時間後,接收到一個時間值。

package main

import (
	"fmt"
	"time"
)

func longRunningTask(resultChan chan<- string) {
	// 模擬一個需要 3 秒才能完成的任務
	time.Sleep(3 * time.Second)
	resultChan <- "Task finished successfully!"
}

func main() {
	result := make(chan string)
	go longRunningTask(result)

	select {
	case res := <-result:
		fmt.Println(res)
	case <-time.After(2 * time.Second): // 設定一個 2 秒的超時
		fmt.Println("Timeout! The task took too long.")
	}
}

執行結果:

Timeout! The task took too long.

程式碼解析:

  1. select 同時監聽 result Channel 和 time.After 產生的 Channel。
  2. 我們的任務需要 3 秒,但我們的超時設定為 2 秒。
  3. 在 2 秒鐘的時候,time.After 的 Channel 會先準備好(接收到一個時間值),於是 select 選擇了超時的 case 來執行。
  4. 如果我們把超時時間改為 4 秒,那麼 result Channel 會先準備好,程式就會印出成功訊息。

這個模式非常強大,它讓我們能輕易地為任何阻塞操作加上超時保護,防止 Goroutine 被永久卡住。

今日總結

今天我們學習了 Go 併發編程中一個極其重要的控制結構 select

  1. select 讓我們能夠同時等待多個 Channel 操作
  2. 它的工作方式是阻塞直到某個 case 就緒,如果有多個就緒則隨機選擇一個。
  3. 我們透過 time.After 函式與 select 結合,學會了如何實現超時控制,這是在編寫健壯的網路或 I/O 程式時的必備技巧。
  4. 我們也提到了 default 子句,可以用來實現非阻塞的 Channel 操作。

我們對 Channel 的基本操作已經相當熟悉了。但是,在函式傳遞 Channel 時,我們有沒有辦法對 Channel 的「方向」做出限制呢?例如,一個函式只應該向 Channel 發送資料,而不應該從中讀取。有沒有辦法在程式碼層級就做出這種保證,讓程式更安全、意圖更清晰?

預告 Day 8: 用單向Channel,打造更穩健的API。我們將探討如何利用 Golang 的型別系統來增強我們併發程式的安全性。