8.7. 基於select的多路複用

下面的程序會進行火箭發射的倒計時。time.Tick函數返回一個channel,程序會週期性地像一個節拍器一樣向這個channel發送事件。每一個事件的值是一個時間戳,不過更有意思的是其傳送方式。

gopl.io/ch8/countdown1

func main() {
    fmt.Println("Commencing countdown.")
    tick := time.Tick(1 * time.Second)
    for countdown := 10; countdown > 0; countdown-- {
        fmt.Println(countdown)
        <-tick
    }
    launch()
}

現在我們讓這個程序支持在倒計時中,用戶按下return鍵時直接中斷髮射流程。首先,我們啟動一個goroutine,這個goroutine會嘗試從標準輸入中讀入一個單獨的byte並且,如果成功了,會向名為abort的channel發送一個值。

gopl.io/ch8/countdown2

abort := make(chan struct{})
go func() {
    os.Stdin.Read(make([]byte, 1)) // read a single byte
    abort <- struct{}{}
}()

現在每一次計數循環的迭代都需要等待兩個channel中的其中一個返回事件了:當一切正常時的ticker channel(就像NASA jorgon的"nominal",譯註:這梗估計我們是不懂了)或者異常時返回的abort事件。我們無法做到從每一個channel中接收信息,如果我們這麼做的話,如果第一個channel中沒有事件發過來那麼程序就會立刻被阻塞,這樣我們就無法收到第二個channel中發過來的事件。這時候我們需要多路複用(multiplex)這些操作了,為了能夠多路複用,我們使用了select語句。

select {
case <-ch1:
    // ...
case x := <-ch2:
    // ...use x...
case ch3 <- y:
    // ...
default:
    // ...
}

上面是select語句的一般形式。和switch語句稍微有點相似,也會有幾個case和最後的default選擇分支。每一個case代表一個通信操作(在某個channel上進行發送或者接收),並且會包含一些語句組成的一個語句塊。一個接收表達式可能只包含接收表達式自身(譯註:不把接收到的值賦值給變量什麼的),就像上面的第一個case,或者包含在一個簡短的變量聲明中,像第二個case裡一樣;第二種形式讓你能夠引用接收到的值。

select會等待case中有能夠執行的case時去執行。當條件滿足時,select才會去通信並執行case之後的語句;這時候其它通信是不會執行的。一個沒有任何case的select語句寫作select{},會永遠地等待下去。

讓我們回到我們的火箭發射程序。time.After函數會立即返回一個channel,並起一個新的goroutine在經過特定的時間後向該channel發送一個獨立的值。下面的select語句會一直等待直到兩個事件中的一個到達,無論是abort事件或者一個10秒經過的事件。如果10秒經過了還沒有abort事件進入,那麼火箭就會發射。

func main() {
    // ...create abort channel...

    fmt.Println("Commencing countdown.  Press return to abort.")
    select {
    case <-time.After(10 * time.Second):
        // Do nothing.
    case <-abort:
        fmt.Println("Launch aborted!")
        return
    }
    launch()
}

下面這個例子更微妙。ch這個channel的buffer大小是1,所以會交替的為空或為滿,所以只有一個case可以進行下去,無論i是奇數或者偶數,它都會打印0 2 4 6 8。

ch := make(chan int, 1)
for i := 0; i < 10; i++ {
    select {
    case x := <-ch:
        fmt.Println(x) // "0" "2" "4" "6" "8"
    case ch <- i:
    }
}

如果多個case同時就緒時,select會隨機地選擇一個執行,這樣來保證每一個channel都有平等的被select的機會。增加前一個例子的buffer大小會使其輸出變得不確定,因為當buffer既不為滿也不為空時,select語句的執行情況就像是拋硬幣的行為一樣是隨機的。

下面讓我們的發射程序打印倒計時。這裡的select語句會使每次循環迭代等待一秒來執行退出操作。

gopl.io/ch8/countdown3

func main() {
    // ...create abort channel...

    fmt.Println("Commencing countdown.  Press return to abort.")
    tick := time.Tick(1 * time.Second)
    for countdown := 10; countdown > 0; countdown-- {
        fmt.Println(countdown)
        select {
        case <-tick:
            // Do nothing.
        case <-abort:
            fmt.Println("Launch aborted!")
            return
        }
    }
    launch()
}

time.Tick函數表現得好像它創建了一個在循環中調用time.Sleep的goroutine,每次被喚醒時發送一個事件。當countdown函數返回時,它會停止從tick中接收事件,但是ticker這個goroutine還依然存活,繼續徒勞地嘗試向channel中發送值,然而這時候已經沒有其它的goroutine會從該channel中接收值了——這被稱為goroutine洩露(§8.4.4)。

Tick函數挺方便,但是隻有當程序整個生命週期都需要這個時間時我們使用它才比較合適。否則的話,我們應該使用下面的這種模式:

ticker := time.NewTicker(1 * time.Second)
<-ticker.C    // receive from the ticker's channel
ticker.Stop() // cause the ticker's goroutine to terminate

有時候我們希望能夠從channel中發送或者接收值,並避免因為發送或者接收導致的阻塞,尤其是當channel沒有準備好寫或者讀時。select語句就可以實現這樣的功能。select會有一個default來設置當其它的操作都不能夠馬上被處理時程序需要執行哪些邏輯。

下面的select語句會在abort channel中有值時,從其中接收值;無值時什麼都不做。這是一個非阻塞的接收操作;反覆地做這樣的操作叫做“輪詢channel”。

select {
case <-abort:
    fmt.Printf("Launch aborted!\n")
    return
default:
    // do nothing
}

channel的零值是nil。也許會讓你覺得比較奇怪,nil的channel有時候也是有一些用處的。因為對一個nil的channel發送和接收操作會永遠阻塞,在select語句中操作nil的channel永遠都不會被select到。

這使得我們可以用nil來激活或者禁用case,來達成處理其它輸入或輸出事件時超時和取消的邏輯。我們會在下一節中看到一個例子。

練習 8.8: 使用select來改造8.3節中的echo服務器,為其增加超時,這樣服務器可以在客戶端10秒中沒有任何喊話時自動斷開連接。