Go语言躲坑经验总结

作者 | 百度小程序团队

导读 本文搜集一些运用Go开发过程中十分容易踩坑的case,所有的case都有详细的代码示例,以及针对的代码修正办法,以避免大家再次踩坑。一般这些坑的特色便是代码正常能编译,但运转成果不及预期或是引进内存漏洞的风险。

全文7866字,估计阅览时刻20分钟。

01 参数传递误用

1.1 误对指针核算Sizeof

对任何指针进行unsafe.Sizeof核算,回来的成果都是 8 (64位平台下)。稍不留意就会引发过错。

过错示例:

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
}

主张运用示例:单独编写一个只处理值巨细的函数 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 可变参数为any类型时,误传切片目标

当参数的可变参数是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 数组是值传递

数组在函数或办法中入参传递是值复制的办法,不能用入参的办法进函数或办法内修正数组内容进行回来的。

示例代码如下:

    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 切片扩容后会新请求内存,不再与内存引证有任何关联

这里坑在,如果从一个数组中引进一个切片,一旦这个切片引发扩容后,则与本来的引证内容没有任何关系。

    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]

上面的示例代码,扩容切片前对内容的修正能够影响到arr数组,说明是同享内存地址引证的,一旦扩容后,则是从头请求了内存,与数组不再是一个内存引证了。

1.5 回来参数尽量避免运用同享数据的切片目标,容易导致原始数据污染

这种场景便是如果经过函数回来值办法从一个大数组获取部分内部,尽量不要用切片同享的办法,能够运用copy的办法来替换。

下面的代码,经过ReadUnsafe读取切片后,修正内容同步影响原始的内容。

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值现已被修正
}

正确的修正如下,运用copy创立一份新内存:

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值安全
}

02 指针相关运用的坑

2.1 误保存uintptr值

uintptr保存的当前地址的一个整型值,它一旦被获取后,是不会被编译器感知的,也便是它便是一个普通变量,不会追溯内存实在地址改动。

    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进行创立,编译器不会报错,可是无法对map进行赋值操作的。正确应运用make进行内存分配。

        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)
}

03 函数,办法与控制流相关

3.1 循环中运用闭包过错引证同一个变量

原因分析:闭包捕获外部变量,它不关怀这些捕获的变量或常量是否超出作用域,只要闭包在运用,这些变量就会一直存在。

  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

所以上面的示例代码,在循环中闭包函数只记录了i变量的运用,当循环结束后,i值变成了3。当调用该匿名函数时,就会引证i=3的值 ,呈现越界的反常。

正确处理的办法如下,只需要闭包前处理一下把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 元素内容较大时,不要用range遍历

用range来操作遍历运用上十分便利,可是它的遍历中是需要进行值赋值操作,遇到元素占用的内存比较大时,功能就会影响较大。

下面是针对两种办法做了一下基准测验。

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 循环内调用defer形成毁掉处理延迟

在许多场景,在循环内请求资源在循环完成后开释,可是运用defer语句处理,是需要在当前函数退出时才会履行,在循环中是不会触发的,导致资源延迟开释。

func main() {
    for i := 0; i < 5; i++ {
        f, err := os.Open("./mygo.go")
        if err != nil {
            log.Fatal(err)
        }
        defer f.Close()
    }
}

比较好的解决办法便是在for循环里不要运用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无法确保在办法退出来履行完成。

func main() {
     gofunc() {
        time.Sleep(time.Second)
        fmt.Println("run")
    }()   
}

3.5 Goroutine 抛panic会导致进程退出

后台Goroutine履行中,如果抛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 r****ecover函数 只在defer函数内生效

需要留意:在非defer函数内,调用recover函数,是不会有任何的履行,也无法来处理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
}

04 并发与内存同步相关

4.1 跨Goroutine之间不支持顺序共同性内存模型

在Go语言的内存模型规划中, 内存写入顺序性只能确保在单一Goroutine内共同,跨Goroutine之间无法确保监测变量操作顺序的共同性。

下面是官方的比如:

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 的写入的监测时, 会对变量a的写入也进行监测,因而该程序也可能会打印出一个空字符串。更糟的是,因为在两个线程之间没有同步事件,因而无法确保对 done 的写入总能被 main 监测到。main 中的循环不确保必定能结束。

解决办法便是运用显现同步方案, 运用通道进行同步通信。

package main
var msg string 
var done = make(chan bool)
func setup() {
    msg = "hello, world"
    done <- true
}
func main() {
    go setup()
    <-done
    println(msg)
}

这样就能够确保代码履行过程中必定输出 hello,world。

更多内存同步阅览材料:go-zh.org/ref/mem

05 序列化相关

5.1 根据指针参数办法传递的反序列功能,都不会初始化要反序列化的目标字段

该问题常常发生的原因是根据指针参数办法传递的反序列函数其实做的仅仅值掩盖的功能,并不会把要反序化的目标的所有值进行初始化操作,这样就会导致未掩盖的值的保存. 像 json.Unmarshal, xml.Unmarshal 函数等。

下面是根据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的内容,但输出成果中仍然保存在了k3的内容。

要解决这个问题,每次 unmarshal 之前都从头声明变量即可。

06 其它杂项

6.1 数字类型转换越界陷阱

Go语言中,任何操作符不会改动变量类型,下面示例引进一个坑, 呈现位移越界。

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的实现是通hash表进行分桶定位,同时map的遍历引进了随机实现,所以每次遍历的顺序都可能改动。

    mp := map[int]int{}
    for i := 0; i < 20; i++ {
        mp[i] = i
    }
    for k, v := range mp {
        fmt.Println(k, v)
    }

——END——

参考资料:

[1]Effective Go 英文版: go.dev/doc/effecti…

[2]Go 语言代码风格指导: github.com/golang/go/w…

推荐阅览:

PaddleBox:百度根据GPU的超大规模离散DNN模型训练解决方案

聊聊机器如何”写”好广告案牍?

百度工程师教你玩转规划模式(适配器模式)

百度搜索事务交付无人值守实践与探索

分布式ID生成服务的技能原理和项目实战

揭秘百度智能测验在测验评估范畴实践