- 原文地址:How to write high-performance code in Golang using Go-Routines
- 原文作者:Vignesh Sk
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:tmpbook
- 校對者:altairlu
如何使用 Golang 中的 Go-Routines 寫出高效能的程式碼
為了用 Golang 寫出快速的程式碼,你需要看一下 Rob Pike 的視訊 - Go-Routines。
他是 Golang 的作者之一。如果你還沒有看過視訊,請繼續閱讀,這篇文章是我對那個視訊內容的一些個人見解。我感覺視訊不是很完整。我猜 Rob 因為時間關係忽略掉了一些他認為不值得講的觀點。不過我花了很多的時間來寫了一篇綜合全面的關於 go-routines 的文章。我沒有涵蓋視訊中涵蓋的所有主題。我會介紹一些自己用來解決 Golang 常見問題的專案。
好的,為了寫出很快的 Golang 程式,有三個概念你需要完全瞭解,那就是 Go-Routines,閉包,還有管道。
Go-Routines
讓我們假設你的任務是將 100 個盒子從一個房間移到另一個房間。再假設,你一次只能搬一個盒子,而且移動一次會花費一分鐘時間。所以,你會花費 100 分鐘的時間搬完這 100 個箱子。
現在,為了讓加快移動 100 個盒子這個過程,你可以找到一個方法更快的移動這個盒子(這類似於找一個更好的演算法去解決問題)或者你可以額外僱傭一個人去幫你移動盒子(這類似於增加 CPU 核數用於執行演算法)
這篇文章重點講第二種方法。編寫 go-routines 並利用一個或者多個 CPU 核心去加快應用的執行。
任何程式碼塊在預設情況下只會使用一個 CPU 核心,除非這個程式碼塊中宣告瞭 go-routines。所以,如果你有一個 70 行的,沒有包含 go-routines 的程式。它將會被單個核心執行。就像我們的例子,一個核心一次只能執行一個指令。因此,如果你想加快應用程式的速度,就必須把所有的 CPU 核心都利用起來。
所以,什麼是 go-routine。如何在 Golang 中宣告它?
讓我們看一個簡單的程式並介紹其中的 go-routine。
示例程式 1
假設移動一個盒子相當於列印一行標準輸出。那麼,我們的例項程式中有 10 個列印語句(因為沒有使用 for 迴圈,我們只移動 10 個盒子)。
package main
import "fmt"
func main() {
fmt.Println("Box 1")
fmt.Println("Box 2")
fmt.Println("Box 3")
fmt.Println("Box 4")
fmt.Println("Box 5")
fmt.Println("Box 6")
fmt.Println("Box 7")
fmt.Println("Box 8")
fmt.Println("Box 9")
fmt.Println("Box 10")
}複製程式碼
因為 go-routines 沒有被宣告,上面的程式碼產生了如下輸出。
輸出
Box 1
Box 2
Box 3
Box 4
Box 5
Box 6
Box 7
Box 8
Box 9
Box 10複製程式碼
所以,如果我們想在在移動盒子這個過程中使用額外的 CPU 核心,我們需要宣告一個 go-routine。
包含 Go-Routines 的示例程式 2
package main
import "fmt"
func main() {
go func() {
fmt.Println("Box 1")
fmt.Println("Box 2")
fmt.Println("Box 3")
}()
fmt.Println("Box 4")
fmt.Println("Box 5")
fmt.Println("Box 6")
fmt.Println("Box 7")
fmt.Println("Box 8")
fmt.Println("Box 9")
fmt.Println("Box 10")
}複製程式碼
這兒,一個 go-routine 被宣告且包含了前三個列印語句。意思是處理 main 函式的核心只執行 4-10 行的語句。另一個不同的核心被分配去執行 1-3 行的語句塊。
輸出
Box 4
Box 5
Box 6
Box 1
Box 7
Box 8
Box 2
Box 9
Box 3
Box 10複製程式碼
分析輸出
在這段程式碼中,有兩個 CPU 核心同時執行,試圖執行他們的任務,並且這兩個核心都依賴標準輸出來完成它們相應的任務(因為這個示例中我們使用了 print 語句)
換句話來說,標準輸出(執行在它自己的一個核心上)一次只能接受一個任務。所以,你在這兒看到的是一種隨機的排序,這取決於標準輸出決定接受 core1 core2 哪個的任務。
如何宣告 go-routine?
為了宣告我們自己的 go-routine,我們需要做三件事。
- 我們建立一個匿名函式
- 我們呼叫這個匿名函式
- 我們使用 「go」關鍵字來呼叫
所以,第一步是採用定義函式的語法,但忽略定義函式名(匿名)來完成的。
func() {
fmt.Println("Box 1")
fmt.Println("Box 2")
fmt.Println("Box 3")
}複製程式碼
第二步是通過將空括號新增到匿名方法後面來完成的。這是一種叫命名函式的方法。
func() {
fmt.Println("Box 1")
fmt.Println("Box 2")
fmt.Println("Box 3")
} ()複製程式碼
步驟三可以通過 go 關鍵字來完成。什麼是 go 關鍵字呢,它可以將功能塊宣告為可以獨立執行的程式碼塊。這樣的話,它可以讓這個程式碼塊被系統上其他空閒的核心所執行。
#細節 1:當 go-routines 的數量比核心數量多的時候會發生什麼?
單個核心通過上下文切換並行執行多個go程式來實現多個核心的錯覺。
#自己試試之1:試著移除示例程式2中的 go 關鍵字。輸出是什麼呢?
答案:示例程式2的結果和1一模一樣。
#自己試試之 2:將匿名函式中的語句從 3 增加至 8 個。結果改變了嗎?
答案:是的。main 函式是一個母親 go-routine(其他所有的 go-routine 都在它裡面被宣告和建立)。所以,當母親 go-routine 執行結束,即使其他 go-routines 執行到中途,它們也會被殺掉然後返回。
我們現在已經知道 go-routines 是什麼了。接下來讓我們來看看閉包。
如果之前沒有在 Python 或者 JavaScript 中學過閉包,你可以現在在 Golang 中學習它。學到的人可以跳過這部分來節省時間,因為 Golang 中的閉包和 Python 或者 JavaScript 中是一樣的。
在我們深入理解閉包之前。讓我們先看看不支援閉包屬性的語言比如 C,C++ 和 Java,在這些語言中,
- 函式只訪問兩種型別的變數,全域性變數和區域性變數(函式內部的變數)。
- 沒有函式可以訪問宣告在其他函式裡的變數。
- 一旦函式執行完畢,這個函式中宣告的所有變數都會消失。
對 Golang,Python 或者 JavaScript 這些支援閉包屬性的語言,以上都是不正確的,原因在於,這些語言擁有以下的靈活性。
- 函式可以宣告在函式內。
- 函式可以返回函式。
推論 #1:因為函式可以被宣告在函式內部,一個函式宣告在另一個函式內的巢狀鏈是這種靈活性的常見副產品。
為了瞭解為什麼這兩個靈活性完全改變了運作方式,讓我們看看什麼是閉包。
所以什麼是閉包?
除了訪問區域性變數和全域性變數,函式還可以訪問函式宣告中宣告的所有區域性變數,只要它們是在之前宣告的(包括在執行時傳遞給閉包函式的所有引數),在巢狀的情況下,函式可以訪問所有函式的變數(無論閉包的級別如何)。
為了理解的更好,讓我們考慮一個簡單的情況,兩個函式,一個包含另一個。
package main
import "fmt"
var zero int = 0
func main() {
var one int = 1
child := func() {
var two int = 3
fmt.Println(zero)
fmt.Println(one)
fmt.Println(two)
fmt.Println(three) // causes compilation Error
}
child()
var three int = 2
}複製程式碼
這兒有兩個函式 - 主函式和子函式,其中子函式定義在主函式中。子函式訪問
- zero 變數 - 它是全域性變數
- one 變數 - 閉包屬性 - one 屬於主函式,它在主函式中且定義在子函式之前。
- two 變數 - 它是子函式的區域性變數
注意:雖然它被定義在封閉函式「main」中,但它不能訪問 three 變數,因為後者的宣告在子函式的定義後面。
和巢狀一樣。
package main
import "fmt"
var global func()
func closure() {
var A int = 1
func() {
var B int = 2
func() {
var C int = 3
global = func() {
fmt.Println(A, B, C)
fmt.Println(D, E, F) // causes compilation error
}
var D int = 4
}()
var E int = 5
}()
var F int = 6
}
func main() {
closure()
global()
}複製程式碼
如果我們考慮一下將一個最內層的函式關聯給一個全域性變數「global」。
- 它可以訪問到 A、B、C 變數,和閉包無關。
- 它無法訪問 D、E、F 變數,因為它們之前沒有定義。
注意:即使閉包執行完了,它的區域性變數任然不會被銷燬。它們仍然能夠通過名字是 「global」的函式名去訪問。
下面介紹一下 Channels。
Channels 是 go-routines 之間通訊的一種資源,它們可以是任意型別。
ch := make(chan string)複製程式碼
我們定義了一個叫做 ch 的 string 型別的 channel。只有 string 型別的變數可以通過此 channel 通訊。
ch <- "Hi"複製程式碼
就是這樣傳送訊息到 channel 中。
msg := <- ch複製程式碼
這是如何從 channel 中接收訊息。
所有 channel 中的操作(傳送和接收)本質上是阻塞的。這意味著如果一個 go-routine 試圖通過 channel 傳送一個訊息,那麼只有在存在另一個 go-routine 正在試圖從 channel 中取訊息的時候才會成功。如果沒有 go-routine 在 channel 那裡等待接收,作為傳送方的 go-routine 就會永遠嘗試傳送訊息給某個接收方。
最重要的點是這裡,跟在 channel 操作後面的所有的語句在 channel 操作結束之前是不會執行的,go-routine 可以解鎖自己然後執行跟在它後面的的語句。這有助於同步其他程式碼塊的各種 go-routine。
免責宣告:如果只有傳送方的 go-routine,沒有其他的 go-routine。那麼會發生死鎖,go 程式會檢測出死鎖並崩潰。
注意:所有以上講的也都適用於接收方 go-routines。
緩衝 Channels
ch := make(chan string, 100)複製程式碼
緩衝 channels 本質上是半阻塞的。
比如,ch 是一個 100 大小的緩衝字元 channel。這意味著前 100 個傳送給它的訊息是非阻塞的。後面的就會阻塞掉。
這種型別的 channels 的用處在於從它中接收訊息之後會再次釋放緩衝區,這意味著,如果有 100 個新 go-routines 程式突然出現,每個都從 channel 中消費一個訊息,那麼來自傳送者的下 100 個訊息將會再次變為非阻塞。
所以,一個緩衝 channel 的行為是否和非緩衝 channel 一樣,取決於緩衝區在執行時是否空閒。
Channels 的關閉
close(ch)複製程式碼
這就是如何關閉 channel。在 Golang 中它對避免死鎖很有幫助。接收方的 go-routine 可以像下面這樣探測 channel 是否關閉了。
msg, ok := <- ch
if !ok {
fmt.Println("Channel closed")
}複製程式碼
使用 Golang 寫出很快的程式碼
現在我們講的知識點已經涵蓋了 go-routines,閉包,channel。考慮到移動盒子的演算法已經很有效率,我們可以開始使用 Golang 開發一個通用的解決方案來解決問題,我們只關注為任務僱傭合適的人的數量。
讓我們仔細看看我們的問題,重新定義它。
我們有 100 個盒子需要從一個房間移動到另一個房間。需要著重說明的一點是,移動盒子1和移動盒子2涉及的工作沒有什麼不同。因此我們可以定義一個移動盒子的方法,變數「i」代表被移動的盒子。方法叫做「任務」,盒子數量用「N」表示。任何「計算機程式設計基礎 101」課程都會教你如何解決這個問題:寫一個 for 迴圈呼叫「任務」N 次,這導致計算被單核心佔用,而系統中的可用核心是個硬體問題,取決於系統的品牌,型號和設計。所以作為軟體開發人員,我們將硬體從我們的問題中抽離出去,來討論 go-routines 而不是核心。越多的核心就支援越多的 go-routines,我們假設「R」是我們「X」核心系統所支援的 go-routines 數量。
FYI:數量「X」的核心數量可以處理超過數量「X」的 go-routines。單個核心支援的 go-routines 數量(R/X)取決於 go-routines 涉及的處理方式和執行時所在的平臺。比如,如果所有的 go-routine 僅涉及阻塞呼叫,例如網路 I/O 或者 磁碟 I/O,則單個核心足以處理它們。這是真的,因為每個 go-routine 相比運算來說更多的在等待。因此,單個核心可以處理所有 go-routine 之間的上下文切換。
因此我們的問題的一般性的定義為
將「N」個任務分配給「R」個 go-routines,其中所有的任務都相同。
如果 N≤R,我們可以用以下方式解決。
package main
import "fmt"
var N int = 100
func Task(i int) {
fmt.Println("Box", i)
}
func main() {
ack := make(chan bool, N) // Acknowledgement channel
for i := 0; i < N; i++ {
go func(arg int) { // Point #1
Task(arg)
ack <- true // Point #2
}(i) // Point #3
}
for i := 0; i < N; i++ {
<-ack // Point #2
}
}複製程式碼
解釋一下我們做了什麼...
- 我們為每個任務建立一個 go-routine。我們的系統能同時支援「R」個 go-routines。只要 N≤R 我們這麼做就是安全的。
- 我們確認 main 函式在等待所有 go-routine 完成的時候才返回。我們通過等待所有 go-routine(通過閉包屬性)使用的確認 channel(「ack」)來傳達其完成。
- 我們傳遞迴圈計數「i」作為引數「arg」給 go-routine,而不是通過閉包屬性在 go-routine 中直接引用它。
另一方面,如果 N>R,則上述解決方法會有問題。它會建立系統不能處理的 go-routines。所有核心都嘗試執行更多的,超過其容量的 go-routines,最終將會把更多的時間話費在上下文切換上而不是執行程式(俗稱抖動)。當 N 和 R 之間的數量差異越來越大,上下文切換的開銷會更加突出。因此要始終將 go-routine 的數量限制為 R。並將 N 個任務分配給 R 個 go-routines。
下面我們介紹 workers 函式
var R int = 100
func Workers(task func(int)) chan int { // Point #4
input := make(chan int) // Point #1
for i := 0; i < R; i++ { // Point #1
go func() {
for {
v, ok := <-input // Point #2
if ok {
task(v) // Point #4
} else {
return // Point #2
}
}
}()
}
return input // Point #3
}複製程式碼
- 建立一個包含有「R」個 go-routines 的池。不多也不少,所有對「input」channel 的監聽通過閉包屬性來引用。
- 建立 go-routines,它通過在每次迴圈中檢查 ok 引數來判斷 channel 是否關閉,如果 channel 關閉則殺死自己。
- 返回 input channel 來允許呼叫者函式分配任務給池。
- 使用「task」引數來允許呼叫函式定義 go-routines 的主體。
使用
func main() {
ack := make(chan bool, N)
workers := Workers(func(a int) { // Point #2
Task(a)
ack <- true // Point #1
})
for i := 0; i < N; i++ {
workers <- i
}
for i := 0; i < N; i++ { // Point #3
<-ack
}
}複製程式碼
通過將語句(Point #1)新增到 worker 方法中(Point #2),閉包屬性巧妙的在任務引數定義中新增了對確認 channel 的呼叫,我們使用這個迴圈(Point #3)來使 main 函式有一個機制去知道池中的所有 go-routine 是否都完成了任務。所有和 go-routines 相關的邏輯都應該包含在 worker 自己中,因為它們是在其中建立的。main 函式不應該知道內部 worker 函式們的工作細節。
因此,為了實現完全的抽象,我們要引入一個『climax』函式,只有在池中所有 go-routine 全部完成之後才執行。這是通過設定另一個單獨檢查池狀態的 go-routine 來實現的,另外不同的問題需要不同型別的 channel 型別。相同的 int cannel 不能在所有情況下使用,所以,為了寫一個更通用的 worker 函式,我們將使用空介面型別重新定義一個 worker 函式。
package main
import "fmt"
var N int = 100
var R int = 100
func Task(i int) {
fmt.Println("Box", i)
}
func Workers(task func(interface{}), climax func()) chan interface{} {
input := make(chan interface{})
ack := make(chan bool)
for i := 0; i < R; i++ {
go func() {
for {
v, ok := <-input
if ok {
task(v)
ack <- true
} else {
return
}
}
}()
}
go func() {
for i := 0; i < R; i++ {
<-ack
}
climax()
}()
return input
}
func main() {
exit := make(chan bool)
workers := Workers(func(a interface{}) {
Task(a.(int))
}, func() {
exit <- true
})
for i := 0; i < N; i++ {
workers <- i
}
close(workers)
<-exit
}複製程式碼
你看,我已經試圖展示了 Golang 的力量。我們還研究瞭如何在 Golang 中編寫高效能程式碼。
請觀看 Rob Pike 的 Go-Routines 視訊,然後和 Golang 度過一個美好的時光。
直到下次...
感謝 Prateek Nischal。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、React、前端、後端、產品、設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。