最佳化Go程式的簡單技巧 - stephen.sh
根據我的經驗,效能不佳表現為以下兩種方式之一:
- 在小規模上表現良好的運營,但隨著使用者數量的增長而變得不可行。這些通常是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 Gryski的Twitter帖子,這是一個有趣的例子,從大型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,而不是每次都分配一個新的緩衝區。或者,您可以將初始緩衝區大小增加到您認為更適合您的程式的值,以減少切片重新複製。
相關文章
- 優化Go程式的簡單技巧 - stephen.sh優化Go
- 好程式設計師Java培訓分享-簡單的效能最佳化技巧程式設計師Java
- Go的第一個Hello程式 簡簡單單 - 快快樂樂Go
- 程式設計師程式設計時的簡單方法與技巧程式設計師
- 三種提升Java程式碼效能的簡單技巧 - levelupJava
- MySQL簡單最佳化MySql
- 簡單的 Go 入門教程Go
- Go 世界如此簡單!Go
- Flutter 簡單佈局技巧Flutter
- XPROG: 簡單實用的魯棒最佳化(RO, DRO)程式語言
- Go語言併發程式設計簡單入門Go程式設計
- lzambarda/simple-go-boilerplate:簡單的go微服務模板Go微服務
- Go程式設計技巧–Goroutine的優雅控制Go程式設計
- 24個PHP程式碼最佳化技巧PHP
- 嵌入式程式碼最佳化技巧
- GO語言一個簡單的工程Go
- Go 原生 RPC 與 APRC 的簡單使用GoRPC
- go練手:簡單的單詞格式轉換工具Go
- 一些常見的簡單最佳化
- 推薦一個可以讓 go 程式跨平臺簡單部署的包Go
- webase go-sdk 簡單使用WebGo
- 簡單的爬蟲程式爬蟲
- 用go實現簡單的氣泡排序Go排序
- 一個超級簡單的 go Web 框架GoWeb框架
- 用 go 實現一個簡單的 mvcGoMVC
- go1.18泛型的簡單嘗試Go泛型
- 簡單介紹Go 語言單例模式Go單例模式
- [譯]深度學習模型的簡單優化技巧深度學習模型優化
- 這些簡單的技巧使 VLC 更加出色
- 簡單介紹5個python的實用技巧Python
- 最佳化兩個簡單的巢狀迴圈巢狀
- 程式設計技巧整理:Java程式效能最佳化總結!程式設計Java
- 幾個簡單的技巧讓你寫出的vue.js程式碼更優雅Vue.js
- 降低程式碼圈複雜度最佳化技巧複雜度
- 你見過哪些優雅的 Java 程式碼最佳化技巧?Java
- Go實現簡單的K-V儲存Go
- 【GO】Elasticsearch的簡單寫入和讀取示例GoElasticsearch
- Snow——簡單易用的 Go 語言業務框架Go框架