【譯】如何使用 Golang 中的 Go-Routines 寫出高效能的程式碼

臨書發表於2017-11-24

如何使用 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,我們需要做三件事。

  1. 我們建立一個匿名函式
  2. 我們呼叫這個匿名函式
  3. 我們使用 「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,在這些語言中,

  1. 函式只訪問兩種型別的變數,全域性變數和區域性變數(函式內部的變數)。
  2. 沒有函式可以訪問宣告在其他函式裡的變數。
  3. 一旦函式執行完畢,這個函式中宣告的所有變數都會消失。

對 Golang,Python 或者 JavaScript 這些支援閉包屬性的語言,以上都是不正確的,原因在於,這些語言擁有以下的靈活性。

  1. 函式可以宣告在函式內。
  2. 函式可以返回函式。

推論 #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
}複製程式碼

這兒有兩個函式 - 主函式和子函式,其中子函式定義在主函式中。子函式訪問

  1. zero 變數 - 它是全域性變數
  2. one 變數 - 閉包屬性 - one 屬於主函式,它在主函式中且定義在子函式之前。
  3. 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」。

  1. 它可以訪問到 A、B、C 變數,和閉包無關。
  2. 它無法訪問 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
    }
}複製程式碼

解釋一下我們做了什麼...

  1. 我們為每個任務建立一個 go-routine。我們的系統能同時支援「R」個 go-routines。只要 N≤R 我們這麼做就是安全的。
  2. 我們確認 main 函式在等待所有 go-routine 完成的時候才返回。我們通過等待所有 go-routine(通過閉包屬性)使用的確認 channel(「ack」)來傳達其完成。
  3. 我們傳遞迴圈計數「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
}複製程式碼
  1. 建立一個包含有「R」個 go-routines 的池。不多也不少,所有對「input」channel 的監聽通過閉包屬性來引用。
  2. 建立 go-routines,它通過在每次迴圈中檢查 ok 引數來判斷 channel 是否關閉,如果 channel 關閉則殺死自己。
  3. 返回 input channel 來允許呼叫者函式分配任務給池。
  4. 使用「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


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

相關文章