【譯】如何使用 Golang 中的 Go-Routines 寫出高性能的代碼

如何使用 Golang 中的 Go-Routines 寫出高性能的代碼

爲了用 Golang 寫出快速的代碼,你須要看一下 Rob Pike 的視頻 - Go-Routines前端

他是 Golang 的做者之一。若是你尚未看過視頻,請繼續閱讀,這篇文章是我對那個視頻內容的一些我的看法。我感受視頻不是很完整。我猜 Rob 由於時間關係忽略掉了一些他認爲不值得講的觀點。不過我花了不少的時間來寫了一篇綜合全面的關於 go-routines 的文章。我沒有涵蓋視頻中涵蓋的全部主題。我會介紹一些本身用來解決 Golang 常見問題的項目。react

好的,爲了寫出很快的 Golang 程序,有三個概念你須要徹底瞭解,那就是 Go-Routines,閉包,還有管道。android

Go-Routines

讓咱們假設你的任務是將 100 個盒子從一個房間移到另外一個房間。再假設,你一次只能搬一個盒子,並且移動一次會花費一分鐘時間。因此,你會花費 100 分鐘的時間搬完這 100 個箱子。ios

如今,爲了讓加快移動 100 個盒子這個過程,你能夠找到一個方法更快的移動這個盒子(這相似於找一個更好的算法去解決問題)或者你能夠額外僱傭一我的去幫你移動盒子(這相似於增長 CPU 核數用於執行算法)git

這篇文章重點講第二種方法。編寫 go-routines 並利用一個或者多個 CPU 核心去加快應用的執行。github

任何代碼塊在默認狀況下只會使用一個 CPU 核心,除非這個代碼塊中聲明瞭 go-routines。因此,若是你有一個 70 行的,沒有包含 go-routines 的程序。它將會被單個核心執行。就像咱們的例子,一個核心一次只能執行一個指令。所以,若是你想加快應用程序的速度,就必須把全部的 CPU 核心都利用起來。golang

因此,什麼是 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前端後端產品設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索