Golang 并發之三 ( go channel 和 gorountine)

Channel 是什么?

channel,通道,本質上是一個通信對象,goroutine 之間可以使用它來通信。從技術上講,通道是一個數據傳輸管道,可以向通道寫入或從中讀取數據。

定義一個channel

Go 規定: 使用 chan 關鍵字來創建通道,使用make關鍵字初始化通道,一個通道只能傳輸一種類型的數據。

零值通道

上面的程序定義一個通道c變量,它可以傳輸類型為int的數據。上面的程序打印 <nil>, 是因為通道的零值是 nil。但是 nil 通道不能傳輸數據。因此,我們必須使用 make 函數來創建一個可用的通道。

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 實踐

goroutine 和 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 內置函數關閉通道。

讓我們看一個簡單的例子。

關閉channel

只是為了幫助你理解阻塞的概念,首先發送操作 c <- "John" 是阻塞的,一些 goroutine 必須從通道讀取數據,因此greet goroutine 是由 Go調度器調度的。然后第一次讀取操作 <-c 是非阻塞的,因為數據存在于通道 c 中以供讀取。第二次讀取操作 <-c 將被阻塞,因為通道 c 沒有任何數據可供讀取,因此 Go 調度器激活 main goroutine 并且程序從 close(c) 函數開始執行。

從上面的錯誤中,是由嘗試在關閉的通道上發送數據引起的。為了更好地理解關閉通道的可用性,讓我們看看 for 循環。

for loop

for{} 的無限循環語法可用于讀取通過通道發送的多個值。

for循環讀取channel

在上面的例子中,我們正在創建 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循環,它會在通道關閉時自動關閉。

讓我們修改我們之前的上述程序。

range讀取channel

在上面的程序中,我們使用了 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。

buffered channel

在上面的程序中,通道 c 的緩沖區容量為 3。這意味著它可以容納 3 個值,在第20行,由于緩沖區沒有溢出(因為我們沒有推送任何新值),main goroutine 不會阻塞,運行后退出,并不會調度到squares goroutine。

讓我們在發送一個額外的值:

buffer channel2

如我們前面討論, 通道 c <- 4 發送操作超出了channel的容量,阻塞了main goroutine, squares goroutine 獲得控制權,并讀取通道內的所有值。

一個通道的長度和容量怎么計算呢

與切片類似,緩沖通道具有長度和容量。通道的長度是通道緩沖區中值的數量,而通道的容量是緩沖區大小,創建時n的值。計算長度,我們使用len函數,而找出容量,我們使用cap函數,就像切片一樣

length and capacity of channel

如果你想知道為什么上面的程序運行良好并且沒有拋出死鎖錯誤。這是因為,由于通道容量為 3 并且緩沖區中只有 2 個值可用,Go 沒有嘗試通過阻止主 goroutine 執行來調度另一個 goroutine。如果需要,您可以簡單地在main goroutine 中讀取這些值,因為即使緩沖區未滿,也不會阻止您從通道讀取值。

另外一個例子:

example

使用多個 goroutine

下面我們創建2個goroutines, 一個計算整數的平方, 一個計算整數的立方

https://play.golang.org/p/6wdhWYpRfrX

下面分析一下程序的執行過程:

  1. 首先創建了兩個函數, squarecube, 兩個函數都使用 c chan int channel 作為參數, 函數從c中讀取整數, 計算完成后,寫回c
  2. 在main goroutinue 中,我們創建了兩個int 類型的 channel : squareChancubeChan
  3. 使用go關鍵字, 以goroutine的方式 square 和 cube
  4. 此時控制權還在 main goroutine中, 我們個變量 testNum 一個值3
  5. 此時我們把testNum 發送到channel squareChancubeChan, main goroutine 將被阻塞,直到這些channel的數據被讀取。一旦chanel中的數據被讀取,main goroutine 將繼續執行。
  6. 此時在main goroutine中, 嘗試從squareChancubeChan 讀取數據, 這依然是阻塞操作, 直到這些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<-), 他們是兩個不同的類型

https://play.golang.org/p/JZO51IoaMg8

但是單向信道有什么用呢?使用單向通道增加了程序的類型安全性。可減少程序出錯概率。

假如有如下場景: 假如你有一個goroutine, 你只需要在其中讀取channel中的數據, 但是main goroutine需要在同一個channle讀取和寫入數據,該怎么做呢?

幸運的是go 提供了簡單的語法, 把雙向的channle,改為單向

https://play.golang.org/p/k3B3gCelrGv

如上述示例所示, 我們只需要在 greet 函數中, 將接收參數修改為單向channel即可,現在我們在greet中,對channel的操作,只能讀了, 任何寫造作都會導致 fatal 錯誤 "invalid operation: roc <- "some text" (send to receive-only type <-chan string)"。

匿名goroutine

在 goroutines 章節中,我們學習了匿名 goroutines。我們也可以與他們一起實施渠道。讓我們修改前面的簡單示例,在匿名 goroutine 中實現 channel。

這是我們之前的例子

https://play.golang.org/p/c5erdHX1gwR

下面是一個修改后的例子,我們將 greet goroutine 變成了一個匿名 goroutine。

https://play.golang.org/p/cM5nFgRha7c

channel 作為 channel 的數據類型

如標題所示, channel 作為 golang中類型中國的一等公民, 可以像其他值一樣在任何地方使用: 作為結構的元素, 函數參數,返回值,甚至是另外一個通道的類型。下面的例子中,我們使用一個通道作為另外一個通道的數據類型。

https://play.golang.org/p/xVQvvb8O4De

select

select 就像沒有任何輸入參數的 switch 一樣,但它只用于通道操作。 select 語句用于僅對多個通道中的一個執行操作,由 case 塊有條件地選擇。

我們先看一個例子:

https://play.golang.org/p/ar5dZUQ2ArH

從上面的程序中,可以看到select語句就像switch一樣,但不是布爾操作,而是通道操作。 select 語句是阻塞的,除非它有default項。 一旦滿足條件之一, 它將解除阻塞。 那么它什么時候滿足條件呢?

如果所有 case 語句(通道操作)都被阻塞,則 select 語句將等待,直到其中一個 case 語句(其通道操作)解除阻塞,然后執行該 case。如果部分或全部通道操作是非阻塞的,則將隨機選擇非阻塞情況之一并立即執行。

為了解釋上面的程序,我們啟動了 2 個具有獨立通道的 goroutine。然后啟動了 2 個case的 select 語句。一種情況從 chan1 讀取值,另一種情況從 chan2 讀取值。由于這些通道是無緩沖的,讀操作將被阻塞(寫操作也是如此)。所以這兩種選擇的情況都是阻塞的。因此 select 將等待,直到其中一種情況變為非阻塞。

當程序運行到 select 代碼段時, main goroutine 會阻塞, 然后它將調度 select 語句中存在的所有 goroutine, 每次一個,這個例子里面是service1service2 對應的goroutine,service1 將會等待3s,然后, 寫入一條數據到 chan1 解除阻塞, service2 等待5s,寫入一條數據到chan2, 然后解除阻塞。由于 service1 比 service2 更早解除阻塞,case 1 將首先解除阻塞,因此將執行該 case,而其他 case(此處為 case 2)將被忽略。完成案例執行后,主函數的執行將繼續進行。

上面的程序模擬了真實世界的 Web 服務,其中負載均衡器收到數百萬個請求,并且必須從可用服務之一返回響應。使用 goroutines、channels 和 select,我們可以向多個服務請求響應,并且可以使用快速響應的服務。

為了模擬所有情況何時都阻塞并且響應幾乎同時可用,我們可以簡單地刪除 Sleep 調用。

https://play.golang.org/p/giSkkqt8XHb

上述程序產生以下結果(您可能會得到不同的結果):

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 將執行該情況。如果沒有,它將立即執行默認情況。

https://play.golang.org/p/rFMpc80EuT3

在上面的程序中,由于通道是無緩沖的,并且兩個通道操作的值都不是立即可用的,因此將執行默認情況。如果上面的 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 將數據發送到通道。

https://play.golang.org/p/S3Wxuqb8lMF

與接收類似,在發送操作中,如果其他 goroutine 處于休眠狀態(未準備好接收值),則執行 default case。

nil channel

眾所周知,通道的默認值為 nil。因此我們不能在 nil 通道上執行發送或接收操作。一旦在 select 語句中使用 nil 通道時,它會拋出以下錯誤之一或兩個錯誤。

https://play.golang.org/p/uhraFubcF4S

從上面的結果我們可以看出,select(no cases)意味著select語句實際上是空的,因為忽略了帶有nil channel的cases。但是由于空的 select{} 語句阻塞了主 goroutine 并且 service goroutine 被安排在它的位置,nil 通道上的通道操作會拋出 chan send (nil chan) 錯誤。為了避免這種情況,我們default情況。

https://play.golang.org/p/upLsz52_CrE

上面的程序不僅忽略了 case 塊,而且立即執行了 default 語句。因此調度程序沒有時間來調度 service goroutine。但這真是糟糕的設計。應該始終檢查通道的 nil 值。

添加超時

上面的程序不是很有用,因為只執行default case。但有時,我們想要的是任何可用的服務都應該在理想的時間內做出響應,如果沒有,則應該執行 default case。這可以通過使用在定義的時間后解除阻塞的通道操作的情況來完成。此通道操作由時間包的 After 函數提供。讓我們看一個例子。

https://play.golang.org/p/mda2t2IQK__X

上面的程序,在 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 將永遠阻塞,從而導致死鎖。

https://play.golang.org/p/-pBd-BLMFOu

在上面的程序中,我們知道 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 都完成了他們的工作。

讓我們深入研究一個示例:

https://play.golang.org/p/8qrAD9ceOfJ

在上面的程序中,創建了一個類型為 sync.WaitGroup 的空結構(帶有零值字段)wg。 WaitGroup 結構體有禁止導出的字段,例如 noCopy、state1 和 sema,我們不需要知道它們的內部實現。這個結構有三個方法,即 Add, WaitDone

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

https://play.golang.org/p/IYiMV1I4lCj

這個程序發生了什么呢?

  • sqrWorker 是一個工作函數, 它接收三個參數 tasks channel, results channel 和 id , 這個gorountine的任務是接收 tasks中的數據,計算平方,并把結果發送到results中。
  • 在 main 函數中, 創建了緩沖容量為10的tasksresults 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 實現相同的效果,但更優雅。

https://play.golang.org/p/0rRfchn7sL1

上面的結果看起來很整潔,因為main goroutine 中results channel 上的讀取操作是非阻塞的,而results channel 開始讀取前已經完成結果填充,而main goroutine 被 wg.Wait() 調用阻塞。使用 waitGroup,我們可以防止大量(不必要的)上下文切換(調度),這里是 7,而前面的例子是 9。但是有一個犧牲,因為你必須等到所有的工作都完成。

metux

Mutex【鎖】 是 Go 中最簡單的概念之一。但在解釋之前,讓我們先了解什么是競態條件。 goroutines 有它們獨立的堆棧,因此它們之間不共享任何數據。但是可能存在堆中的某些數據在多個 goroutine 之間共享的情況。在這種情況下,多個 goroutine 試圖在同一內存位置操作數據,從而導致意外結果。下面展示一個簡單的例子:

https://play.golang.org/p/MQNepChxiEa

在上面的程序中,我們生成了 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。

讓我們用互斥鎖修改前面的例子。

https://play.golang.org/p/xVFAX_0Uig8

在上面的程序中,我們創建了一個互斥鎖 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 【并發模式】

通俗來講,就是日常使用的常用范式。
以下是一些可以使程序更快更可靠的概念和方法。

    1. Generator 【生成器模式】

使用通道,可以實現更好實現生成器。
比如斐波那契數列,計算上開銷很大, 我們可以提前計算好結果,并放入channel中, 等待程序執行到此,直接取結果即可, 而不必等待。

https://play.golang.org/p/1_2MDeqQ3o5

此圖中,調用fib 函數,返回了一個channel,通過循環channel,可以接收到的計算好的數據。在 fib 函數內部,必須返回一個只接收通道,我們創建一個有buffer的通道,并在函數最后返回它。fib的返回值會將這個雙向通道轉換為單向只接收通道。在匿名 goroutine 中,使用 for 循環將斐波那契數推送到此通道,完成后,關閉此通道。在主協程中,使用 fib 函數調用的范圍,我們可以直接訪問這個通道。

    1. 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)
}

參考

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,763評論 6 539
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,238評論 3 428
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,823評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,604評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,339評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,713評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,712評論 3 445
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,893評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,448評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,201評論 3 357
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,397評論 1 372
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,944評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,631評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,033評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,321評論 1 293
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,128評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,347評論 2 377

推薦閱讀更多精彩內容

  • 如果必須選擇 Go 的一項偉大功能,那么它必須是內置的并發模型。它不僅支持并發,而且使它變得更好。 Go Conc...
    癩痢頭閱讀 1,488評論 0 1
  • Channel 單純地將函數并發執行是沒有意義地,函數與函數需要交換數據才能體現并發執行函數地意義。Go語言的并發...
    TZX_0710閱讀 337評論 0 0
  • Go 并發編程 選擇 Go 編程的原因可能是看中它簡單且強大,那么你其實可以選擇C語言;除此之外,我看中 Go 的...
    PRE_ZHY閱讀 902評論 1 6
  • CSP 并發模型 CSP(Communicating Sequential Processes),是用于描述兩個獨...
    朱建濤閱讀 705評論 0 2
  • 開發go程序的時候,時常需要使用goroutine并發處理任務,有時候這些goroutine是相互獨立的,而有的時...
    駐馬聽雪閱讀 2,461評論 0 21