[Go] 我在工作中总结的 Golang 高性能编程(上)

10 天前(已编辑)
15
1

[Go] 我在工作中总结的 Golang 高性能编程(上)

struct{}

空结构体 struct{} 实例不占据任何的内存空间, 可以使用 unsafe.Sizeof 计算出一个数据类型实例需要占用的字节数,因为空结构体不占据内存空间,因此被广泛作为各种场景下的占位符使用。

有时候使用 channel 不需要发送任何的数据,只用来通知子协程执行/退出任务,或只用来控制协程并发度,这种情况下,声明为空结构体类型的 channel 是合适的。

第一种 struct{} 场景:通知子协程执行任务:

func worker(ch chan struct{}) {
    <-ch
    fmt.Println("do something")
    close(ch)
}

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

第二种 struct{} 场景:结构体只包含方法,不包含任何的字段。例如上面例子中的 Door,在这种情况下,声明为空结构体是最合适的。

type Door struct{}

func (d Door) Open() {
    fmt.Println("Open the door")
}

func (d Door) Close() {
    fmt.Println("Close the door")
}

第三种 struct{} 场景:map 作为集合(Set)使用时,可以将值类型定义为空结构体,仅作为占位符使用即可, 这样做可以节省内存。

type Set map[string]struct{}

func (s Set) Has(key string) bool {
    _, ok := s[key]
    return ok
}

func (s Set) Add(key string) {
    s[key] = struct{}{}
}

func (s Set) Delete(key string) {
    delete(s, key)
}

func main() {
    s := make(Set)
    s.Add("Tom")
    s.Add("Sam")
    fmt.Println(s.Has("Tom"))
    fmt.Println(s.Has("Jack"))
}

strings.Builder

在 Go 语言中,拼接字符串事实上是创建了一个新的字符串对象。如果代码中存在大量的字符串拼接,对性能会产生严重的影响。

结论:不建议直接使用 +fmt.Sprintf 做字符串拼接,在基准测试中,生成了一个长度为 10 的字符串,并拼接 1w 次,使用 + 和 fmt.Sprintf 的效率是最低的,和其余的方式(strings.Builder, []byte , bytes.Buffer )相比,性能相差约 1000 倍,而且消耗了超过 1000 倍的内存。

建议直接使用 strings.Builder 拼接字符串

func builderConcat (n int, str string) string {
  var builder strings.Builder

  builder.Grow(n * len(str))

  for i := n; i < n; i++ {
    builder.WriteString(str)
  }

  return builder.String()
}

对于 []bytebytes.Buffer 来说 , strings.Builder 省去了 []byte 和字符串(string) 之间的转换, 直接将底层的 []byte 转换成了字符串类型返回了回来。

// 直接将底层的 []byte 转换成了字符串类型返回
func (b *Builder) String() string {
    return *(*string)(unsafe.Pointer(&b.buf))
}

[] bytes 拼接

func byteConcat(n int, str string) string {
    buf := make([]byte, 0)
    for i := 0; i < n; i++ {
        buf = append(buf, str...)
    }
    return string(buf)
}

bytes.Buffer 拼接

func bufferConcat(n int, s string) string {
    buf := new(bytes.Buffer)
    for i := 0; i < n; i++ {
        buf.WriteString(s)
    }
    return buf.String()
}

slice

先聊聊数组,在 C 语言中,数组变量是指向第一个元素的指针,但是 Go 语言中并不是。Go 语言中,数组变量属于值类型(value type),因此当一个数组变量被赋值或者传递时,实际上会复制整个数组。例如,将 a 赋值给 b,修改 a 中的元素并不会改变 b 中的元素, 通常为了避免复制数组,一般会传递指向数组的指针。

a := [...]int{1, 2, 3} // ... 会自动计算数组长度
b := a
a[0] = 100
fmt.Println(a, b) // [100 2 3] [1 2 3]

slice 的性能陷阱

大量内存得不到释放, 由于在已有切片的基础上进行切片,不会创建新的底层数组。因为原来的底层数组没有发生变化,内存会一直占用,直到没有变量引用该数组。因此很可能出现这么一种情况,原切片由大量的元素构成,但是我们在原切片的基础上切片,虽然只使用了很小一段,但底层数组在内存中仍然占据了大量空间,得不到释放。比较推荐的做法,使用 copy 替代 re-slice。

// good
// 通过 copy,指向了一个新的底层数组,当 origin 不再被引用后,内存会被垃圾回收(garbage collector, GC)。
func lastNumsByCopy(origin []int) []int {
    result := make([]int, 2)
    copy(result, origin[len(origin)-2:])
    return result
}

// bad
func lastNumsBySlice(origin []int) []int {
    return origin[len(origin)-2:]
}

下面是常用的 slice CURD 操作:

slice copy

将 a slice 完整的 copy 到 b slice 下面三种方法都可以:

b = make([]T, len(a))
copy(b, a)
b = append([]T(nil), a...)
b = append(a[:0:0], a...)

slice append

切片有三个属性,指针(ptr)、长度(len) 和容量(cap)。append 时有两种场景:

  • 当 append 之后的长度小于等于 cap,将会直接利用原底层数组剩余的空间
  • 当 append 后的长度大于 cap 时,则会分配一块更大的区域来容纳新的底层数组

因此,为了避免内存发生拷贝,如果能够知道最终的切片的大小,预先设置 cap 的值能够获得最好的性能

a = append(a, b...)

slice delete

切片的底层是数组,因此删除意味着后面的元素需要逐个向前移位。每次删除的复杂度为 O(N),因此切片不合适大量随机删除的场景,这种场景下适合使用链表。

a = append(a[:i], a[i+1:]...)
a = [:i + copy(a[i:], a[i+1:])]
下面这种方式,删除指定元素后,将空余的位置置空,有助于垃圾回收(GC)
copy(a[i:], a[i+1:])

a[len(a) - 1] = nil
a = a[:len(a) -1]

slice insert

insert 和 append 类似。即在某个位置添加一个元素后,将该位置后面的元素再 append 回去。复杂度为 O(N)。因此,不适合大量随机插入的场景,这种场景下适合使用链表。

a = append(a[:i], append([]T{x}, a[i:]....)...)

slice filter

当原切片不会再被使用时,就地 filter 方式是比较推荐的,可以节省内存空间

n := 0

for _, x := range a {
  if keep(a) {
    a[n] = x
    n++
  }
}

a = a[:n]

slice push

  • 在末尾追加元素,不考虑内存拷贝的情况,复杂度为 O(1)
  • 在头部追加元素,时间和空间复杂度均为 O(N),不推荐

    // 尾部追加
    a = append(a, x)
// 头部追加
a = append([]T{x}, a...)

slice pop

  • 尾部删除元素,复杂度 O(1)
  • 头部删除元素,如果使用切片方式,复杂度为O(1)。但是需要注意的是,底层数组没有发生改变,第 0 个位置的内存仍旧没有释放。如果有大量这样的操作,头部的内存会一直被占用
// 尾部删除
x, a := a[len(a) - 1], a[:len(a) - 1]
// 头部删除
x, a := a[0], a[1:]

for | for range

  • for range 在迭代过程中返回的是迭代值的拷贝
  • 如果每次迭代的元素的内存占用很低(例如:[]int),那么 for 和 range 的性能几乎是一样
  • 如果 for range 迭代的元素是一个包含很多属性的 struct 结构体,那么 for 的性能将显著地高于 range,有时候甚至会有上千倍的性能差异
  • 如果想使用 range 同时迭代下标和值,则需要将切片/数组的元素改为指针,才能不影响性能

reflect

  • 尽量避免使用 reflect, 如果有替代方案情况下,比如我们要对结构体进行序列化和反序列化 可以替换 go 标准库的 json 为 easyjson
  • FieldByName 相比于 Field 有一个数量级的性能劣化,在实际的应用中,就要避免直接调用 FieldByName。我们可以利用字典将 Name 和 Index 的映射缓存起来(map),通过 Field 和 Index 来进行查找
  • Field 按照下标访问查询效率为 O(1), FieldByName 按照 Name 访问,则需要遍历所有字段,查询效率为 O(N)。结构体所包含的字段(包括方法)越多,那么两者之间的效率差距则越大。

总结源自:极客兔兔, 以及结合了自己在工作当中的实战经验

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...