Go語言中常見的坑以及高性能編程技巧分享
背景
代碼的穩(wěn)健性、高性能、可讀性是我們每一位coder必須去追求的目標,也是coding的基本功。
本文結(jié)合Go語言的特性,以及自己在寫Go項目中做的總結(jié),從Go常見的數(shù)據(jù)結(jié)構(gòu)、內(nèi)存管理、并發(fā)等方面做了相關(guān)總結(jié)
本文相關(guān)代碼的驗證環(huán)境
GOARCH="arm64" GOOS="darwin" GOVERSION="go1.16.15"
一. 常見的坑
1.1 數(shù)據(jù)結(jié)構(gòu)
1.在函數(shù)調(diào)用過程中,數(shù)組是值傳遞pass-by-value,必要時必須使用slice。因為slice是引用類型,在slice傳參時,只會復(fù)制slice的Data指針和len、cap,形參和實參的slice使用的是同一個底層數(shù)組。
2.map是一種hash表實現(xiàn),每次遍歷的順序都可能不一樣。
3.切片會導(dǎo)致整個數(shù)組被鎖定,導(dǎo)致底層數(shù)組無法及時釋放內(nèi)存,如果底層數(shù)組過大,會對內(nèi)存產(chǎn)生極大的壓力。
錯誤示例:
func test() { fileHeaderMap := make(map[string][]byte) for i := 0; i < 5; i++ { name := "/data/test/file_"+strconv.Itoa(i) data, err := ioutil.ReadFile(name) if err != nil { fmt.Println(err) continue } fileHeaderMap[name] = data[:1] } // do some thing }
正確示例:
解決辦法是將結(jié)果克隆一份,這樣可以釋放底層數(shù)組
func test() { fileHeaderMap := make(map[string][]byte) for i := 0; i < 5; i++ { name := "/data/test/file_"+strconv.Itoa(i) data, err := ioutil.ReadFile(name) if err != nil { fmt.Println(err) continue } fileHeaderMap[name] = append([]byte{}, data[:1]...) } // do some thing }
4.當函數(shù)的可變參數(shù)是空接口時,傳入空接口的切片時需要注意參數(shù)展開的問題
func main() { var a = []interface{}{111, 222, 333} fmt.Println(a) fmt.Println(a...) } // 不管是否展開,編譯器都會編譯通過,但是輸出是不同的: //print: [111 222 333] 111 222 333
5.對于切片的操作,會操作同一個底層數(shù)組,因此對于一個切片的修改操作,會影響到整個數(shù)組。另外由于string 在go中是immutable,對于同一個字符串的兩個變量,Go做了內(nèi)存優(yōu)化,會使用的相同的底層數(shù)組
func main() { slice := []int{1, 2, 3, 4, 5} slice1 := slice[:2] slice2 := slice[:4] fmt.Println("slice: ", slice, ",slice1: ", slice1, ",slice2: ", slice2) slice2[0] = 6 fmt.Println("slice: ", slice, ",slice1: ", slice1, ",slice2: ", slice2) string1 := "go hello word" string2 := "go hello word" fmt.Printf("string1 data addr: %d \n", (*reflect.StringHeader)(unsafe.Pointer(&string1)).Data) fmt.Printf("string2 data addr: %d \n", (*reflect.StringHeader)(unsafe.Pointer(&string2)).Data) } //print: slice: [1 2 3 4 5] ,slice1: [1 2] ,slice2: [1 2 3 4] slice: [6 2 3 4 5] ,slice1: [6 2] ,slice2: [6 2 3 4] string1 data addr: 4333475958 string2 data addr: 4333475958
1.2 Go語言特性相關(guān)
1.Go中的recover捕獲的是祖父級調(diào)用時候的panic,直接調(diào)用時不會有任何效果,必須在defer函數(shù)中調(diào)用才有效果。
func test() err error { defer func() { if r := recover(); r != nil { err = fmt.Errorf("[ERROR] Process exception. Panic: %v", r) } }() // do some thing panic("some error happend") }
2.不同goroutine之間不滿足順序一致性的內(nèi)存模型,需要使用顯示的同步:如 channel
var testMsg string var done = make(chan struct{}) func initMsg() { testMsg = "go hello world" done <- struct{}{} } func main() { go initMsg() <-done println(testMsg) }
3.閉包的錯誤使用,導(dǎo)致使用同一個變量
錯誤示例:
func main() { for i := 0; i < 5; i++ { defer func() { println(i) }() } } //print: 5 5 5 5 5
正確示例:
func main() { for i := 0; i < 5; i++ { defer func(i int) { println(i) }(i) // or //i := i //defer func(){ // println(i) //}() } } //print: 4 3 2 1 0
4.因為defer在函數(shù)退出時才會執(zhí)行,因此不能在循環(huán)內(nèi)部執(zhí)行defer,否則會導(dǎo)致相關(guān)defer操作調(diào)用延遲,比如fd會延遲關(guān)閉,應(yīng)將defer包裝在func中
錯誤示例:
func test() { for i := 0; i < 5; i++ { f, err := os.Open("/data/test/file_"+strconv.Itoa(i)) if err != nil { fmt.Println(err) continue } defer f.Close() // do some thing } // do some thing }
正確示例:
func test() { for i := 0; i < 5; i++ { func() { f, err := os.Open("/data/test/file_"+strconv.Itoa(i)) if err != nil { fmt.Println(err) return } defer f.Close() // do some thing }() } // do some thing }
5.Goroutine泄漏:
Go語言是帶內(nèi)存自動回收的特性,因此內(nèi)存一般不會泄漏。但是Goroutine卻存在泄漏的情況,同時泄漏的Goroutine引用的內(nèi)存也同樣無法被回收。因此可通過context包或者退出的channel來避免Goroutine的泄漏。
錯誤示例:
func main() { ch := func() <-chan int { ch := make(chan int) go func() { for i := 0; ; i++ { ch <- i } } () return ch }() for value := range ch { fmt.Println(value) if value == 10 { break } } // do some thing }
上面的程序中后臺Goroutine向channel輸入整數(shù),main函數(shù)中輸出該整數(shù)。但是當值為10時, break跳出for循環(huán),后臺Goroutine就處于無法被回收的狀態(tài)了
正確示例:
func main() { ctx, cancel := context.WithCancel(context.Background()) // stopChan := make(chan struct{}) //ch := func(stopChan chan struct{}) <-chan int { ch := func(ctx context.Context) <-chan int { ch := make(chan int) go func() { for i := 0; ; i++ { select { //case <-stopChan: // return case <- ctx.Done(): return case ch <- i: } } }() return ch }(ctx) //}(stopChan) for value := range ch { fmt.Println(value) if value == 10 { cancel() //通過調(diào)用cancel()來通知后臺Goroutine退出 // stopChan <- struct{}{} break } } // do some thing }
6. go channel注意事項
- 如果是一個buffer channel,即使被 close, 也可以讀到之前存入的值,讀取完畢后開始讀零值,已經(jīng)關(guān)閉的channel寫入則會觸發(fā) panic
- nil channel 讀取和存入都會阻塞,close 會 panic
- 已經(jīng)close過的channel再次close會觸發(fā)panic
- 關(guān)閉channel的原則:不要在消費端進行關(guān)閉、不要在有多個并行的生產(chǎn)端關(guān)閉
二. 高性能Go編程
2.1 數(shù)據(jù)結(jié)構(gòu)
盡可能指定容器容量,以便為容器預(yù)先分配內(nèi)存。這將在后續(xù)添加元素時減少通過復(fù)制來調(diào)整容器大小
遍歷 []struct{} 使用下標而不是 range
如果切片是[]int,切片的Item為int差別不大,但是如果切片Item是一個結(jié)構(gòu)體類型struct{}時,且Item中包含一些比較大內(nèi)存的成員類型,比如 [2048]byte,如果每次遍歷[]struct{} ,都會進行一次值拷貝,所以會帶來性能消耗。此外,因為 range 遍歷事獲取值拷貝的副本,所以對副本的修改,是不會影響到原切片。 如果切片的Item是指針類型,即[]*struct{} 則兩者遍歷方法都一樣
string轉(zhuǎn)[]byte的零拷貝操作:
我們一般在做string轉(zhuǎn)[]byte時,會直接進行[]byte(string),這樣的話會發(fā)生一次拷貝操作,對于一些長尾的字符串,會產(chǎn)生性能問題。這是因為在Go的設(shè)計中string是immutable,而[]byte是mutable,如果不希望進行這次拷貝的消耗,這時候就需要用到unsafe
可參見:pkg.go.dev/reflect#SliceHeader
func string2bytes(s string) []byte { stringHeader := (*reflect.StringHeader)(unsafe.Pointer(&s)) var b []byte rs := (*reflect.SliceHeader)(unsafe.Pointer(&b)) rs.Data = stringHeader.Data rs.Len = stringHeader.Len rs.Cap = stringHeader.Len return b }
不過需要特別注意這樣生成的[]byte不可修改,否則會出現(xiàn)未定義的行為
Go 中空結(jié)構(gòu)體 struct{} 是不占用內(nèi)存空間,因此fmt.Println(unsafe.Sizeof(struct{}{})) 為0,不像 C/C++ 中空結(jié)構(gòu)體仍占用 1 字節(jié)大小,因此可用于以下幾個場景來節(jié)省內(nèi)存:
- 在實現(xiàn)集合時,可以將map的value的設(shè)置為struct{}來節(jié)省內(nèi)存;
- 在使用不發(fā)送數(shù)據(jù)的channel時,只是用于通知其他Goroutine,也可以使用空結(jié)構(gòu)體;
- 僅包含方法的結(jié)構(gòu)體,即結(jié)構(gòu)體沒有任何成員類型字段
盡量少使用反射,因為反射里邊牽扯到類型判斷和內(nèi)存分配,會對性能產(chǎn)生影響
2.2 內(nèi)存管理
struct結(jié)構(gòu)體內(nèi)的成員布局需要考慮內(nèi)存對齊,一般建議字段寬度從小到大由上到下排列,減少內(nèi)存占用,提高內(nèi)存讀寫性能
值傳遞會拷貝整個對象,而指針傳遞只會拷貝對象的地址,指針指向的對象是同一個。返回指針可以減少值的拷貝,但是會導(dǎo)致內(nèi)存分配逃逸到堆中,即變量逃逸,增加垃圾回收(GC)的負擔。在對象頻繁創(chuàng)建和刪除的場景下,傳遞指針會導(dǎo)致GC的開銷增大,影響性能。
一般情況下,對于需要修改原對象值,或占用內(nèi)存比較大的結(jié)構(gòu)體,選擇返回指針。對于只讀的或者占用內(nèi)存較小的結(jié)構(gòu)體,直接返回值性能要優(yōu)于指針
對于需要重復(fù)分配、回收內(nèi)存的地方,優(yōu)先使用sync.Pool進行池化,用來保存和復(fù)用對象,減少內(nèi)存分配,降低GC壓力,并且sync.Pool是并發(fā)安全的
2.3 并發(fā)編程
1.鎖的使用
- 優(yōu)先考慮無鎖的數(shù)據(jù)結(jié)構(gòu)使用,即lock-free 比如atomic包,但其底層也是memory barrier,如果并發(fā)太高,性能也未必高
- Goroutine局部,即線程的TLS,最后再進行每個Goroutine合并處理
- 進行數(shù)據(jù)切片,減少鎖的競爭,或者使用讀寫鎖,替換互斥鎖
- 對于map數(shù)據(jù)結(jié)構(gòu)的并發(fā)訪問,在讀多寫少的情況下,建議使用官方的sync.Map,sync.Map是空間換時間的實現(xiàn)內(nèi)部有兩個map,有一個專門讀的read map,另一個是提供讀寫的dirty map,優(yōu)先讀read map,未讀到會穿透到dirty map,但是不適用于大量寫的場景,dirty map會存在頻繁刷新為read map,整體性能會降低。
2.Goroutine 池化 github.com/panjf2000/ants
到此這篇關(guān)于Go語言中常見的坑以及高性能編程技巧分享的文章就介紹到這了,更多相關(guān)Go編程技巧內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
golang jsoniter extension 處理動態(tài)字段的實現(xiàn)方法
這篇文章主要介紹了golang jsoniter extension 處理動態(tài)字段的實現(xiàn)方法,我們使用實例級別的 extension, 而非全局,可以針對不同業(yè)務(wù)邏輯有所區(qū)分,jsoniter 包提供了比較完善的定制能力,通過例子可以感受一下擴展性,需要的朋友可以參考下2023-04-04go+redis實現(xiàn)消息隊列發(fā)布與訂閱的詳細過程
這篇文章主要介紹了go+redis實現(xiàn)消息隊列發(fā)布與訂閱,redis做消息隊列的缺點:沒有持久化,一旦消息沒有人消費,積累到一定程度后就會丟失,本文給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2022-09-09Go內(nèi)存節(jié)省技巧簡單實現(xiàn)方法
這篇文章主要為大家介紹了Go內(nèi)存節(jié)省技巧簡單實現(xiàn)方法詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-01-01Go語言使用goroutine及通道實現(xiàn)并發(fā)詳解
這篇文章主要為大家介紹了Go語言使用goroutine及通道實現(xiàn)并發(fā)詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-08-08