Go 併發編程 - channel 多路複用

在前面兩篇文章中,已經詳細的介紹了 goroutine 和 channel,它們是 Go 併發編程的基礎。今天這篇文章會介紹 Go 併發編程中另一個重要的角色——多路複用。

  1. 爲什麼需要多路複用

Go 程序在併發處理一些任務的時,會爲每一個任務創建一個 goroutine,然後需要根據不同的 goroutine 的返回的結果做不同的處理。

如果按照通常的做法,分別獲取每個 channel 的結果:

taskCh1 := make(chan bool)
taskCh2 := make(chan bool)
taskCh3 := make(chan bool)
go run(taskCh1)
go run(taskCh2)
go run(taskCh3)
for {
    // 接收通道 1 的結果
    result1 := <-taskCh1
    // 接收通道 2 的結果
    result2 := <-taskCh2
    // 接收通道 3 的結果
    result3 := <-taskCh3
}

然後再根據不同 goroutine 返回的結果做後續的處理,這個代碼有個問題,需要等待所有的 goroutine 都執行完成之後才能做出結果,這樣實現的效率很低,因爲每一個獲取 channel 值的過程都是阻塞的。

在處理多個通道時,想同時接收多個通道的數據將會很困難。

而且在一些情況下,需要根據先返回通道的做出不同的處理,上面那種方式無法做到,這就需要使用多路複用

Go 提供了 select 機制來解決這個問題。

  1. select 基本使用

select 語法形式和 switch 很相似,switch 接收一個變量,然後根據變量的值做不同的處理,select 操作接收的是通道操作:

ch := make(chan int, 1) // 這個例子中,這裏必須用緩衝通道
for {
  select {
  case <-ch:
    time.Sleep(time.Second)
    fmt.Println("case 1 invoke")
  case data := <-ch:
    time.Sleep(time.Second)
    fmt.Printf("case 2 invoke %d\n", data)
  case ch <- 100:
    time.Sleep(time.Second)
    fmt.Println("case3 invoke")
}

在 select 的 case 中,可以執行三種操作:

  1. <- ch:接收通道,但是對值不處理

  2. data := <-ch:接收通道,並處理從通道中得到的結果

  3. ch <- 100:向通道中發送數據

上面的程序運行起來之後,case 3 會首先執行,然後 case1 和 case2 會隨機執行一個,程序就這樣一直交替運行下去。

如果用 select 改造上面第一個例子中的代碼,就是下面這樣:

for {
    select {
    // 接收通道 1 的結果
    case r := <-taskCh1:
      fmt.Printf("task1 result %+v\n", r)
    // 接收通道 2 的結果
    case r := <-taskCh2:
      fmt.Printf("task2 result %+v\n", r)
    // 接收通道 3 的結果
    case r := <-taskCh3:
      fmt.Printf("task3 result %+v\n", r)
    }
}

select 會及時響應每一個就緒的 channel,無論是發送數據還是接收數據。

  1. 處理超時情況

select 除了用於同時處理多個通道之外,還可以用來處理一些通道超時的情況,通道在阻塞的時候,如果沒有外界的干擾,會一直等下去,但是可以通過 select 設置一個超時時間,來打斷阻塞:

ch := make(chan int, 1)
select {
case data := <- ch:
  fmt.Printf("case invoke %+v\n", data)
case <-time.After(3 * time.Second):
  fmt.Println("channel timeout")
}

上面的代碼在創建了一個通道,但沒有向通道中發送數據,如果不用 select,程序就會死鎖。

select 中添加了兩個 case,一個從通道中獲取數據, 但肯定獲取不到,所以在 3 秒鐘之後,另一個 case 就會執行,返回通道超時的提示,這樣就避免了程序會一直等待下去。

還有一個情況是我們有時候需用通過鍵盤獲取其他輸入設備向程序發送信號,也可以通過這種方式來實現,把上面的程序再修改一下:

ch := make(chan int, 1)
quitCh := make(chan bool, 1)
go func(ch chan bool) {
  var quit string
  fmt.Printf("quit? are you sure?: ")
  fmt.Scanln(&quit)
  quitCh <- true
}(quitCh)
select {
case data := <- ch:
  fmt.Printf("case invoke %+v\n", data)
case <-quitCh:
  fmt.Println("program quit")
}

這次不再通過超時來控制,而是通過鍵盤來控制,新建了一個通道,只有在鍵盤輸入之後,纔會向通道中發送數據,這樣就可以做到自由控制程序的退出。

  1. 非阻塞的 select

在上面的示例代碼中,其實還少寫了一部分,看下面的代碼:

ch := make(chan int)
for {
  select {
  case <-ch:
    fmt.Println("case invoke")
  }
}

上面的代碼會出現死鎖,因爲這個 select 只有一個 case,而這個 case 永遠都不會接收到數據,所以 select 本身也被阻塞了,程序無法繼續運行,就會造成死鎖,對於這種情況,我們設置一個可用的 case,讓 select 變成非阻塞,就可以解決這個問題。

ch := make(chan int)
for {
  select {
  case <-ch:
    fmt.Println("case invoke")
  default:
    time.Sleep(time.Second)
    fmt.Println("default invoke")
  }
}

這樣,程序就不會死鎖,而是不斷的執行 default 中的內容。

  1. 小結

在這篇文章中,我們介紹了通道的多路複用,並說明了可以用到多路複用的場景。下篇文章中,我們來詳細聊一下 Go 是如何實現傳統的併發模型。

文 / Rayjun

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Iu4LKZ7daQOnjmd4jiv0pQ