最佳化Go程式的簡單技巧 - stephen.sh

banq發表於2019-06-19

根據我的經驗,效能不佳表現為以下兩種方式之一:
  • 在小規模上表現良好的運營,但隨著使用者數量的增長而變得不可行。這些通常是O(N)或O(N²)操作。當您的使用者群很小時,這些表現很好,通常是為了將產品推向市場。隨著您的使用基礎的增長,您會看到更多您不期望的病態示例,並且您的服務將停止執行。
  • 許多個別小最佳化的來源 - AKA'千人死亡'。

我的職業生涯大部分時間都是用Python做資料科學,或者用Go構建服務; 我有更多最佳化後者的經驗。Go通常不是我編寫的服務的瓶頸 - 程式通常在與資料庫通訊時受到IO限制。但是,在批處理機器學習管道中 - 就像我在之前的角色中構建的那樣 - 您的程式通常受CPU限制。當您的Go程式使用過多的CPU,並且過度使用會產生負面影響時,您可以使用各種策略來緩解這種情況。
這篇文章解釋了一些可以用來顯著提高程式效能的技巧。我故意忽略需要付出巨大努力的技術,或者對程式結構進行大量更改。

在你開始之前
在對程式進行任何更改之前,請花時間建立適當的基線進行比較。如果你不這樣做,你會在黑暗中四處搜尋,想知道你的改變是否有任何改善。首先編寫基準測試,並獲取在pprof中使用的配置檔案。在最好的情況下,這將是一個Go基準:這允許輕鬆使用pprof和記憶體分配分析。您還應該使用benchcmp:一個有用的工具,用於比較兩個基準測試之間的效能差異。
如果您的程式碼不容易進行基準測試,那麼請從您可以計算的時間開始。您可以使用手動配置程式碼runtime/pprof
讓我們開始吧!

使用sync.Pool重新使用以前分配的物件

sync.Pool實現一個 free-list.。這允許您重新使用先前分配的struct 。這會在多個用法中分配物件的分配,從而減少垃圾收集器必須完成的工作。API非常簡單:實現一個分配物件新例項的函式。它應該返回一個指標型別。

var bufpool = sync.Pool{
    New: func() interface{} {
        buf := make([]byte, 512)
        return &buf
    }}


在此之後,您可以Get()從池中獲取物件,在Put()完成後將它們返回。

// sync.Pool returns a interface{}: you must cast it to the underlying type
// before you use it.
b := *bufpool.Get().(*[]byte)
defer bufpool.Put(&b)


// Now, go do interesting things with your byte buffer.
buf := bytes.NewBuffer(b)


在Go 1.13之前,每次發生垃圾收集時,池都被清除。這可能會對分配很多的程式的效能產生不利影響。在1.13中,似乎更多的物件將在GC中存活下來

在將物件放回池中之前,必須將struct的欄位清零。

如果不這樣做,則可以從池中獲取包含先前使用資料的“髒”物件。這可能是一個嚴重的安全風險!

type AuthenticationResponse {
    Token string
    UserID string
}

rsp := authPool.Get().(*AuthenticationResponse)
defer authPool.Put(rsp)

// If we don't hit this if statement, we might return data from other users!  
if blah {
    rsp.UserID = "user-1"
    rsp.Token = "super-secret
}

return rsp


確保始終保持零記憶體的安全方法是明確地這樣做:

// reset resets all fields of the AuthenticationResponse before pooling it.
func (a* AuthenticationResponse) reset() {
    a.Token = ""
    a.UserID = ""
}

rsp := authPool.Get().(*AuthenticationResponse)
defer func() {
    rsp.reset()
    authPool.Put(rsp)
}()

其中,這不是一個問題的唯一情況是當您使用正是你寫的記憶體。例如:

var (
    r io.Reader
    w io.Writer
)

// Obtain a buffer from the pool.
buf := *bufPool.Get().(*[]byte)
defer bufPool.Put(&buf)

// We only write to w exactly what we read from r, and no more.  
nr, er := r.Read(buf)
if nr > 0 {
    nw, ew := w.Write(buf[0:nr])
}


避免使用包含指標的struct作為大型Map的Key
在垃圾收集期間,執行時掃描包含指標的物件,並追蹤它們。如果你有一個非常大的map[string]int,GC必須檢查地圖中的每個字串,每個GC,因為字串包含指標。

在這個例子中,我們向a寫入1000萬個元素map[string]int,併為垃圾收集計時。我們在包範圍內分配對映以確保它是堆分配的。

package main

import (
    "fmt"
    "runtime"
    "strconv"
    "time"
)

const (
    numElements = 10000000
)

var foo = map[string]int{}

func timeGC() {
    t := time.Now()
    runtime.GC()
    fmt.Printf("gc took: %s\n", time.Since(t))
}

func main() {
    for i := 0; i < numElements; i++ {
        foo[strconv.Itoa(i)] = i
    }

    for {
        timeGC()
        time.Sleep(1 * time.Second)
    }
}


執行此程式,我們看到以下內容:

inthash → go install && inthash
gc took: 98.726321ms
gc took: 105.524633ms
gc took: 102.829451ms
gc took: 102.71908ms
gc took: 103.084104ms
gc took: 104.821989ms


我們可以做些什麼來改善它?儘可能刪除指標似乎是一個好主意 - 我們將減少垃圾收集器必須追逐的指標數量。字串包含指標 ; 所以讓我們實現這個map[int]int。

package main

import (
    "fmt"
    "runtime"
    "time"
)

const (
    numElements = 10000000
)

var foo = map[int]int{}

func timeGC() {
    t := time.Now()
    runtime.GC()
    fmt.Printf("gc took: %s\n", time.Since(t))
}

func main() {
    for i := 0; i < numElements; i++ {
        foo[i] = i
    }

    for {
        timeGC()
        time.Sleep(1 * time.Second)
    }
}


再次執行程式,我們得到以下內容:

go install && inthash
gc took: 3.608993ms
gc took: 3.926913ms
gc took: 3.955706ms
gc took: 4.063795ms
gc took: 3.91519ms
gc took: 3.75226ms


好多了。我們已經將垃圾收集時間縮短了97%。在生產用例中,在插入Map之前,您需要將字串雜湊為整數。

你可以做更多的事情來逃避GC。如果您分配無指標結構,整數或位元組的巨型陣列,GC將不會掃描它:這意味著您不需要支付GC開銷。這些技術通常需要對程式進行大量的重新設計,因此我們今天不會深入研究它們。

與所有最佳化一樣,您的里程可能會有所不同。檢視來自Damian GryskiTwitter帖子,這是一個有趣的例子,從大型Map中刪除字串以支援更智慧的資料結構實際上增加了記憶體。一般來說,你應該閱讀他所提出的一切。

程式碼生成編組程式碼以避免執行時反射
將struct編組和解組為各種序列化格式(如JSON)是一種常見操作; 特別是在構建微服務時。實際上,您經常會發現大多數微服務實際上做的唯一事情就是序列化。函式類似於json.Marshal並json.Unmarshal依賴於執行時反射來將結構欄位序列化為位元組,反之亦然。這可能很慢:反射並不像顯式程式碼那樣高效。

但是,它不一定是這種方式。編組JSON的機制有點像這樣:

package json

// Marshal take an object and returns its representation in JSON.
func Marshal(obj interface{}) ([]byte, error) {
    // Check if this object knows how to marshal itself to JSON
    // by satisfying the Marshaller interface.
    if m, is := obj.(json.Marshaller); is {
        return m.MarshalJSON()
    }

    // It doesn't know how to marshal itself. Do default reflection based marshallling.
    return marshal(obj)
}


如果我們知道如何編組JSON,我們有一個避免執行時反射的鉤子。但是我們不想手寫所有的編組程式碼,那麼我們該怎麼辦?讓計算機為我們編寫程式碼!像easyjson這樣的程式碼生成器檢視struct,並生成高度最佳化的程式碼,該程式碼與現有的編組介面json.Marshaller完全相容。

下載該包,並在$file.go包含要為其生成程式碼的結構上執行以下命令。

easyjson -all $file.go

您應該找到$file_easyjson.go已生成的檔案。由於easyjson已經為您實現了json.Marshaller介面,因此將呼叫這些函式而不是基於反射的預設值。恭喜:您剛剛將JSON編組程式碼加速了3倍。你可以透過很多東西來提高效能。

更改struct時,您需要確保重新生成編組程式碼。如果您忘記了,您新增的新欄位將不會被序列化和反序列化,這可能會令人困惑!您可以使用它go generate來為您處理此程式碼生成。為了使這些與結構保持同步,我喜歡generate.go在包的根目錄中呼叫包中go generate的所有檔案:當有許多需要生成的檔案時,這可以幫助維護。熱門提示:go generate在CI中呼叫並檢查它沒有帶有簽入程式碼的差異,以確保結構是最新的。

使用strings.Builder建立字串
在Go中,字串是不可變的:將它們視為只讀位元組片。這意味著每次建立字串時,都會分配新記憶體,並可能為垃圾收集器建立更多工作。
在Go 1.10中,strings.Builder作為構建字串的有效方式被引入。在內部,它寫入一個位元組緩衝區。只有在呼叫String()構建器時,才會實際建立字串。它依賴於一些unsafe技巧來將基礎位元組作為具有零分配的字串返回:請參閱此部落格以進一步瞭解其工作原理。

讓我們進行效能比較以驗證兩種方法:

// main.go
package main

import "strings"

var strs = []string{
    "here's",
    "a",
    "some",
    "long",
    "list",
    "of",
    "strings",
    "for",
    "you",
}

func buildStrNaive() string {
    var s string

    for _, v := range strs {
        s += v
    }

    return s
}

func buildStrBuilder() string {
    b := strings.Builder{}

    // Grow the buffer to a decent length, so we don't have to continually
    // re-allocate.
    b.Grow(60)

    for _, v := range strs {
        b.WriteString(v)
    }

    return b.String()
}

// main_test.go
package main

import (
    "testing"
)

var str string

func BenchmarkStringBuildNaive(b *testing.B) {
    for i := 0; i < b.N; i++ {
        str = buildStrNaive()
    }
}
func BenchmarkStringBuildBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        str = buildStrBuilder()
    }


在Macbook Pro上得到以下結果:

go test -bench=. -benchmem
goos: darwin
goarch: amd64
pkg: github.com/sjwhitworth/perfblog/strbuild
BenchmarkStringBuildNaive-8          5000000           255 ns/op         216 B/op          8 allocs/op
BenchmarkStringBuildBuilder-8       20000000            54.9 ns/op        64 B/op          1 allocs/op


我們可以看到,strings.Builder速度提高了4.7倍,導致分配數量的1/8,以及分配的記憶體的1/4。

如果效能很重要,請使用strings.Builder。一般來說,我建議除了最簡單的構建字串之外的所有情況都使用它。

使用strconv而不是fmt

fmt是Go中最知名的軟體包之一。您可能已經在第一個Go程式中使用它來向螢幕列印“hello,world”。然而,當涉及將整數和浮點數轉換為字串時,它的效能不如它的低階表兄:strconv。對於API中的一些非常小的變化,這個軟體包可以為您提供更好的效能。

fmt主要是interface{}作為函式的引數。這有兩個缺點:

  • 你失去了型別安全。對我來說這是一個很大的問題。
  • 它可以增加所需的分配數量。傳遞非指標型別interface{}通常會導致堆分配。閱讀此部落格,找出原因。

以下程式顯示了效能差異:

// main.go
package main

import (
    "fmt"
    "strconv"
)

func strconvFmt(a string, b int) string {
    return a + ":" + strconv.Itoa(b)
}

func fmtFmt(a string, b int) string {
    return fmt.Sprintf("%s:%d", a, b)
}

func main() {}
// main_test.go
package main

import (
    "testing"
)

var (
    a    = "boo"
    blah = 42
    box  = ""
)

func BenchmarkStrconv(b *testing.B) {
    for i := 0; i < b.N; i++ {
        box = strconvFmt(a, blah)
    }
    a = box
}

func BenchmarkFmt(b *testing.B) {
    for i := 0; i < b.N; i++ {
        box = fmtFmt(a, blah)
    }
    a = box
}

Macbook Pro上的基準測試結果:

go test -bench=. -benchmem
goos: darwin
goarch: amd64
pkg: github.com/sjwhitworth/perfblog/strfmt
BenchmarkStrconv-8      30000000            39.5 ns/op        32 B/op          1 allocs/op
BenchmarkFmt-8          10000000           143 ns/op          72 B/op     


我們可以看到strconv版本快3.5倍,分配數量的1/3,分配的記憶體的一半。

分配make中的容量以避免重新分配
在我們進行效能改進之前,讓我們快速回顧一下slice 。slice 是Go中非常有用的構造。它提供了一個可調整大小的陣列,能夠在不重新分配的情況下在相同的底層記憶體上獲取不同的檢視。如果你偷看引擎蓋下,slice 由三個元素組成:

type slice struct {
    // pointer to underlying data in the slice.
    data uintptr
    // the number of elements in the slice.
    len int
    // the number of elements that the slice can 
    // grow to before a new underlying array
    // is allocated.
    cap int     
}


說明:
  • data:指向slice 中基礎資料的指標
  • len:slice 中當前的元素數。
  • cap:slice 在重新分配之前可以增長的元素數。


在引擎蓋下,slice 是固定長度的陣列陣列。當你到達cap一個slice 時,會分配一個前一個slice 上限加倍的新陣列,將記憶體從舊切片複製到新slice ,舊陣列被丟棄

我經常看到類似下面的程式碼,當預先知道slice 的容量時,會分配零容量的slice 。

var userIDs []string
for _, bar := range rsp.Users {
    userIDs = append(userIDs, bar.ID)
}


在這種情況下,切片以零長度和零容量開始。收到響應後,我們將使用者附加到slice 。當我們這樣做時,我們達到了slice 的容量:需要分配了一個新的底層陣列,它是前一個slice 容量的兩倍,並且slice 中的資料被複制到其中。如果響應中有8個使用者,則會產生5個分配。

一種更有效的方法是將其更改為以下內容:

userIDs := make([]string, 0, len(rsp.Users)

for _, bar := range rsp.Users {
    userIDs = append(userIDs, bar.ID)
}


我們已經使用make明確地將容量分配給slice 。現在,我們可以附加到slice ,知道我們不會觸發額外的分配和複製。

如果您不知道應分配多少因為容量是動態的或稍後在程式中計算的,請測量在程式執行時最終得到的切片大小的分佈。我通常採用第90或第99百分位數,並對程式中的值進行硬編碼。如果您有RAM來換取CPU,請將此值設定為高於您認為需要的值。

此建議也適用於map:使用make(map[string]string, len(foo))將在引擎蓋下分配足夠的容量以避免重新分配。

使用允許您傳遞位元組slice 的方法
使用包時,請檢視使用允許傳遞位元組slice 的方法:這些方法通常可以讓您更好地控制分配。

time.Formatvs. time.AppendFormat是一個很好的例子。time.Format返回一個字串。在引擎蓋下,這會分配一個新的位元組slice 並對其進行呼叫time.AppendFormat。time.AppendFormat採用位元組緩衝區,寫入時間的格式化表示,並返回擴充套件位元組slice 。這在標準庫的其他包中很常見:請參閱strconv.AppendFloat(連結)或bytes.NewBuffer。

為什麼這會增加效能呢?那麼,您現在可以傳遞從您獲得的位元組slice sync.Pool,而不是每次都分配一個新的緩衝區。或者,您可以將初始緩衝區大小增加到您認為更適合您的程式的值,以減少切片重新複製。

相關文章