學到什麼
併發與並行的區別?
什麼是 Goroutine?
什麼是通道?
Goroutine 如何通訊?
相關函式的使用?
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 後,下一步就要知道它們之間要如何通訊。
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 就可以繼續爬取,而不像無緩衝通道,放入資料時會阻塞,直到通道資料被讀出,才能進行。
為了加深印象,再來一張圖:
圖解:
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 <- 1
,select
語句會在 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 協議》,轉載必須註明作者和本文連結