[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()
}
对于 []byte
和 bytes.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)。结构体所包含的字段(包括方法)越多,那么两者之间的效率差距则越大。
总结源自:极客兔兔, 以及结合了自己在工作当中的实战经验