?
? 作者 |?百度小程序團隊 ?
導讀?
introduction ? ?
本文收集一些使用Go開發(fā)過程中非常容易踩坑的case,所有的case都有具體的代碼示例,以及針對的代碼修復方法,以避免大家再次踩坑。通常這些坑的特點就是代碼正常能編譯,但運行結果不及預期或是引入內(nèi)存漏洞的風險。 ? 全文7866字,預計閱讀時間20分鐘。
? GEEK TALK
01參數(shù)傳遞誤用? ??
1.1 誤對指針計算Sizeof
對任何指針進行unsafe.Sizeof計算,返回的結果都是 8 (64位平臺下)。稍不注意就會引發(fā)錯誤。
錯誤示例:
?
func TestSizeofPtrBug(t *testing.T) { ????type?CodeLocation?struct?{ LineNo int64 ColNo int64 } cl := &CodeLocation{10, 20} size := unsafe.Sizeof(cl) fmt.Println(size) // always return 8 for point size }
?
建議使用示例:單獨編寫一個只處理值大小的函數(shù) ValueSizeof。
?
func TestSizeofPtrWithoutBug(t *testing.T) { type CodeLocation struct { LineNo int64 ColNo int64 } cl := &CodeLocation{10, 20} size := ValueSizeof(cl) fmt.Println(size) // 16 } func ValueSizeof(v any) uintptr { typ := reflect.TypeOf(v) if typ.Kind() == reflect.Pointer { return typ.Elem().Size() } return typ.Size() }? ?
?
1.2?可變參數(shù)為any類型時,誤傳切片對象
當參數(shù)的可變參數(shù)是any類型時,傳入切片對象時一定要用展開方式。
?
appendAnyF := func(t []any, toAppend ...any) []any { ret := append(t, toAppend...) return ret } emptySlice := []any{} slice2 := []any{"hello", "world"} // bug append slice as a element emptySlice = appendAnyF(emptySlice, slice2) fmt.Println(emptySlice) // only 1 element [[hello world]] emptySlice = []any{} emptySlice = appendAnyF(emptySlice, slice2...) fmt.Println(emptySlice) // [hello world]? ?
?
1.3?數(shù)組是值傳遞
數(shù)組在函數(shù)或方法中入?yún)鬟f是值復制的方式,不能用入?yún)⒌姆绞竭M函數(shù)或方法內(nèi)修改數(shù)組內(nèi)容進行返回的。
示例代碼如下:
?
arr := [3]int{0, 1, 2} f := func(v [3]int) { v[0] = 100 } f(arr) // no modify to arr fmt.Println(arr) // [0 1 2]? ?
?
1.4?切片擴容后會新申請內(nèi)存,不再與內(nèi)存引用有任何關聯(lián)
這里坑在,如果從一個數(shù)組中引入一個切片,一旦這個切片引發(fā)擴容后,則與原來的引用內(nèi)容沒有任何關系。
?
arr := []int{0, 1, 2} f := func(v []int) { v[0] = 100// can modify origin array v = append(v, 4) // new memory allocated v[0] = 50// no modify to origin array } f(arr) fmt.Println(arr) // [100 1 2]
?
上面的示例代碼,擴容切片前對內(nèi)容的修改可以影響到arr數(shù)組,說明是共享內(nèi)存地址引用的,一旦擴容后,則是重新申請了內(nèi)存,與數(shù)組不再是一個內(nèi)存引用了。
1.5?返回參數(shù)盡量避免使用共享數(shù)據(jù)的切片對象,容易導致原始數(shù)據(jù)污染
這種場景就是如果通過函數(shù)返回值方式從一個大數(shù)組獲取部分內(nèi)部,盡量不要用切片共享的方式,可以使用copy的方式來替換。
下面的代碼,通過ReadUnsafe讀取切片后,修改內(nèi)容同步影響原始的內(nèi)容。
?
type Queue struct { content []byte pos int } func (q *Queue) ReadUnsafe(size int) []byte { if q.pos+size >= len(q.content) { return nil } pos := q.pos q.pos = q.pos + size return q.content[pos:q.pos] } func TestReadUnsafe(t *testing.T) { c := [200]byte{} q := &Queue{content: c[:]} v := q.ReadUnsafe(10) v[0] = 1 fmt.Println(q.content[0]) // 1 q.content值已經(jīng)被修改 }
?
正確的修改如下,使用copy創(chuàng)建一份新內(nèi)存:
?
func (q *Queue) ReadSafe(size int) []byte { if q.pos+size >= len(q.content) { return nil } pos := q.pos q.pos = q.pos + size ret := make([]byte, size) copy(ret, q.content[pos:q.pos]) return ret } func TestReadSafe(t *testing.T) { c := [200]byte{} q := &Queue{content: c[:]} v := q.ReadSafe(10) v[0] = 1 fmt.Println(q.content[0]) // 0 q.content值安全 }
?
GEEK TALK
02指針相關使用的坑
2.1?誤保存uintptr值
uintptr保存的當前地址的一個整型值,它一旦被獲取后,是不會被編譯器感知的,也就是它就是一個普通變量,不會追溯內(nèi)存真實地址變化。
?
slice := []int{0, 1, 2} ptr := unsafe.Pointer(&slice[0]) // get array element:0 pointer slice = append(slice, 3) // allocate new memory ptr2 := unsafe.Pointer(&slice[0]) // ptr is 824633770392, ptr2 is 824633762896, ptr==ptr2 result is false ????fmt.Println(fmt.Sprintf("ptr?is?%d,?ptr2?is?%d,?ptr==ptr2?result?is?%v",?ptr,?ptr2,?ptr?==?ptr2))? ?
?
2.2?len與cap 對空指針nil與空值返回相同
針對切片, 用len與cap操作時,空值與nil都是返回0, 針對map, 用len操作時,空值與nil都是返回0。
?
var slice []int = nil fmt.Println(len(slice), cap(slice)) // 0 0 var slice2 []int = []int{} fmt.Println(len(slice2), cap(slice2)) // 0 0 var mp map[int]int = nil fmt.Println(len(mp)) // 0 var mp2 map[int]int = map[int]int{} fmt.Println(len(mp2)) // 0? ?
?
2.3?用new對map類型進行初始化
用new對map進行創(chuàng)建,編譯器不會報錯,但是無法對map進行賦值操作的。正確應使用make進行內(nèi)存分配。
?
mp := new(map[int]int) f := func(m map[int]int) { m[10] = 10 } f(*mp) // assignment to entry in nil map? ?
?
2.4?空指針和空接口不等價
對于接口類型是可以用nil賦值的,但如果對于接口指針類型,其值對應的并不一個空接口。Go語言編譯器似乎在這個處理,會特殊處理。
// MyErr just for demotype MyErr struct{} func (e *MyErr) Error() string { return"" } func TestInterfacePointBug(t *testing.T) { var e *MyErr = nil var e2 error = e // e2 will never be nil. fmt.Println(e2 == nil) }?
?
GEEK TALK
03函數(shù),方法與控制流相關
3.1 循環(huán)中使用閉包錯誤引用同一個變量
原因分析:閉包捕獲外部變量,它不關心這些捕獲的變量或常量是否超出作用域,只要閉包在使用,這些變量就會一直存在。
?
type S struct { A string B string C string } typ := reflect.TypeOf(S{}) funcArr := make([]func() string, typ.NumField()) for i := 0; i < typ.NumField(); i++ { f := func() string { return typ.Field(i).Name } funcArr[i] = f } fmt.Println(funcArr[0]()) // error reflect: Field index out of bounds
?
所以上面的示例代碼,在循環(huán)中閉包函數(shù)只記錄了i變量的使用,當循環(huán)結束后,i值變成了3。當調(diào)用該匿名函數(shù)時,就會引用i=3的值 ,出現(xiàn)越界的異常。
正確處理的方式如下,只需要閉包前處理一下把i變量賦值給一個新變量。
?
type S struct { A string B string C string } typ := reflect.TypeOf(S{}) funcArr := make([]func() string, typ.NumField()) for i := 0; i < typ.NumField(); i++ { index := i // assign to a new variable f := func() string { name := typ.Field(index).Name return name } funcArr[i] = f } fmt.Println(funcArr[0]()) // A? ?
?
3.2?元素內(nèi)容較大時,不要用range遍歷
用range來操作遍歷使用上非常方便,但是它的遍歷中是需要進行值賦值操作,遇到元素占用的內(nèi)存比較大時,性能就會影響較大。
下面是針對兩種方式做了一下基準測試。
?
func CreateABigSlice(count int) [][4096]int { ret := make([][4096]int, count) for i := 0; i < count; i++ { ret[i] = [4096]int{} } return ret } func BenchmarkRangeHiPerformance(b *testing.B) { v := CreateABigSlice(1 << 12) for i := 0; i < b.N; i++ { len := len(v) var tmp [4096]int for k := 0; k < len; k++ { tmp = v[k] } _ = tmp } } func BenchmarkRangeLowPerformance(b *testing.B) { v := CreateABigSlice(1 << 12) for i := 0; i < b.N; i++ { var tmp [4096]int for _, e := range v { tmp = e } _ = tmp } }
?
測試結果如下:range方式的性能較for方式相差了近10000倍。
?
cpu: 11th Gen Intel(R) Core(TM) i5-1145G7 @ 2.60GHz BenchmarkRangeHiPerformance-8 9767457 1255 ns/op BenchmarkRangeLowPerformance-8 975 11513216 ns/op PASS ok withoutbug/avoidtofix 26.270s? ?
?
3.3?循環(huán)內(nèi)調(diào)用defer造成銷毀處理延遲
在很多場景,在循環(huán)內(nèi)申請資源在循環(huán)完成后釋放,但是使用defer語句處理,是需要在當前函數(shù)退出時才會執(zhí)行,在循環(huán)中是不會觸發(fā)的,導致資源延遲釋放。
?
func main() { for i := 0; i < 5; i++ { f, err := os.Open("./mygo.go") if err != nil { log.Fatal(err) } defer f.Close() } }
?
比較好的解決辦法就是在for循環(huán)里不要使用defer,直接進行銷毀處理。
?
func main() { for i := 0; i < 5; i++ { f, err := os.Open("/path/to/file") if err != nil { log.Fatal(err) } f.Close() } }? ?
?
3.4?Goroutine無法阻止主進程退出
后臺Goroutine無法保證在方法退出來執(zhí)行完成。
?
func main() { gofunc() { time.Sleep(time.Second) fmt.Println("run") }() }? ?
?
3.5?Goroutine 拋panic會導致進程退出
后臺Goroutine執(zhí)行中,如果拋panic并不進行recover處理,會導致主進程退出。
下面的代碼示例:
?
func main1() { go func() { panic("oh...") }() for i := 0; i < 3; i++ { fmt.Println(i) time.Sleep(time.Second) } fmt.Println("bye bye!") }
?
修正代碼如下:
?
func main2() { go func() { defer func() { recover() // should do some thing here }() panic("oh...") }() for i := 0; i < 3; i++ { fmt.Println(i) time.Sleep(time.Second) } fmt.Println("bye bye!") }? ?
?
3.6 recover函數(shù) 只在defer函數(shù)內(nèi)生效
需要注意:在非defer函數(shù)內(nèi),調(diào)用recover函數(shù),是不會有任何的執(zhí)行,也無法來處理panic錯誤。
下面的示例代碼,是無法處理panic的錯誤:
?
func NoTestDeferBug(t *testing.T) { recover() panic(1) // could not catch } func NoTestDeferBug2(t *testing.T) { defer recover() panic(1) // could not catch }
?
正確的代碼如下:
?
func TestDeferFixed(t *testing.T) { defer func() { recover() }() panic("this is panic info") // could not catch }
?
GEEK TALK
04并發(fā)與內(nèi)存同步相關
4.1?跨Goroutine之間不支持順序一致性內(nèi)存模型
在Go語言的內(nèi)存模型設計中, 內(nèi)存寫入順序性只能保障在單一Goroutine內(nèi)一致,跨Goroutine之間無法保障監(jiān)測變量操作順序的一致性。
下面是官方的例子:
?
package main var msg string var done bool func setup() { msg = "hello, world" done = true } func main() { go setup() for !done { } println(msg) }
?
上面代碼的問題是,不能保證在 main 中對 done 的寫入的監(jiān)測時, 會對變量a的寫入也進行監(jiān)測,因此該程序也可能會打印出一個空字符串。更糟的是,由于在兩個線程之間沒有同步事件,因此無法保證對 done 的寫入總能被 main 監(jiān)測到。main 中的循環(huán)不保證一定能結束。
解決辦法就是使用顯示同步方案, 使用通道進行同步通信。
?
package main var msg string var done = make(chan bool) func setup() { msg = "hello, world" done <- true } func main() { go setup() <-done println(msg) }
?
這樣就可以保證代碼執(zhí)行過程中必定輸出 hello,world。
更多內(nèi)存同步閱讀材料:https://go-zh.org/ref/mem
GEEK TALK
05序列化相關
5.1?基于指針參數(shù)方式傳遞的反序列功能,都不會初始化要反序列化的對象字段
該問題經(jīng)常發(fā)生的原因是基于指針參數(shù)方式傳遞的反序列函數(shù)其實做的只是值覆蓋的功能,并不會把要反序化的對象的所有值進行初始化操作,這樣就會導致未覆蓋的值的保留. 像 json.Unmarshal, xml.Unmarshal 函數(shù)等。
下面是基于json對map 類型的變量進行json.Unmarshal的問題示例:
?
package main import ( "encoding/json" "fmt" ) func main() { val := map[string]int{} s1 := `{"k1":1, "k2":2, "k3":3}` s2 := `{"k1":11, "k2":22, "k4":44}` json.Unmarshal([]byte(s1), &val) fmt.Println(s1, val) json.Unmarshal([]byte(s2), &val) fmt.Println(s2, val) }
?
輸出:
?
{"k1":1, "k2":2, "k3":3} map[k1:1 k2:2 k3:3] {"k1":11,?"k2":22,?"k4":44}?map[k1:11?k2:22?k3:3?k4:44]
?
由于 json.UnMarshal 方法只會新增和覆蓋 map 中的 key,不會刪除 key。雖然第二個json字符串中沒有k3的內(nèi)容,但輸出結果中依然保留在了k3的內(nèi)容。
要解決這個問題,每次 unmarshal 之前都重新聲明變量即可。
GEEK TALK
06其它雜項
6.1?數(shù)字類型轉換越界陷阱
Go語言中,任何操作符不會改變變量類型,下面示例引入一個坑, 出現(xiàn)位移越界。
?
func TestOverFlowBug(t *testing.T) { var num int16 = 5000 var result int64 = int64(num << 9) fmt.Println(result) // 4096 overflow }
?
修正方式如下,需要操作前對類型轉換:
?
func TestOverFlowFixed(t *testing.T) { var num int16 = 5000 var result int64 = int64(num) << 9 fmt.Println(result) // 2560000 }? ?
?
6.2?map遍歷是順序不固定
map的實現(xiàn)是通hash表進行分桶定位,同時map的遍歷引入了隨機實現(xiàn),所以每次遍歷的順序都可能變化。
?
mp := map[int]int{} for i := 0; i < 20; i++ { mp[i] = i } for k, v := range mp { fmt.Println(k, v) }
?
審核編輯:湯梓紅
評論
查看更多