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

經過插圖學習 Go 的併發

你極可能從各類各樣的途徑據說過 Go。它由於各類緣由而愈來愈受歡迎。Go 很快,很簡單,而且擁有一個很棒的社區。併發模型是學習這門語言最使人興奮的方面之一。Go 的併發原語使建立併發、多線程的程序變得簡單而有趣。我將經過插圖介紹 Go 的併發原語,但願能讓這些概念更加清晰而有助於未來的學習。本文適用於 Go 的新手,而且想要了解Go的併發原語:Go 例程和通道。前端

單線程程序與多線程程序

你可能之前寫過不少單線程程序。編程中一種常見的模式是用多個函數來完成一個特定的任務,但只有在程序的前一部分爲下一個函數準備好數據時纔會調用它們。android

這就是咱們設立的第一個例子,採礦程序。這個例子中的函數執行:尋礦挖礦煉礦。在咱們的例子中,礦坑和礦石被表示爲一個字符串數組,每一個函數接收它們並返回一個「處理好的」字符串數組。對於單線程應用程序,程序設計以下。ios

有3個主要函數。一個尋礦者,一個礦工和一個冶煉工。在這個版本的程序中,咱們的函數在單個線程上運行,一個接一個地運行 - 而這個單線程(名爲 Gary 的 gopher)須要完成全部工做。git

func main() {
 theMine := [5]string{「rock」, 「ore」, 「ore」, 「rock」, 「ore」}
 foundOre := finder(theMine)
 minedOre := miner(foundOre)
 smelter(minedOre)
}
複製代碼

在每一個函數的末尾打印出處理後的「礦石」數組,咱們獲得如下輸出:github

From Finder: [ore ore ore]

From Miner: [minedOre minedOre minedOre]

From Smelter: [smeltedOre smeltedOre smeltedOre]
複製代碼

這種編程風格具備易於設計的優勢,可是當你想要利用多個線程並執行彼此獨立的功能的時候,會發生什麼狀況?這是併發編程發揮做用的地方。編程

這種採礦設計更有效率。如今多線程(gopher 們)獨立工做;所以,並非讓 Gary 完成整個行動。有一個 gopher 尋找礦石,一個開採礦石,另外一個冶煉礦石——可能所有在同一時間進行。後端

爲了讓咱們將這種類型的功能帶入咱們的代碼中,咱們須要兩件事:一種建立獨立工做的 gopher 的方法,以及一種讓 gopher 們相互溝通(發送礦石)的方法。這就是 Go 併發原語進場的地方:Go 例程和通道。數組

Go 例程

Go 例程能夠被認爲是輕量級線程。建立 Go 例程簡單到只須要將 go 添加到調用函數的開始。舉一個簡單的例子,讓咱們建立兩個尋礦函數,使用 go 關鍵字調用它們,並在他們每次在礦中發現「礦石」時將其打印出來。bash

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 例程的信息。

myFirstChannel := make(chan string)
複製代碼

Go 例程能夠在通道上發送接收。這是經過使用指向數據的方向的箭頭(<-)來完成的。

myFirstChannel <- "hello" // 發送
myVariable := <- myFirstChannel // 接收
複製代碼

如今經過使用一個通道,咱們可讓咱們的尋礦 gopher 當即將他們發現的東西發送給咱們的挖礦 gopher,而無需等待所有發現。

我已經更新了示例,因而尋礦代碼和挖礦函數被設置爲匿名函數。若是你歷來沒有見過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 例程(gopher)在一個通道上發送,進行發送的 Go 例程就會阻塞,直到另外一個 Go 例程收到通道發送的信息爲止。

接收阻塞

相似於在通道上發送後的阻塞,Go例程在等待從通道獲取值,但尚未發送給它的時候會阻塞。

一開始,阻塞可能有點難以理解,但你能夠把它想象成兩個 Go 例程(gophers)之間的交易。不管 gopher 是等待金錢仍是匯款,都會等待交易中的其餘合做夥伴出現。

如今咱們對 Go 例程經過通道進行通訊的時候會阻塞的不一樣方式有了一個印象,讓咱們討論兩種不一樣類型的通道:無緩衝,和緩衝。選擇使用什麼類型的通道能夠改變你的程序的行爲。

無緩衝通道

在以前的全部例子中,咱們都使用了無緩衝的通道。它們的特殊之處在於,一次只有一條數據可以經過通道。

緩衝通道

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

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

緩衝通道的工做原理相似於無緩衝通道,僅有一點不一樣 —— 咱們能夠在須要另外的 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 的併發原語來充分利用多線程。

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 func() {
 fmt.Println("I'm running in my own go routine")
}()
複製代碼

這樣,若是咱們只須要調用一次函數,咱們能夠將它放在本身的 Go 例程中運行,而不用擔憂建立官方函數聲明。

主函數是一個 Go 例程

主程序其實是在本身的 Go 例程中運行的!更重要的是要知道,一旦主函數返回,它將關閉其它全部正在運行的例程。這就是爲何咱們在主函數底部有一個計時器 —— 它建立了一個通道,並在 5 秒後發送了一個值。

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

還記得一個 Go 例程是如何阻塞一個讀取,直到一些東西被髮送的嗎?經過添加上面的代碼,這正是主例程發生的狀況。主例程會阻塞,給咱們其餘的例程 5 秒額外的生命運行。

如今有更好的方法來處理阻塞主函數,直到全部其餘的 Go 例程完成。一般的作法是建立一個主函數在等待讀取時阻塞的 done 通道。一旦你完成你的工做,寫入這個通道,程序將結束。

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」)  
}
複製代碼

下一步學習

有不少講座和博客文章涵蓋通道和例程的更多細節。既然您對這些工具的目的和應用有了紮實的理解,那麼您應該可以充分利用如下文章和演講。

Google I/O 2012 — Go 併發模式

Rob Pike — ‘併發並不是並行’

GopherCon 2017: Edward Muller — Go 反模式

感謝您抽時間閱讀。我但願你可以瞭解 Go 例程,通道以及它們爲編寫併發程序帶來的好處。

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


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

相關文章
相關標籤/搜索