golang日常开发常见的坑位解析

前言

主要解析了 Go 语言日常开发中的常见坑位,包括核心数据结构(值类型、指针类型、切片、映射、通道、数组等)、变量(遮蔽、可为空类型、类型转换)、字符串特性、nil 值比较,以及 defer、panic、recover 的使用和相关注意事项,还提及了一些运行时的致命错误情况。

1. 核心数据结构

值类型包括: 所有integer、所有float、bool、string(+rune)、array 和 struct

指针类型: Go是类C语言,只有值类型和指针类型,没有明确的引用类型的概念, 只不过利用strut类型和指针,能做到C#引用类型的效果。

2. 切片slice是结构体

slice是一个结构体,切片s执行unsafe.Sizeof(s) = 24

type slice struct {
    array unsafe.Pointer         // 底层数组指针
    len   int                     // 内容长度
    cap   int                    // 底层数组容量
}
  • append(slice) 不需要显式初始化

slice需要通过make初始化完才可以使用,有一个例外,不初始化就可以使用:append

  • sort.Slice([] s) 是原地排序
var person = []RouteInfo { RouteInfo{},  RouteInfo{} }
    sort.Slice(person, func(i, j int) bool { // 原地降序
    // return person[i].Id > person[j].Id && person[i].Prefix > person[j].Prefix // 这个是做不到的先按照id,再按照Prefix排序的
    if person[i].Id == person[j].Id {
        return person[i].Prefix > person[j].Prefix
    }
    return person[i].Id > person[j].Id
})

3. map是指针

map 是指向一个hmap结构体的指针,对字典m执行unsafe.Sizeof(m) =8

Like slices, maps hold references to an underlying data structure. If you pass a map to a function that changes the contents of the map, the changes will be visible in the caller.

根据源代码中的注释描述 :

  1. map 是由一组哈希槽实现 ,每个哈希槽包含8个键值对; 低位hashcode 用于定位哈希槽,高位哈希值用于在槽内定位每个entry
  2. 槽内超过8条目,该槽会链接溢出槽
  3. 触发扩容, 2倍原始大小,增量拷贝。
// A header for a Go map.
type hmap struct {
    // Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
    // Make sure this stays in sync with the compiler's definition.
    count     int // # live cells == size of map.  Must be first (used by len() builtin)
    flags     uint8
    B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
    noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
    hash0     uint32 // hash seed

    buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
    oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
    nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)

    extra *mapextra // optional fields
}

map需要先初始化,才能使用, 否则会panic

可采用字面量或者make关键字初始化

mm := map[string]string{"k1": "v1", "k2": "v2"}  // map字面量
m := make(map[string]float64, 5)
m["pi"] = 3.14

map中取得不存在的键,会返回零值, 会误导对于该值存在性的判断

func main() {  
   x := map[string]string{"k1":"v1","k2":"v2"}
   if v := x["k3"]; v == "" {         // 不存在该键值对,会返回值类型的零值
       fmt.Println("①字典该key对应值为""  ②字典不存在该key")
   }
}

使用map取值的返回参数2 bool值来判断

func main() {  
    x := map[string]string{"k1":"v1","k2":"v2","k3":""}
    if _,ok := x["k3"]; !ok {
        fmt.Println("字典不存在该key")
    }
}

mapfor-range输出是随机的, 但是json.Marshalfmt.Printf是固定排序

说一个题外话: JSON字符串的值可以是null, 当json.Unmarshal反序列化时,null被反序列化为指定字段的零值。

4.channel 是指向hchan结构体的指针

type hchan struct {
    qcount   uint           // 队列中已有的缓存元素的长度
    dataqsiz uint           // 环形队列的长度
    buf      unsafe.Pointer // 环形队列的地址
    elemsize uint16
    closed   uint32
    elemtype *_type // 元素类型
    sendx    uint   // 待发送的元素索引
    recvx    uint   // 待接受元素索引
    recvq    waitq  // 阻塞等待的goroutine
    sendq    waitq  // 阻塞等待的gotoutine

    // lock protects all fields in hchan, as well as several
    // fields in sudogs blocked on this channel.
    //
    // Do not change another G's status while holding this lock
    // (in particular, do not ready a G), as this can deadlock
    // with stack shrinking.
    lock mutex
}

5. 数组array 是值类型

将数组array作为函数参数传递,函数内的修改不会体现在 原始数组上。

package main

import "fmt"

func changeFunc(arr [3]int) {
    arr[0] = 222
}
func main() {
    var arr [3]int = [3]int{1, 2, 3}
    changeFunc(arr)
    for i, item := range arr {
        fmt.Printf("index : %d, item: %d \n\r", i, item)    // 输出 1,2,3
    }
}

6. for-range 语法糖副本/ 全局变量陷阱

golang中除了经典的三段式for循环外,还有帮助快速遍历 slice array map channelfor range循环。

陷阱的来源:

slice map channel 是引用, array 是值。

for-range语法糖,Golang会在编译期对原切片/数组赋复制给一个新变量 ha,在赋值的过程中就发生了拷贝,且for-range内部的迭代变量是一个全局变量(1.21 之前), 给迭代变量赋值也会形成拷贝。

ha := a
hv1 := 0
hn := len(ha)
v1 := hv1
v2 := nil
for ; hv1 < hn; hv1++ {
    tmp := ha[hv1]
    v1, v2 = hv1, tmp
    ...
}

7. struct{} 类型占用空间为0

变量不一定都占用空间,struct{}类型指向看了一个固定地址, 不开辟空间。

  • 定义chan struct{} 信道, 用于协商goroutine的运行,而不发送数据
  • 可用于实现数学概念的集合(无重复元素): 利用map实现, 值为struct{}{}, 只关注key
  • 单纯的定义一个只包含方法的结构体
func worker(ch chan struct{}) {
    <-ch
    fmt.Println("do something")
    close(ch)
}

func main() {
    ch := make(chan struct{})
    go worker(ch)
    ch <- struct{}{}
}

8. string 是不可修改的结构体

在golang中,字符串是utf-8编码的字节序列, 对字符串执行unsafe.Sizeof(s) =16

  type  stringStruct  struct {
      str unsafe.Pointer   // 指向底层字符数组的指针
      len  int                  //  底层数组长度
  }
  1. for-range 字符串,默认情况下是迭代rune

uft-8 是一种变长编码方式,一个字符可能由一个或多个字节组成。 rune 表示一个unicode字符, 可以方便的处理 多字节字符, 故在for-range 字符串时默认是迭代rune而不是字节。

同大多数语言一样,golang的string是不可变的, 可尝试通过byte/rune 中转:

[]byte /[]rune 显式转化为string,最常见和安全的方式,适用于大多数场景:在转换时会创建一个新的字符串,并将字节数组的内容复制到新的字符串中。

  1. 都说string()显式转换[]byte性能比不上 指针方式,那什么时候使用不安全指针方式?

指针方式利用 unsafe包来避免数据复制,从而提高性能, 适用于对性能有极高要求且能够接受使用不安全代码的场景。 带来的问题:

  • 内存安全性:

unsafe包的使用会破坏Go语言的内存安全保证。如果[]byte在转换后的string仍在使用时被修改,可能导致数据不一致或者未定义的行为,因为string在Go中应该是不可变的。

  • 数据生命周期:

如果[]byte的底层数组在string仍在使用时被垃圾回收或者被其他数据覆盖,可能导致string指向无效内存区域。这会引发程序崩溃或产生不可预测的行为。

import (
    "reflect"
    "unsafe"
)

func UnsafePointer() {
    var b = []byte{'h', 'e', 'l', 'l', 'o'}
    var s string = *(*string)(unsafe.Pointer(&b))

    fmt.Printf("%s\n", s)    // 这里演示了通过unsafe.pointer 将[]byte 转换为string的效果
    b[0] = 'H'
    fmt.Printf("%s\n", s)

    s = "我们都有一个家" // string 是不可修改的,直接对string赋值实际是新申请空间去存储
    for _, c := range s {  //  这里编译器会提示迭代的元素类型是 rune
        fmt.Printf("%c", c)
    }
    for _, c := range b {
        fmt.Printf("%c", c)
    }
}

-- output
hello
Hello
我们都有一个家Hello

9. 变量

  1. 变量遮蔽

块内声明的变量会遮蔽上层的同名变量n

func main() {
    n := 0
    if true {
        n := 1
        n++
    }

    fmt.Println(n)     // 块内:=声明的变量, 遮蔽了外层变量,块内的操作对外层无影响  。   0 
}
  1. 可为空类型

golang 没有可为空类型这样的说法, 但是实际业务中我们要体现 null true false 这样的可为空值, 这在序列化时 null值会被忽略KV。

golang 可以使用指针类型达到C#可为空类型的效果:

// 定义 
ControlByJean        *bool    `json:"controlByJean,omitempty" bson:"controlByJean,omitempty" cfg:"controlByJean" web:"健康检查规则由jean控制"`

具体赋值时: 
  t = false
  ControlByJean  = &t  
  ControlByJean  = nil   

10. nil值比较

golang中:一个接口等于另一个接口,前提是它们的类型和动态值相同。这同样适用于nil值。

func Foo() error {
    var err *os.PathError = nil
    return err
}
func main() {
    err := Foo()
    fmt.Println(err)         // print: <nil>
    fmt.Println(err == nil)  // print: false
}

solution: 强转为同一类型

fmt.Println(err == (*os.PathError)(nil))  // print: true

或者显式返回nil error

func returnsError() error {
  if bad() {
      return ErrBad
  }
  return nil
}

在底层,接口被实现为两个元素,一个类型T和一个值V,V是一个具体的值,比如int、结构体或指针,而不是接口本身,它的类型是T, 上面的错误示例中: err 具备了T=*MyError, V=nil 的实现,故与nil不等。

只要记住,如果接口中存储了任何具体的值,该接口将不会为nil.

11. defer-panic()-recover()

defer 用于打扫战场

defer用于简化执行清理操作的函数, defer 语法将函数压栈,这些函数在包围的函数返回之后开始出栈(注意是包围的函数返回时出栈,不是代码块)

panic 系统异常/也可主动触发, 向上冒泡

  • panic 终止原始控制并开始panic,当F函数调用panic时, F函数执行终止,包围F函数的defer函数正常执行,之后执行流返回给上层调用者,对于上层调用者而言,F函数现在就像是panic()

  • 以上过程持续向上冒泡,直到当前goroutine中所有函数都能返回,这个时候程序崩溃, 所以要注意goroutine的健壮性。

panic is a built-in function that stops the ordinary flow of control and begins panicking. When the function F calls panic, execution of F stops, any deferred functions in F are executed normally, and then F returns to its caller. To the caller, F then behaves like a call to panic. The process continues up the stack until all functions in the current goroutine have returned, at which point the program crashes. Panics can be initiated by invoking panic directly. They can also be caused by runtime errors, such as out-of-bounds array accesses

一个比较好的实践是:打印错误和堆栈

func testDefer() *int64 {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println("stacktrace from panic:" + string(debug.Stack()))     // 打印错误堆栈
        }
    }()
    var a int64 = 1
    panic("panic....")       

    return &a
}

func main() {
    var ss *int64 = testDefer()
    fmt.Printf("ss=%+v\n", ss)
}

注意: testDefert函数内panic之后, 执行流立即回到defer recover()return &a 不会得到执行, 那么定义的临时返回变量将保持指针的零值, 未被赋值。

output:

stacktrace from panic:goroutine 1 [running]:
runtime/debug.Stack()
        /usr/local/go/src/runtime/debug/stack.go:24 +0x65
main.testDefer.func1()
        /Users/admin/test/test_sync_one/main.go:37 +0x2f
panic({0x10987c0, 0x10c9680})
        /usr/local/go/src/runtime/panic.go:884 +0x213
main.testDefer()
        /Users/admin/test/test_sync_one/main.go:41 +0x65
main.main()
        /Users/admin/test/test_sync_one/main.go:48 +0x2b

ss=<nil>

recover 用于捕获异常, 重获执行流, 拒绝程序崩溃

recover 用于(正在panicgoroutine)重新获得控制,recover 只能用在defer函数(这意味着,在常规代码块中调用recover,会返回nil并没有任何效果), 当前goroutine正在panic,调用recover会获得panic时候的参数,并重获执行流。

runtime 中的panic无法被捕获

业务代码的panic可以通过自己编写recover捕获并恢复控制,但runtime源码中主动throw函数抛出的panic是无法在业务代码中通过recover()函数捕获的,这点最为致命。

  • 并发读写map: 对于并发读写 map 的地方,应该对 map 加锁。
  • 堆栈内存耗尽
  • nil作为goroutine启动
  • goroutine死锁
  • 线程限制耗尽, Golang官方定义线程数1w
  • 超出可用内存
关于我
loading