[譯] 通過插圖學習 Go 的併發

elliott_zhao發表於2018-06-22

通過插圖學習 Go 的併發

你很可能從各種各樣的途徑聽說過 Go。它因為各種原因而越來越受歡迎。Go 很快,很簡單,並且擁有一個很棒的社群。併發模型是學習這門語言最令人興奮的方面之一。Go 的併發原語使建立併發、多執行緒的程式變得簡單而有趣。我將通過插圖介紹 Go 的併發原語,希望能讓這些概念更加清晰而有助於將來的學習。本文適用於 Go 的新手,並且想要了解Go的併發原語:Go 例程和通道。

單執行緒程式與多執行緒程式

你可能以前寫過很多單執行緒程式。程式設計中一種常見的模式是用多個函式來完成一個特定的任務,但只有在程式的前一部分為下一個函式準備好資料時才會呼叫它們。

[譯] 通過插圖學習 Go 的併發

這就是我們設立的第一個例子,採礦程式。這個例子中的函式執行:尋礦挖礦煉礦。在我們的例子中,礦坑和礦石被表示為一個字串陣列,每個函式接收它們並返回一個“處理好的”字串陣列。對於單執行緒應用程式,程式設計如下。

[譯] 通過插圖學習 Go 的併發

有3個主要函式。一個尋礦者,一個礦工和一個冶煉工。在這個版本的程式中,我們的函式在單個執行緒上執行,一個接一個地執行 - 而這個單執行緒(名為 Gary 的 gopher)需要完成所有工作。

func main() {
 theMine := [5]string{“rock”, “ore”, “ore”, “rock”, “ore”}
 foundOre := finder(theMine)
 minedOre := miner(foundOre)
 smelter(minedOre)
}
複製程式碼

在每個函式的末尾列印出處理後的“礦石”陣列,我們得到以下輸出:

From Finder: [ore ore ore]

From Miner: [minedOre minedOre minedOre]

From Smelter: [smeltedOre smeltedOre smeltedOre]
複製程式碼

這種程式設計風格具有易於設計的優點,但是當你想要利用多個執行緒並執行彼此獨立的功能的時候,會發生什麼情況?這是併發程式設計發揮作用的地方。

[譯] 通過插圖學習 Go 的併發

這種採礦設計更有效率。現在多執行緒(gopher 們)獨立工作;因此,並不是讓 Gary 完成整個行動。有一個 gopher 尋找礦石,一個開採礦石,另一個冶煉礦石——可能全部在同一時間進行。

為了讓我們將這種型別的功能帶入我們的程式碼中,我們需要兩件事:一種建立獨立工作的 gopher 的方法,以及一種讓 gopher 們相互溝通(傳送礦石)的方法。這就是 Go 併發原語進場的地方:Go 例程和通道。

Go 例程

Go 例程可以被認為是輕量級執行緒。建立 Go 例程簡單到只需要將 go 新增到呼叫函式的開始。舉一個簡單的例子,讓我們建立兩個尋礦函式,使用 go 關鍵字呼叫它們,並在他們每次在礦中發現“礦石”時將其列印出來。

[譯] 通過插圖學習 Go 的併發

func main() {
 theMine := [5]string{“rock”, “ore”, “ore”, “rock”, “ore”}
 go finder1(theMine)
 go finder2(theMine)
 <-time.After(time.Second * 5) //你可以先忽略這個
}
複製程式碼

以下是我們程式的輸出結果:

Finder 1 found ore!
Finder 2 found ore!
Finder 1 found ore!
Finder 1 found ore!
Finder 2 found ore!
Finder 2 found ore!
複製程式碼

從上面的輸出中可以看到,尋礦者正在同時執行。誰先發現礦石並沒有真正的順序,並且當多次執行時,順序並不總是相同的。

這是偉大的進步!現在我們有一個簡單的方法來建立一個多執行緒(多 Gopher)程式,但是當我們需要我們獨立的 Go 例程相互通訊時會發生什麼?歡迎來到神奇的通道世界。

通道

[譯] 通過插圖學習 Go 的併發

通道允許例程彼此通訊。您可以將通道視為管道,從中可以傳送和接收來自其他 Go 例程的資訊。

[譯] 通過插圖學習 Go 的併發

myFirstChannel := make(chan string)
複製程式碼

Go 例程可以在通道上傳送接收。這是通過使用指向資料的方向的箭頭(<-)來完成的。

[譯] 通過插圖學習 Go 的併發

myFirstChannel <- "hello" // 傳送
myVariable := <- myFirstChannel // 接收
複製程式碼

現在通過使用一個通道,我們可以讓我們的尋礦 gopher 立即將他們發現的東西傳送給我們的挖礦 gopher,而無需等待全部發現。

[譯] 通過插圖學習 Go 的併發

我已經更新了示例,於是尋礦程式碼和挖礦函式被設定為匿名函式。如果你從來沒有見過lambda函式,不要過多地關注程式的那一部分,只要知道每個函式都是用 go 關鍵字呼叫的,所以它們正在在自己的例程上執行。重要的是注意 Go 例程如何使用通道 oreChan 在彼此之間傳遞資料。別擔心,我會在最後解釋匿名函式。

func main() {
 theMine := [5]string{“ore1”, “ore2”, “ore3”}
 oreChan := make(chan string)

 // 尋礦者
 go func(mine [5]string) {
  for _, item := range mine {
   oreChan <- item //send
  }
 }(theMine)

 // 礦工
 go func() {
  for i := 0; i < 3; i++ {
   foundOre := <-oreChan //接收
   fmt.Println(“Miner: Received “ + foundOre + “ from finder”)
  }
 }()
 <-time.After(time.Second * 5) // 還是先忽略這個
}
複製程式碼

在下面的輸出中,您可以看到我們的礦工三次通過礦石通道讀取,每次接收到一塊“礦石”。

Miner: Received ore1 from finder

Miner: Received ore2 from finder

Miner: Received ore3 from finder

太好了,現在我們可以在程式中的不同 Go 例程(gophers)之間傳送資料。在我們開始編寫帶有通道的複雜程式之前,讓我們首先介紹一些理解通道屬性的關鍵點。

通道阻塞

在多種情況下,通道會阻塞例程。這允許我們的 Go 例程在彼此踏上各自的愉悅旅途之前先進行同步。

傳送阻塞

[譯] 通過插圖學習 Go 的併發

一旦一個 Go 例程(gopher)在一個通道上傳送,進行傳送的 Go 例程就會阻塞,直到另一個 Go 例程收到通道傳送的資訊為止。

接收阻塞

[譯] 通過插圖學習 Go 的併發

類似於在通道上傳送後的阻塞,Go例程在等待從通道獲取值,但還沒有傳送給它的時候會阻塞。

一開始,阻塞可能有點難以理解,但你可以把它想象成兩個 Go 例程(gophers)之間的交易。無論 gopher 是等待金錢還是匯款,都會等待交易中的其他合作伙伴出現。

現在我們對 Go 例程通過通道進行通訊的時候會阻塞的不同方式有了一個印象,讓我們討論兩種不同型別的通道:無緩衝,和緩衝。選擇使用什麼型別的通道可以改變你的程式的行為。

無緩衝通道

[譯] 通過插圖學習 Go 的併發

在之前的所有例子中,我們都使用了無緩衝的通道。它們的特殊之處在於,一次只有一條資料能夠通過通道。

緩衝通道

[譯] 通過插圖學習 Go 的併發

在併發程式中,時序並不總是完美的。在我們的採礦案例中,我們可能會遇到這樣一種情況:我們的尋礦 gopher 可以在礦工 gopher 處理一塊礦石的時間內找到 3 塊礦石。為了不讓尋礦 gopher 把大部分時間花費在等待給礦工 gopher 的工作完成上,我們可以使用緩衝通道。讓我們開始做一個容量為 3 的緩衝通道。

bufferedChan := make(chan string, 3)
複製程式碼

緩衝通道的工作原理類似於無緩衝通道,僅有一點不同 —— 我們可以在需要另外的 Go 例程讀取通道之前將多條資料傳送到通道。

[譯] 通過插圖學習 Go 的併發

bufferedChan := make(chan string, 3)

go func() {
 bufferedChan <- "first"
 fmt.Println("Sent 1st")
 bufferedChan <- "second"
 fmt.Println("Sent 2nd")
 bufferedChan <- "third"
 fmt.Println("Sent 3rd")
}()

<-time.After(time.Second * 1)

go func() {
 firstRead := <- bufferedChan
 fmt.Println("Receiving..")
 fmt.Println(firstRead)
 secondRead := <- bufferedChan
 fmt.Println(secondRead)
 thirdRead := <- bufferedChan
 fmt.Println(thirdRead)
}()
複製程式碼

我們兩個 Go 例程之間的列印順序是:

Sent 1st
Sent 2nd
Sent 3rd
Receiving..
first
second
third
複製程式碼

為了簡單起見,我們不會在最終程式中使用緩衝通道,但瞭解併發工具帶中可用的通道型別很重要。

注意:使用緩衝通道不會阻止阻塞的發生。例如,如果尋礦 gopher 比礦工快 10 倍,並且它們通過大小為 2 的緩衝通道進行通訊,則發現 gopher 仍將在程式中多次阻塞。

把它們結合起來

現在憑藉 Go 例程和通道的強大功能,我們可以編寫一個程式,使用 Go 的併發原語來充分利用多執行緒。

[譯] 通過插圖學習 Go 的併發

theMine := [5]string{"rock", "ore", "ore", "rock", "ore"}
oreChannel := make(chan string)
minedOreChan := make(chan string)
// Finder
go func(mine [5]string) {
 for _, item := range mine {
  if item == "ore" {
   oreChannel <- item //在 oreChannel 上傳送東西
  }
 }
}(theMine)
// Ore Breaker
go func() {
 for i := 0; i < 3; i++ {
  foundOre := <-oreChannel //從 oreChannel 上讀取
  fmt.Println("From Finder: ", foundOre)
  minedOreChan <- "minedOre" //向 minedOreChan 傳送
 }
}()
// Smelter
go func() {
 for i := 0; i < 3; i++ {
  minedOre := <-minedOreChan //從 minedOreChan 讀取
  fmt.Println("From Miner: ", minedOre)
  fmt.Println("From Smelter: Ore is smelted")
 }
}()
<-time.After(time.Second * 5) // 還是一樣,你可以忽略這些
複製程式碼

程式的輸出如下:

From Finder:  ore

From Finder:  ore

From Miner:  minedOre

From Smelter: Ore is smelted

From Miner:  minedOre

From Smelter: Ore is smelted

From Finder:  ore

From Miner:  minedOre

From Smelter: Ore is smelted
複製程式碼

與我們原來的例子相比,這是一個很大的改進!現在,我們的每個函式都是獨立執行在自己的 Go 例程上的。另外,每一塊礦石在處理之後,都會進入我們採礦線的下一個階段。

為了將注意力集中在瞭解通道和 Go 例程的基礎知識上,有一些我沒有提到的重要資訊 —— 如果你不知道,當你開始程式設計時可能會造成一些麻煩。現在您已瞭解 Go 例程和通道的工作原理,讓我們在開始使用 Go 例程和通道編寫程式碼之前,先了解一些您應該瞭解的資訊。

在出發前,你應該知道……

匿名 Go 例程

[譯] 通過插圖學習 Go 的併發

類似於我們可以使用 go 關鍵字設定一個可以執行自己的 Go 例程的函式,我們可以使用以下格式建立一個匿名函式來執行自己的 Go 例程:

// 匿名 Go 例程
go func() {
 fmt.Println("I'm running in my own go routine")
}()
複製程式碼

這樣,如果我們只需要呼叫一次函式,我們可以將它放在自己的 Go 例程中執行,而不用擔心建立官方函式宣告。

主函式是一個 Go 例程

[譯] 通過插圖學習 Go 的併發

主程式實際上是在自己的 Go 例程中執行的!更重要的是要知道,一旦主函式返回,它將關閉其它所有正在執行的例程。這就是為什麼我們在主函式底部有一個計時器 —— 它建立了一個通道,並在 5 秒後傳送了一個值。

<-time.After(time.Second * 5) //在 5 秒後從通道接收
複製程式碼

還記得一個 Go 例程是如何阻塞一個讀取,直到一些東西被髮送的嗎?通過新增上面的程式碼,這正是主例程發生的情況。主例程會阻塞,給我們其他的例程 5 秒額外的生命執行。

現在有更好的方法來處理阻塞主函式,直到所有其他的 Go 例程完成。通常的做法是建立一個主函式在等待讀取時阻塞的 done 通道。一旦你完成你的工作,寫入這個通道,程式將結束。

[譯] 通過插圖學習 Go 的併發

func main() {
 doneChan := make(chan string)
 go func() {
  // Do some work…
  doneChan <- “I’m all done!”
 }()
 
 <-doneChan // 阻塞直到 Go 例程發出工作完成的訊號
}
複製程式碼

您可以在通道上範圍取值

在前面的例子中,我們讓我們的礦工在 for 迴圈中經歷了 3 次迭代讀取通道。如果我們不知道究竟尋礦者會傳送多少礦石,會發生什麼?那麼,類似於在集合上範圍取值,你可以在通道上範圍取值

更新我們以前的礦工函式,我們可以寫:

 // 礦工
 go func() {
  for foundOre := range oreChan {
   fmt.Println(“Miner: Received “ + foundOre + “ from finder”)
  }
 }()
複製程式碼

由於礦工需要讀取尋礦者傳送給他的所有內容,因此在此通道上範圍取值能夠確保我們收到傳送的所有內容。

注意:對通道進行範圍取值將會阻塞通道,直到通道上傳送另一個包裹。在發生所有傳送之後,阻止 Go 例程阻塞的唯一方法是通過關閉通道 'close(channel)'。

您可以在通道上進行非阻塞讀取

但你剛才告訴我們的全是通道如何阻塞 Go 例程?!沒錯,但是有一種技術可以使用 Go 的 select case 結構在通道上進行非阻塞式讀取。通過使用下面的結構,如果有東西的話,您的 Go 例程將從通道中讀取,否則執行預設情況。

myChan := make(chan string)
 
go func(){
 myChan <- “Message!”
}()
 
select {
 case msg := <- myChan:
  fmt.Println(msg)
 default:
  fmt.Println(“No Msg”)
}
<-time.After(time.Second * 1)
select {
 case msg := <- myChan:
  fmt.Println(msg)
 default:
  fmt.Println(“No Msg”)
}
複製程式碼

執行時,此示例具有以下輸出:

No Msg  
Message!
複製程式碼

您也可以在通道上進行非阻塞式傳送

非阻塞傳送使用相同的 select case 結構來執行其非阻塞操作,唯一的區別是我們的情況看起來像傳送而不是接收。

select {  
 case myChan <- “message”:  
  fmt.Println(“sent the message”)  
 default:  
  fmt.Println(“no message sent”)  
}
複製程式碼

下一步學習

[譯] 通過插圖學習 Go 的併發

有很多講座和部落格文章涵蓋通道和例程的更多細節。既然您對這些工具的目的和應用有了紮實的理解,那麼您應該能夠充分利用以下文章和演講。

Google I/O 2012 — Go 併發模式

Rob Pike — ‘併發並非並行’

GopherCon 2017: Edward Muller — Go 反模式

感謝您抽時間閱讀。我希望你能夠了解 Go 例程,通道以及它們為編寫併發程式帶來的好處。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章