- 原文地址:Learning Go’s Concurrency Through Illustrations
- 原文作者:Trevor Forrey
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:Elliott Zhao
- 校對者:CACppuccino
通過插圖學習 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]
複製程式碼
這種程式設計風格具有易於設計的優點,但是當你想要利用多個執行緒並執行彼此獨立的功能的時候,會發生什麼情況?這是併發程式設計發揮作用的地方。
這種採礦設計更有效率。現在多執行緒(gopher 們)獨立工作;因此,並不是讓 Gary 完成整個行動。有一個 gopher 尋找礦石,一個開採礦石,另一個冶煉礦石——可能全部在同一時間進行。
為了讓我們將這種型別的功能帶入我們的程式碼中,我們需要兩件事:一種建立獨立工作的 gopher 的方法,以及一種讓 gopher 們相互溝通(傳送礦石)的方法。這就是 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 例程的資訊。
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”)
}
複製程式碼
下一步學習
有很多講座和部落格文章涵蓋通道和例程的更多細節。既然您對這些工具的目的和應用有了紮實的理解,那麼您應該能夠充分利用以下文章和演講。
感謝您抽時間閱讀。我希望你能夠了解 Go 例程,通道以及它們為編寫併發程式帶來的好處。
如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。