認真一點學 Go:18. 併發

瀟灑哥老苗發表於2021-11-04

學到什麼

  1. 併發與並行的區別?

  2. 什麼是 Goroutine?

  3. 什麼是通道?

  4. Goroutine 如何通訊?

  5. 相關函式的使用?

  6. select 語句如何使用?

併發與並行

為了更有意思的解釋這個概念,我借用知乎上的一個回答:

  • 你吃飯吃到一半,電話來了,你一直到吃完了以後才去接,這就說明你不支援併發也不支援並行。

  • 你吃飯吃到一半,電話來了,你停了下來接了電話,接完後繼續吃飯,這說明你支援併發。

  • 你吃飯吃到一半,電話來了,你一邊打電話一邊吃飯,這說明你支援並行。

併發的關鍵是你有處理多個任務的能力,不一定要同時。

並行的關鍵是你有同時處理多個任務的能力。

對應到 CPU 上,如果是多核它就有同時執行的能力,即有並行的能力。

對於 Go 語言,它自行安排了我們的程式碼合適併發合適並行。

什麼是 Goroutine

學會這個就知道怎麼寫一個併發程式,用起來很簡單的,現在開始。

Goroutine 是 Go 語言中的協程,其它語言稱為的協程字面上叫 Coroutine,簡單理解下就是比執行緒更輕量的一個玩意。

再說白了,就是可以非同步執行函式。

main Goroutine

當啟動 main 入口函式時,後臺就自動跑了一個 main Goroutine,還原給大家看看。


package main

func  main() {

 panic("看這裡")

}

執行上面程式碼,會輸出如下部分資訊:


panic: 看這裡

goroutine 1 [running]:

main.main()

從結果中可以看到,出現了一個 goroutine 字眼,它對應的索引為 1。

建立 Goroutine

建立 Goroutine 很簡單,只需要在函式前增加一個 go 關鍵字,格式如下:


go  fun1(...)

也支援匿名函式。


go  func(...){

 // ...

}(...)
  • go 關鍵字後的函式可以寫返回值,但無效。因為 Goroutine 是非同步的,所以沒法接受。

下來看一個完整的例子:


package main

import (

 "fmt"

)

func  PrintA()  {

    fmt.Println("A")

}

func  main() {

 go  PrintA()

    fmt.Println("main")

}

看上面 main 函式只有兩行:

  • 第一行:建立一個 Goroutine,非同步列印“A”字串。

  • 第二行:列印 “main” 字串。

現在先停留一會,想想執行該程式碼後,輸出結果是啥。

結果如下:


main

你沒看錯,沒有輸出“A”字串。

因為 go PrintA() 建立的 Goroutine 它是非同步執行,main 函式執行完退出程式時,也不會管它。所以下來看如何讓 main 函式等待 Goroutine 執行完。

方法一:使用 time.Sleep 函式。


func  main() {

 go  PrintA()

    fmt.Println("main")

    time.Sleep(time.Second)

}

// 輸出

main

A

main 函式退出前讓等一會。

方法二:使用空的select 語句,非空的 select 用法會配合通道一塊講解。


func  main() {

   go  PrintA()

   fmt.Println("main")

   select {}

}

// 輸出

main

A

fatal error: all goroutines are asleep - deadlock!

...

“A”字串是輸出了,但程式也出現異常了。

原因是,當程式中存在執行的 Goroutine,select{} 就會一直等待,如果 Goroutine 都執行結束了,沒有什麼可等待的了,就會丟擲異常。

在真實專案中,出現異常自然不對,那 select{} 使用場景是啥,例如:

  • 爬蟲專案,建立了 Goroutine,需要一直爬取資料,不需要停止。

方法三:使用 WaitGroup 型別等待 Goroutine 結束,專案中常常使用,完整例子如下:


package main

import (

 "fmt"

 "sync"

)

var  wg sync.WaitGroup

func  PrintA()  {

    fmt.Println("A")

    wg.Done()

}

func  main() {

    wg.Add(1)

 go  PrintA()

    wg.Wait()

    fmt.Println("main")

}
  • 宣告 WaitGroup 型別變數 wg,使用時無需初始化。

  • wg.Add(1) 表示需要等待一個 Goroutine,如果有兩個,使用 Add(2)

  • 當一個 Goroutine 執行完後使用 wg.Done() 通知。

  • wg.Wait() 等待 Goroutine 執行完。

控制併發數

Go 語言中可以控制使用 CPU 的核心數量,從 Go1.5 版本開始,預設設定為 CPU 的總核心數。如果想自定義設定,使用如下函式:


num := 2

runtime.GOMAXPROCS(num)

num 如果大於 CPU 的核心數,也是允許的,Go 語言排程器會將很多的 Goroutine 分配到不同的處理器上。

什麼是通道

現在明白了怎麼建立 Goroutine 後,下一步就要知道它們之間要如何通訊。

認真一點學 Go:18. 併發

Goroutine 通訊使用“通道(channel)”,如果 Goroutine1 想傳送資料給 Goroutine2,就把資料放到通道里,Goroutine2 直接從通道里拿就行,反過來也是一樣。

在給通道放資料時,也可以指定通道放置的資料型別。

建立通道

建立通道時,分為無緩衝和有緩衝兩種。

1. 無緩衝


strChan := make(chan  string)

定義了一個儲存資料型別為 string 的無緩衝通道,如果想儲存任意型別,那資料型別設定為空介面。


allChan := make(chan  interface{})

建立好了通道,下來就要給通道里放資料。


strChan := make(chan  string)

strChan <- "老苗"

使用”<-“操作符連結資料,表示將“老苗”字串送入 strChan 通道變數。

但這樣放資料是會報錯的,因為 strChan 變數是無緩衝通道,放入資料時 main 函式會一直等待,因此會造成死鎖。

如果想解決死鎖情況,就要保證有地方在非同步讀通道,因此需要建立一個 Goroutine 來負責。

例子如下:


// concurrency/channel/main.go

package main

import (

 "fmt"

 "sync"

)

var  wg sync.WaitGroup

func  Read(strChan chan  string)  {

 data := <-strChan

    fmt.Println(data)

    wg.Done()

}

func  main() {

    wg.Add(1)

 strChan := make(chan  string)

 go  Read(strChan)

strChan <- "老苗"

    wg.Wait()

}

// 輸出

老苗
  • Read 函式負責讀取通道資料,並列印。

  • 通道是引用型別,因此傳遞時無需使用指標。

  • <-strChan 表示從通道里拿資料,如果通道里沒有資料它會進行阻塞。

  • wg.Wait() 等待 Read 非同步函式執行完。

2. 有緩衝

讀了上面就會了解到,對於無緩衝通道,它會產生阻塞。為了不讓阻塞,必須建立一個 Goroutine 負責從通道讀取才行。

而有緩衝的通道,會有緩衝的餘地,具體來看看。

建立緩衝通道,如下:


bufferChan := make(chan  string, 3)
  • 建立了一個儲存資料型別為 string 的通道。

  • 可以緩衝 3 個資料,即給通道送入 3 個資料不會進行阻塞。

測試如下:


// concurrency/bufferchannel/main.go

package main

import  "fmt"

func  main() {

 bufferChan := make(chan  string, 3)

    bufferChan<-"a"

    bufferChan<-"b"

    bufferChan<-"c"

    fmt.Println(<-bufferChan)

}

// 輸出

a
  • bufferChan 變數存入 3 個字串。

  • 存入 3 個資料時不會阻塞,當存入數量超過 3 時,就需要 Goroutine 非同步讀取。

緩衝通道何時使用,例如:

爬蟲資料,第 1 個 Goroutine 負責爬取資料,第 2 個 Goroutine 負責處理和儲存資料。 當第 1 個的處理速度大於第 2 個時,可以使用緩衝通道暫存起來。

暫存起來後,第 1 個 Goroutine 就可以繼續爬取,而不像無緩衝通道,放入資料時會阻塞,直到通道資料被讀出,才能進行。

為了加深印象,再來一張圖:

認真一點學 Go:18. 併發

圖解:

  • bufferChan 長度為 3 的緩衝通道,並且已存入 2 個資料。

  • 看圖中的兩個箭頭,箭頭在 bufferChan 右邊,表示存,左邊表示取。

  • 按照先入先出規則存取。

單向通道

現在知道了如何建立一個雙向通道,雙向通道指的就是即可以存,又可以取。

那單向通道建立如下:


readChan := make(<-chan  string)

writeChan := make(chan<- string)
  • readChan 只能讀取資料。

  • writeChan 只能存取資料。

但這樣建立的通道是無法傳遞資料的,為什麼?

因為,如果只能讀的通道,沒法存資料,那我存了個寂寞。而存的通道,我資料拿不出來,又有何用。

現在看看如何正確使用單向通道的例子,如下:


// concurrency/onechannel/main.go

package main

import (

 "fmt"

 "sync"

)

var  wg sync.WaitGroup

// 寫通道

func  write(data chan<- int)  {

    data<-520

    wg.Done()

}

// 讀通道

func  read(data <-chan  int)  {

    fmt.Println(<-data)

    wg.Done()

}

func  main() {

    wg.Add(2)

 dataChan := make(chan  int)

 go  write(dataChan)

 go  read(dataChan)

    wg.Wait()

}

// 輸出

520
  • 建立了兩個 Goroutine,read 函式負責只讀,write 函式負責只寫。

  • 通道傳遞時,將雙向通道轉化為單向通道。

遍歷通道

在實際專案中,通道里會產生大量的資料,這時候就要迴圈的從通道里讀取。

現在改寫單向通道寫入資料的例子:


func  write(data chan<- int)  {

 for  i := 0; i < 10; i++ {

        data<-i

    }

    wg.Done()

}

這段程式碼是給通道里迴圈寫入數字。

下來使用兩種方式迴圈讀取通道資料。

1. 死迴圈


func  read(data <-chan  int)  {

 for {

 d := <-data

        fmt.Println(d)

    }

    wg.Done()

}

使用死迴圈讀取資料,但這個有個問題,什麼時候退出 for 迴圈?

read 函式在讀取通道時是不知道資料寫入完了,如果讀取不到資料,它會一直阻塞,因此,如果寫資料完成時,需要使用 close 函式關閉通道。


func  write(data chan<- int)  {

 // ...

 close(data)

    wg.Done()

}

關閉後,讀取通道時也需要檢測判斷。


func  read(data <-chan  int)  {

 for {

 d, ok := <-data

 if !ok {

 break

        }

        fmt.Println(d)

    }

    wg.Done()

}
  • ok 變數為 false 時,表示通道已關閉。

  • 關閉通道後,ok 變數不會立馬變成 false,而是等已放入通道的資料都讀取完。


ch := make(chan  string, 1)

ch <- "a"

close(ch)

val, ok := <-ch

fmt.Println(val, ok)

val1, ok1 := <-ch

fmt.Println(val1, ok1)

// 輸出

a true

 false

2. for-range

也可以使用 for-range 語句讀取通道,這比死迴圈使用起來簡單一點。


func  read(data <-chan  int)  {

 for  d := range data{

        fmt.Println(d)

    }

    wg.Done()

}
  • 如果想退出 for-range 語句,也需要關閉通道。

  • 如果關閉通道後,不需要增加 ok 判斷,等通道資料讀取完,自行會退出。

通道函式

使用 len 函式獲取通道里還有多少個訊息未讀,cap 函式獲取通道的緩衝大小


ch := make(chan  int, 3)

ch<-1

fmt.Println(len(ch))

fmt.Println(cap(ch))

// 輸出

1

3

select 語句

上面已經知道了空 select 語句的作用,現在看看非空 select 的用法。

select 語句 和 switch 語句類似,它也有 case 分支,也有 default 分支,但 select 語句的不同點有兩個:

  • case 分支只能是“讀通道”或“寫通道”,如果讀寫成功,即不阻塞,則 case 分支就滿足。

  • fallthrough 關鍵字不能使用。

1. 無 default 分支

select 語句會在 case 分支中選擇一個可讀寫成功的通道。

正確例子:


// concurrency/select/main.go

package main

import  "fmt"

func  main() {

   ch1 := make(chan  int, 1)

   ch2 := make(chan  int, 1)

ch1 <- 1

   select {

   case  v, ok := <-ch1:

 if ok {

         fmt.Println("ch1通道", v)

      }

   case  v, ok := <-ch2:

 if ok {

         fmt.Println("ch2通道", v)

      }

   }

}

// 輸出

ch1通道 1
  • ch1 通道有資料,因此進入了第一個 case 分支。

  • 這裡展示了讀通道,也可以給通道寫資料,例:case ch2<-2

  • 如果刪除 ch1 <- 1select 語句會在 main 函式中一直等待,因此會造成死鎖。


fatal error: all goroutines are asleep - deadlock!

goroutine 1 [select]:

main.main()

    C:/workspace/go/src/gobasic/cocurrency/select/main.go:9 +0xe7

2. 有 default 分支

為了防止 select 語句出現死鎖,可以增加 default 分支。意思就是,當沒有一個 case 分支可以進行通道讀寫,那就走 default 分支。


// ...

func  main() {

 ch1 := make(chan  int, 1)

 ch2 := make(chan  int, 1)

 select {

 case  v, ok := <-ch1:

 if ok {

            fmt.Println("ch1通道", v)

        }

 case  v, ok := <-ch2:

 if ok {

            fmt.Println("ch2通道", v)

        }

 default:

        fmt.Println("沒有可讀寫通道")

    }

}

// 輸出

沒有可讀寫通道

總結

這節課很關鍵,也是很容易出現問題的地方,我再針對重點的重點強調一下:

  • 在函式呼叫前增加 go 關鍵字,表示建立 Goroutine。

  • 執行 Goroutine 不會同步等待,常用的使用WaitGroup 型別處理。

  • Goroutine 的通訊使用通道傳輸。

  • 無緩衝的通道,不要進行同步讀寫,不然會阻塞。

最後,再揣摩一句話,不要用共享記憶體來通訊,要用通訊來共享記憶體。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
瀟灑哥老苗

相關文章