Channel 是什么?
channel,通道,本質上是一個通信對象,goroutine 之間可以使用它來通信。從技術上講,通道是一個數據傳輸管道,可以向通道寫入或從中讀取數據。
定義一個channel
Go 規定: 使用
chan
關鍵字來創建通道,使用make
關鍵字初始化通道,一個通道只能傳輸一種類型的數據。
上面的程序定義一個通道c
變量,它可以傳輸類型為int的數據。上面的程序打印 <nil>, 是因為通道的零值是 nil。但是 nil 通道不能傳輸數據。因此,我們必須使用 make 函數來創建一個可用的通道。
我們使用簡寫語法 :=
和 make
函數創建通道。上述程序產生以下結果:
type of `c` is chan int
value of `c` is 0xc0420160c0
注意通道的值 c
,看起來它是一個內存地址。
通道是指針類型。某些場景下,goroutine 之間通信時,會將通道作為參數傳遞給函數或方法,當程序接收該通道作為參數時,無需取消引用它即可從該通道推送或拉取數據。
通道數據讀寫
Go規定:使用左箭頭語法
<-
從通道讀取和寫入數據。
- 寫入數據
c <- data
上面的示例表示:將data發送到通道 c 中。看箭頭的方向,它從data指向c, 因此可以想象”我們正在嘗試將data推送到 c”。
- 讀取數據
<- c
上述示例表示:從通道 c 讀取數據。看箭頭方向,從 c 通道開始。
該語句不會將數據推存到任何內容中,但它仍然是一個有效的語句。
如果您有一個變量data,可以保存來自通道的數據,則可以使用以下語法:
var data int
data = <- c
現在來自通道 c 的 int 類型的數據可以存儲到變量data中, data 必須也是int類型。
上面的語法可以使用簡寫語法重寫,如下所示
data := <- c
golang 自動識別c中的數據類型,并將data設置為同樣的類型
以上所有通道操作都是阻塞的
在上一課中,我們使用 time.Sleep 阻塞了goroutine,從而調度了其它的goroutine。 而通道操作本質上也是阻塞的,當向通道寫入數據時,當前goroutine 會被阻塞,直到其它goroutine 從該通道讀取數據。我們在并發章節中看到,當前的goroutine阻塞后, 調度器會調度其它空閑的goroutine繼續工作,從而保證程序不會永遠阻塞,這是用channel來做的。通道的這個特性在 goroutines 通信中非常有用,因為它可以防止我們編寫手動鎖和 hack 來使它們彼此協同工作。
channel 實踐
下面我們一步一步的講講解上面程序的執行過程:
- 首先聲明了greet函數,它接受字符串類型的通道c。greeter這個函數從通道 c 讀取數據,并打印到控制臺。
- 在 main 函數中,第一條語句: 打印 "main started" 到控制臺
- main函數第二條語句:使用 make 初始化字符串類型的通道 c
- main 函數第三條語句中: 將通道c 傳遞給 greet 函數,但使用 go 關鍵字將其作為 goroutine 執行。
- 此時,進程有 2 個 goroutine,而活動goroutine 是
main goroutine
(查看上一課就知道它是什么)。然后控制權轉到下一行代碼。 - main 第四行語句:將字符串值 "John" 發送到通道 c。此時,goroutine 被阻塞,直到某個 goroutine 讀取它。 Go調度器調度
greet goroutine
,它按照第一點中提到的那樣執行。 - 然后
main goroutine
激活并執行最后的語句,打印 "main stopped"。
deadlock
前面我們說了“通道本質上是阻塞的”,當在通道寫入或讀取數據時,該 goroutine 會阻塞,而且會一直阻塞,控制權被傳遞給其他可用的 goroutine。如果沒有其他可用的 goroutine 怎么辦,程序無法向下執行了,這就產生死鎖錯誤,導致整個程序崩潰。
當嘗試從某個通道讀取數據,但通道沒有可用的值時,會期望其它 goroutine 推送值到該通道, 而阻塞當前goroutine,交出控制權,因此,此讀取操作將是阻塞的。同樣,如果要向通道發送數據,會交出控制權到其它goroutine,直到某個 goroutine 從中讀取數據。因此,此發送操作將被阻塞。
死鎖的一個簡單示例是只有主 goroutine 執行一些通道操作。
上面的程序將在運行時死鎖了, 拋出以下錯誤:
main() started
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
program.Go:10 +0xfd
exit status 2
fatal error: all goroutines are asleep - deadlock!
。似乎所有 goroutine 都處于睡眠狀態,或者根本沒有其他 goroutine 可用于調度。
關閉channel
一個通道可以關閉,這樣就不能再通過它發送數據了。接收器 goroutine 可以使用 val, ok := <- c
語法找出通道的狀態,如果通道打開或可以執行讀取操作,則 ok 為真,如果通道關閉且無法執行更多讀取操作,則為 false 。
可以使用
close(channel)
的close
內置函數關閉通道。
讓我們看一個簡單的例子。
只是為了幫助你理解阻塞的概念,首先發送操作
c <- "John"
是阻塞的,一些 goroutine 必須從通道讀取數據,因此greet goroutine
是由 Go調度器調度的。然后第一次讀取操作<-c
是非阻塞的,因為數據存在于通道 c 中以供讀取。第二次讀取操作<-c
將被阻塞,因為通道 c 沒有任何數據可供讀取,因此 Go 調度器激活main goroutine
并且程序從close(c)
函數開始執行。
從上面的錯誤中,是由嘗試在關閉的通道上發送數據引起的。為了更好地理解關閉通道的可用性,讓我們看看 for 循環。
for loop
for{} 的無限循環語法可用于讀取通過通道發送的多個值。
在上面的例子中,我們正在創建 goroutine squares,它一個一個地返回從 0 到 9 的數字的平方。在main goroutine 中,我們正在無限循環中讀取這些數字。
在無限 for 循環中,由于我們需要一個條件來在某個時刻中斷循環,因此我們使用語法 val, ok := <-c
從通道讀取值。在這里,當通道關閉時,ok 會給我們額外的信息。因此,在 squares 協程中,在寫完所有數據后,我們使用語法 close(c)
關閉通道。當 ok 為真時,程序打印 val 中的值和通道狀態 ok。當它為假時,我們使用 break 關鍵字跳出循環。因此,上述程序產生以下結果:
main() started
0 true
1 true
4 true
9 true
16 true
25 true
36 true
49 true
64 true
81 true
0 false <-- loop broke!
main() stopped
當通道關閉時,goroutine 讀取的值為通道數據類型的零值。在這種情況下,由于 channel 正在傳輸 int 數據類型,因此可以從結果中看到它是 0。與向通道讀取或寫入值不同,關閉通道不會阻塞當前的 goroutine。
為了避免手動檢查通道關閉條件的痛苦,Go 提供了更簡單的
for range
循環,它會在通道關閉時自動關閉。
讓我們修改我們之前的上述程序。
在上面的程序中,我們使用了 for val := range c
而不是 for{}
。 range 將一次從通道讀取一個值,直到它關閉。因此,上述程序產生以下結果
main() started
0
1
4
9
16
25
36
49
64
81
main() stopped
如果不關閉 for range 循環中的通道,程序將在運行時拋出死鎖致命錯誤。所以,數據發送完成時,要記得關閉通道;還可以使用select,default來避免這個問題。
緩沖區通道和通道容量
正如我們所見,每一個通道寫入操作都會阻塞當前goroutine。但是到目前位置,在創建channel時,我們都沒有給make第二個參數。這第二個參數就是通道容量,或緩沖區。默認為0的通道成為無緩沖區通道。
當通道的緩沖區大于0時,緩沖區被填滿之前, goroutine不會被阻塞。緩沖區滿后, 只有當通道的最后數據被讀取,新的值才能被添加。有一個問題是通道讀取是饑渴操作,這表示讀取一旦開始,只要緩沖區有數據, 讀取就會一直進行。技術上來講,緩沖區為空時,讀取操作才是阻塞的。
我們使用下面的語法定義帶緩沖的通道:
c := make(chan Type, n)
上面會創建一個數據類型為 Type 且緩沖區大小為 n 的通道。直到 channel 收到 n+1 個發送操作,它才會阻塞當前的 goroutine。
在上面的程序中,通道 c 的緩沖區容量為 3。這意味著它可以容納 3 個值,在第20行,由于緩沖區沒有溢出(因為我們沒有推送任何新值),main goroutine 不會阻塞,運行后退出,并不會調度到squares goroutine。
讓我們在發送一個額外的值:
如我們前面討論, 通道 c <- 4 發送操作超出了channel的容量,阻塞了main goroutine, squares goroutine 獲得控制權,并讀取通道內的所有值。
一個通道的長度和容量怎么計算呢
與切片類似,緩沖通道具有長度和容量。通道的長度是通道緩沖區中值的數量,而通道的容量是緩沖區大小,創建時n的值。計算長度,我們使用len函數,而找出容量,我們使用cap函數,就像切片一樣
如果你想知道為什么上面的程序運行良好并且沒有拋出死鎖錯誤。這是因為,由于通道容量為 3 并且緩沖區中只有 2 個值可用,Go 沒有嘗試通過阻止主 goroutine 執行來調度另一個 goroutine。如果需要,您可以簡單地在main goroutine 中讀取這些值,因為即使緩沖區未滿,也不會阻止您從通道讀取值。
另外一個例子:
使用多個 goroutine
下面我們創建2個goroutines, 一個計算整數的平方, 一個計算整數的立方
下面分析一下程序的執行過程:
- 首先創建了兩個函數,
square
和cube
, 兩個函數都使用c chan int
channel 作為參數, 函數從c
中讀取整數, 計算完成后,寫回c
中 - 在main goroutinue 中,我們創建了兩個int 類型的 channel :
squareChan
和cubeChan
- 使用
go
關鍵字, 以goroutine的方式 square 和 cube - 此時控制權還在 main goroutine中, 我們個變量
testNum
一個值3 - 此時我們把
testNum
發送到channelsquareChan
和cubeChan
, main goroutine 將被阻塞,直到這些channel的數據被讀取。一旦chanel中的數據被讀取,main goroutine 將繼續執行。 - 此時在main goroutine中, 嘗試從
squareChan
和cubeChan
讀取數據, 這依然是阻塞操作, 直到這些channel在他們各自的goroutine被寫入數據, main gorounine 才能繼續執行
上圖中程序的執行結果如下:
[main] main() started
[main] sent testNum to squareChan
[square] reading
[main] resuming
[main] sent testNum to cubeChan
[cube] reading
[main] resuming
[main] reading from channels
[main] sum of square and cube of 3 is 36
[main] main() stopped
單向通道
至此, 我們所操作的channel都是雙向的, 既能讀取,也能寫入。我們也可以創建單向操作的channel, 只讀channel:只能讀取數據;只寫chanel:只能寫入數據。
單向chanel創建依然使用make
函數,只是額外添加了單向箭頭(<-
)語法:
roc := make(<-chan int) // read-only chan
soc := make(chan<- int) // send-only chan
在上面的程序中, roc是只讀channel, make函數中的箭頭方向是遠離chan(<-chan
); soc是只寫channel, make函數中箭頭方向,指向chan(chan<-
), 他們是兩個不同的類型
但是單向信道有什么用呢?使用單向通道增加了程序的類型安全性。可減少程序出錯概率。
假如有如下場景: 假如你有一個goroutine, 你只需要在其中讀取channel中的數據, 但是main goroutine需要在同一個channle讀取和寫入數據,該怎么做呢?
幸運的是go 提供了簡單的語法, 把雙向的channle,改為單向
如上述示例所示, 我們只需要在 greet 函數中, 將接收參數修改為單向channel即可,現在我們在greet中,對channel的操作,只能讀了, 任何寫造作都會導致 fatal 錯誤 "invalid operation: roc <- "some text" (send to receive-only type <-chan string)"。
匿名goroutine
在 goroutines 章節中,我們學習了匿名 goroutines。我們也可以與他們一起實施渠道。讓我們修改前面的簡單示例,在匿名 goroutine 中實現 channel。
這是我們之前的例子
下面是一個修改后的例子,我們將 greet goroutine 變成了一個匿名 goroutine。
channel 作為 channel 的數據類型
如標題所示, channel 作為 golang中類型中國的一等公民, 可以像其他值一樣在任何地方使用: 作為結構的元素, 函數參數,返回值,甚至是另外一個通道的類型。下面的例子中,我們使用一個通道作為另外一個通道的數據類型。
select
select 就像沒有任何輸入參數的 switch 一樣,但它只用于通道操作。 select 語句用于僅對多個通道中的一個執行操作,由 case 塊有條件地選擇。
我們先看一個例子:
從上面的程序中,可以看到select語句就像switch一樣,但不是布爾操作,而是通道操作。 select 語句是阻塞的,除非它有default項。 一旦滿足條件之一, 它將解除阻塞。 那么它什么時候滿足條件呢?
如果所有 case 語句(通道操作)都被阻塞,則 select 語句將等待,直到其中一個 case 語句(其通道操作)解除阻塞,然后執行該 case。如果部分或全部通道操作是非阻塞的,則將隨機選擇非阻塞情況之一并立即執行。
為了解釋上面的程序,我們啟動了 2 個具有獨立通道的 goroutine。然后啟動了 2 個case的 select 語句。一種情況從 chan1 讀取值,另一種情況從 chan2 讀取值。由于這些通道是無緩沖的,讀操作將被阻塞(寫操作也是如此)。所以這兩種選擇的情況都是阻塞的。因此 select 將等待,直到其中一種情況變為非阻塞。
當程序運行到 select
代碼段時, main goroutine 會阻塞, 然后它將調度 select 語句中存在的所有 goroutine, 每次一個,這個例子里面是service1
和 service2
對應的goroutine,service1
將會等待3s,然后, 寫入一條數據到 chan1 解除阻塞, service2
等待5s,寫入一條數據到chan2, 然后解除阻塞。由于 service1 比 service2 更早解除阻塞,case 1 將首先解除阻塞,因此將執行該 case,而其他 case(此處為 case 2)將被忽略。完成案例執行后,主函數的執行將繼續進行。
上面的程序模擬了真實世界的 Web 服務,其中負載均衡器收到數百萬個請求,并且必須從可用服務之一返回響應。使用 goroutines、channels 和 select,我們可以向多個服務請求響應,并且可以使用快速響應的服務。
為了模擬所有情況何時都阻塞并且響應幾乎同時可用,我們可以簡單地刪除 Sleep 調用。
上述程序產生以下結果(您可能會得到不同的結果):
main() started 0s
service2() started 481μs
Response from service 2 Hello from service 2 981.1μs
main() stopped 981.1μs
有時候也可能是:
main() started 0s
service1() started 484.8μs
Response from service 1 Hello from service 1 984μs
main() stopped 984μs
發生這種情況是因為 chan1 和 chan2 操作幾乎同時發生,但執行和調度仍然存在一些時間差。
default case
和 switch 語句一樣,select 語句也有 default 項。 default 是非阻塞的:default case 使得 select 語句總是非阻塞的。這意味著,任何通道(緩沖或非緩沖)上的發送和接收操作始終是非阻塞的。
如果某個值在任何通道上可用,則 select 將執行該情況。如果沒有,它將立即執行默認情況。
在上面的程序中,由于通道是無緩沖的,并且兩個通道操作的值都不是立即可用的,因此將執行默認情況。如果上面的 select 語句沒有 default case,select 就會阻塞并且響應會有所不同。
由于有default的情況下select 是非阻塞的,main goroutine 不會阻塞,調度程序也不會調用其他 goroutine, service1 和 service2 并不會執行。但是我們可以手動調用 time.Sleep 來阻塞 main goroutine。這樣,所有其他的 goroutine 都會執行并死亡,將控制權返回給 main goroutine,它會在一段時間后喚醒。當main gorountine喚醒時,通道將立即有可用的值。
上述程序的執行結果:
main() started 0s
service1() started 0s
service2() started 0s
Response from service 1 Hello from service 1 3.0001805s
main() stopped 3.0001805s
也可能是:
main() started 0s
service1() started 0s
service2() started 0s
Response from service 2 Hello from service 2 3.0000957s
main() stopped 3.0000957s
死鎖
當沒有通道可用于發送或接收數據時,default case
很有用。為了避免死鎖,我們可以使用 default case。這是可能的,因為默認情況下的所有通道操作都是非阻塞的,如果數據不是立即可用,Go 不會安排任何其他 goroutine 將數據發送到通道。
與接收類似,在發送操作中,如果其他 goroutine 處于休眠狀態(未準備好接收值),則執行 default case。
nil channel
眾所周知,通道的默認值為 nil。因此我們不能在 nil 通道上執行發送或接收操作。一旦在 select 語句中使用 nil 通道時,它會拋出以下錯誤之一或兩個錯誤。
從上面的結果我們可以看出,select(no cases)意味著select語句實際上是空的,因為忽略了帶有nil channel的cases。但是由于空的 select{} 語句阻塞了主 goroutine 并且 service goroutine 被安排在它的位置,nil 通道上的通道操作會拋出 chan send (nil chan) 錯誤。為了避免這種情況,我們default情況。
上面的程序不僅忽略了 case 塊,而且立即執行了 default 語句。因此調度程序沒有時間來調度 service goroutine。但這真是糟糕的設計。應該始終檢查通道的 nil 值。
添加超時
上面的程序不是很有用,因為只執行default case。但有時,我們想要的是任何可用的服務都應該在理想的時間內做出響應,如果沒有,則應該執行 default case。這可以通過使用在定義的時間后解除阻塞的通道操作的情況來完成。此通道操作由時間包的 After 函數提供。讓我們看一個例子。
上面的程序,在 2 秒后產生以下結果。
main() started 0s
No response received 2.0010958s
main() stopped 2.0010958s
在上面的程序中, <-time.After(2 * time.Second)
2s 后解除阻塞,并返回時間,但在這里,我們對其返回值不感興趣。由于它也像一個 goroutine,我們有 3 個 goroutine, time.After 是第一個解除阻塞的channel。因此,對應于該 goroutine 操作的 case 被執行。
這很有用,因為您不想等待來自可用服務的響應太長時間,因為用戶必須等待很長時間才能從服務中獲取任何信息。如果我們在上面的例子中添加 10 * time.Second,來自 service1 的響應將被打印出來,我想現在很明顯了。
empty select
與 for{} 空循環一樣,空的 select{} 語法也是有效的,但有一個問題。{}正如我們所知,select 語句會被阻塞,直到其中一個 case 解除阻塞,并且由于沒有可用的 case 語句來解除阻塞,主 goroutine 將永遠阻塞,從而導致死鎖。
在上面的程序中,我們知道 select 會阻塞 main goroutine,調度器會調度另一個可用的 goroutine,即 service。但在那之后,它會死掉,調度必須調度另一個可用的 goroutine,但由于主例程被阻塞,沒有其他 goroutine 可用,導致死鎖。
main() started
Hello from service!
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [select (no cases)]:
main.main()
program.Go:16 +0xba
exit status 2
waitgroup
讓我們想象一種情況,您需要知道所有 goroutine 是否都完成了它們的工作。這與 select 你只需要一個條件為真的情況有點相反,但在這里你需要所有條件都為真才能解除main goroutine 的阻塞。這里的條件是通道操作成功。
WaitGroup 是一個帶有計數器值的結構體,它跟蹤產生了多少 goroutine 以及有多少已經完成了它們的工作。當這個計數器達到零時,意味著所有的 goroutine 都完成了他們的工作。
讓我們深入研究一個示例:
在上面的程序中,創建了一個類型為 sync.WaitGroup
的空結構(帶有零值字段)wg。 WaitGroup 結構體有禁止導出的字段,例如 noCopy、state1 和 sema,我們不需要知道它們的內部實現。這個結構有三個方法,即 Add
, Wait
和 Done
。
Add
方法需要一個 int 參數,它是 WaitGroup 計數器的增加量。計數器只不過是一個默認值為 0 的整數。它保存了正在運行的 goroutine 的數量。當 WaitGroup 創建時,它的計數器值為 0,我們可以通過使用 Add 方法傳遞 delta 作為參數來增加它。請記住,當 goroutine 啟動時,計數器不會遞增,因此我們需要手動遞增它。
Wait
方法用于阻塞當前的gorountine。一旦計數器達到 0,該 goroutine 將解除阻塞。因此,我們需要一些東西來減少計數器。
Done
方法就是用來遞減計數器的。它不接受任何參數,因此它只將計數器減 1。
在上面的程序中,創建 wg 后,我們運行了 3 次 for 循環。在每一輪中,我們啟動了 1 個 goroutine 并將計數器加 1。這意味著,現在我們有 3 個 goroutine 等待執行,WaitGroup 計數器為 3。請注意,我們在 goroutine 中傳遞了一個指向 wg 的指針。這是因為在 goroutine 中,一旦我們完成了 goroutine 應該做的任何事情,我們需要調用 Done 方法來遞減計數器。如果 wg 作為值傳遞,則 main 中的 wg 不會遞減。這是很明顯的。
在 for 循環執行完畢后,我們仍然沒有將控制權交給其他 goroutine。這是通過調用wg 的 Wait方法來完成的。這將阻塞 main goroutine,直到計數器達到 0。一旦計數器達到 0,因為從 3 個協程開始,我們在 wg 上調用了 Done 方法 3 次,主協程將解除阻塞并開始執行進一步的代碼。
上面的程序將產生如下結果:
main() started
Service called on instance 2
Service called on instance 3
Service called on instance 1
main() stopped
以上結果對你們來說可能不同,因為 goroutine 的執行順序可能會有所不同。
添加方法接受 int 類型,這意味著 delta 也可以是負數。要了解更多信息,請查看官方文檔[https://golang.org/pkg/sync/#WaitGroup.Add]。
worker pool
顧名思義,worker pool
:即工作池,是并發模式協同完成同樣工作一些列 goroutine 的集合。在前面 WaitGroup 中,我們看到一組 goroutines 并發工作,但它們沒有特定的工作內容。一旦將channel加入其中,讓它們有相同的工作去完成,這些goroutines就會成為一個工作池。
因此,worker pool
背后的概念是維護一個 worker goroutines 池,它接收一些任務并返回結果。一旦他們都完成了他們的工作,我們就會收集結果。所有這些 goroutine 都出于各自的目的使用相同的通道。
讓我們看一個帶有兩個通道的簡單示例,即 tasks 和 results
這個程序發生了什么呢?
-
sqrWorker
是一個工作函數, 它接收三個參數tasks
channel,results
channel 和id
, 這個gorountine的任務是接收tasks
中的數據,計算平方,并把結果發送到results
中。
- 在 main 函數中, 創建了緩沖容量為10的
tasks
和results
channel,因此,在tasks
緩沖滿之前, 發送操作都是非阻塞的。因此,設置大的緩沖值是一個是一個好主意。
- 然后我們生成多個
sqrWorker
goroutines實例,把前面建立的兩個channel 和 id 作為參數,其中id是作為標記,標識是哪個gorountine正在執行任務。
- 然后我們將 5 個job傳遞給非阻塞的
tasks
channel。
- 完成了發送job到
tasks
后,我們關閉了它。這并不是必需的,但是如果出現一些錯誤,它將在將來節省大量時間。
- 然后使用 for, 循環 5 次,我們從
results
channel中提取數據。由于對空緩沖區的讀取操作是阻塞的,因此將從工作池中調度一個 goroutine。在 goroutine 返回一些結果之前,main goroutine 將被阻塞。
- 因為在 worker goroutine 中模擬阻塞操作,調度程序將調用另一個可用的 goroutine,直到work gorountine變得可用時,它會將計算結果寫入
results
channel。由于在緩沖區滿之前,寫入通道是非阻塞的,因此在此處寫入results
通道是非阻塞的。此外,雖然當前的 worker goroutine 不可用,但執行了多個其他 worker goroutines,消耗了tasks
緩沖區中的值。在所有worker goroutine 消耗完tasks
后,tasks
通道緩沖區為空,for range 循環結束。當tasks
通道關閉時,它不會拋出死鎖錯誤。
- 有時,所有工作協程都可能處于休眠狀態,因此主協程將喚醒并工作,直到結果通道緩沖區再次為空。
- 在所有工作 goroutine 死后,main goroutine 將重新獲得控制權并從結果通道打印剩余的結果并繼續執行。
上面的例子解釋了多個 goroutines 如何可以在同一個通道上提供數據并優雅地完成工作。當worker被阻塞時,goroutines 很靈活的解決了這個問題。如果刪除 time.Sleep() 的調用,則只有一個 goroutine 獨自完成該作業,因為在 for range 循環完成且 goroutine 終止之前不會調度其他 goroutine。
運行系統速度不同, 您可能得到與上述例子不同的結果,因為如果所有的gorountine即使是被阻塞很短的時間, main gorountine也會被喚醒執行程序。
現在,讓我們使用sync.WaitGroup 實現相同的效果,但更優雅。
上面的結果看起來很整潔,因為main goroutine 中results
channel 上的讀取操作是非阻塞的,而results
channel 開始讀取前已經完成結果填充,而main goroutine 被 wg.Wait() 調用阻塞。使用 waitGroup,我們可以防止大量(不必要的)上下文切換(調度),這里是 7,而前面的例子是 9。但是有一個犧牲,因為你必須等到所有的工作都完成。
metux
Mutex【鎖】 是 Go 中最簡單的概念之一。但在解釋之前,讓我們先了解什么是競態條件。 goroutines 有它們獨立的堆棧,因此它們之間不共享任何數據。但是可能存在堆中的某些數據在多個 goroutine 之間共享的情況。在這種情況下,多個 goroutine 試圖在同一內存位置操作數據,從而導致意外結果。下面展示一個簡單的例子:
在上面的程序中,我們生成了 1000 個 goroutines,它們增加了初始為 0 的全局變量 i 的值。由于我們正在實現 WaitGroup,我們希望所有 1000 個 goroutines 一一增加 i 的值,從而得到 i 的最終值為 1000。當主 goroutine 在 wg.Wait() 調用后再次開始執行時,我們正在打印 i。讓我們看看最終的結果。
value of i after 1000 operations is 937
什么?為什么我們不到1000?看起來有些 goroutines 不起作用。但實際上,我們的程序存在競爭條件。讓我們看看可能發生了什么。
i = i + 1 calculation has 3 steps
-(1) 獲取 i 當前值
-(2) 將i的值增加 1
-(3) 使用新值替換 i
讓我們想象一個場景,在這些步驟之間安排了不同的 goroutine。例如,讓我們考慮 1000 個 goroutine 池中的 2 個 goroutine,即:G1 和 G2。
當 i 為 0 時 G1 首先啟動,運行前 2 個步驟,現在 i 現在為 1。但在 G1 更新步驟 3 中 i 的值之前,新的 goroutine G2 被調度并運行所有步驟。但是在 G2 的情況下,i 的值仍然是 0,因此在它執行第 3 步之后,i 將是 1。現在 G1 再次被安排完成第 3 步并從第 2 步更新 i 的值為 1。在 goroutines 的完美世界中在完成所有 3 個步驟后安排,2 個 goroutines 的成功操作會產生 i 的值是 2 但這里不是這種情況。因此,我們幾乎可以推測為什么我們的程序沒有將 i 的值變為 1000。
到目前為止,我們了解到 goroutine 是協作調度的。除非一個 goroutine 在并發課程中提到的條件之一阻塞,否則另一個 goroutine 不會取代它。既然 i = i + 1 沒有阻塞,為什么 Go 調度器會調度另一個 goroutine?
你絕對應該在stackoverflow上查看這個答案。在任何情況下,您都不應該依賴 Go 的調度算法并實現自己的邏輯來同步不同的 goroutine。
確保一次只有一個 goroutine 完成上述所有 3 個步驟的一種方法是實現互斥鎖。 Mutex(互斥)是編程中的一個概念,其中一次只有一個例程(線程)可以執行多個操作。這是通過一個例程獲取對值的鎖定,對它必須執行的值進行任何操作,然后釋放鎖定來完成的。當值被鎖定時,沒有其他例程可以讀取或寫入它。
在 Go 中,互斥鎖數據結構(本質上是個map)由sync包提供的。在 Go 中,在對可能導致競爭條件的值執行任何操作之前,我們使用 mutex.Lock() 方法獲取鎖,然后是操作代碼。一旦我們完成了操作,在上面的程序 i = i + 1 中,我們使用 mutext.Unlock() 方法解鎖它。當任何其他 goroutine 在鎖存在時嘗試讀取或寫入 i 的值時,該 goroutine 將阻塞,直到操作從第一個 goroutine 解鎖。因此只有 1 個 goroutine 可以讀取或寫入 i 的值,避免競爭條件。請記住,在整個操作被解鎖之前,鎖定和解鎖之間的操作中存在的任何變量將不可用于其他 goroutine。
讓我們用互斥鎖修改前面的例子。
在上面的程序中,我們創建了一個互斥鎖 m 并將指向它的指針傳遞給所有生成的 goroutine。在開始對 i 進行操作之前,我們使用 m.Lock() 語法獲取了互斥鎖 m 上的鎖,并且在操作之后,我們使用 m.Unlock() 語法將其解鎖。以上程序產生以下結果
value of i after 1000 operations is 1000
從上面的結果可以看出,互斥鎖幫助我們解決了競態條件。但是第一條規則是避免 goroutine 之間共享資源。
您可以在運行 Go run -race program.Go 之類的程序時使用種族標志測試 Go 中的競爭條件。在此處閱讀有關比賽檢測器的更多信息。
Concurrency Patterns 【并發模式】
通俗來講,就是日常使用的常用范式。
以下是一些可以使程序更快更可靠的概念和方法。
- Generator 【生成器模式】
使用通道,可以實現更好實現生成器。
比如斐波那契數列,計算上開銷很大, 我們可以提前計算好結果,并放入channel中, 等待程序執行到此,直接取結果即可, 而不必等待。
此圖中,調用fib 函數,返回了一個channel,通過循環channel,可以接收到的計算好的數據。在 fib 函數內部,必須返回一個只接收通道,我們創建一個有buffer的通道,并在函數最后返回它。fib的返回值會將這個雙向通道轉換為單向只接收通道。在匿名 goroutine 中,使用 for 循環將斐波那契數推送到此通道,完成后,關閉此通道。在主協程中,使用 fib 函數調用的范圍,我們可以直接訪問這個通道。
- fan-in & fan-out 【多路復用】
fan-in 是一種多路復用策略,將過個輸入通道組合,以產生輸出通道。fan-out 是將單個通道拆分為多個通道的解復用策略。
package main
import (
"fmt"
"sync"
)
// return channel for input numbers
func getInputChan() <-chan int {
// make return channel
input := make(chan int, 100)
// sample numbers
numbers := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
// run goroutine
go func() {
for num := range numbers {
input <- num
}
// close channel once all numbers are sent to channel
close(input)
}()
return input
}
// returns a channel which returns square of numbers
func getSquareChan(input <-chan int) <-chan int {
// make return channel
output := make(chan int, 100)
// run goroutine
go func() {
// push squares until input channel closes
for num := range input {
output <- num * num
}
// close output channel once for loop finishesh
close(output)
}()
return output
}
// returns a merged channel of `outputsChan` channels
// this produce fan-in channel
// this is veriadic function
func merge(outputsChan ...<-chan int) <-chan int {
// create a WaitGroup
var wg sync.WaitGroup
// make return channel
merged := make(chan int, 100)
// increase counter to number of channels `len(outputsChan)`
// as we will spawn number of goroutines equal to number of channels received to merge
wg.Add(len(outputsChan))
// function that accept a channel (which sends square numbers)
// to push numbers to merged channel
output := func(sc <-chan int) {
// run until channel (square numbers sender) closes
for sqr := range sc {
merged <- sqr
}
// once channel (square numbers sender) closes,
// call `Done` on `WaitGroup` to decrement counter
wg.Done()
}
// run above `output` function as groutines, `n` number of times
// where n is equal to number of channels received as argument the function
// here we are using `for range` loop on `outputsChan` hence no need to manually tell `n`
for _, optChan := range outputsChan {
go output(optChan)
}
// run goroutine to close merged channel once done
go func() {
// wait until WaitGroup finishesh
wg.Wait()
close(merged)
}()
return merged
}
func main() {
// step 1: get input numbers channel
// by calling `getInputChan` function, it runs a goroutine which sends number to returned channel
chanInputNums := getInputChan()
// step 2: `fan-out` square operations to multiple goroutines
// this can be done by calling `getSquareChan` function multiple times where individual function call returns a channel which sends square of numbers provided by `chanInputNums` channel
// `getSquareChan` function runs goroutines internally where squaring operation is ran concurrently
chanOptSqr1 := getSquareChan(chanInputNums)
chanOptSqr2 := getSquareChan(chanInputNums)
// step 3: fan-in (combine) `chanOptSqr1` and `chanOptSqr2` output to merged channel
// this is achieved by calling `merge` function which takes multiple channels as arguments
// and using `WaitGroup` and multiple goroutines to receive square number, we can send square numbers
// to `merged` channel and close it
chanMergedSqr := merge(chanOptSqr1, chanOptSqr2)
// step 4: let's sum all the squares from 0 to 9 which should be about `285`
// this is done by using `for range` loop on `chanMergedSqr`
sqrSum := 0
// run until `chanMergedSqr` or merged channel closes
// that happens in `merge` function when all goroutines pushing to merged channel finishes
// check line no. 86 and 87
for num := range chanMergedSqr {
sqrSum += num
}
// step 5: print sum when above `for loop` is done executing which is after `chanMergedSqr` channel closes
fmt.Println("Sum of squares between 0-9 is", sqrSum)
}
完